From 945ce5fd3a0447b06357cfa69634c8384cb51d5c Mon Sep 17 00:00:00 2001 From: gabrielyoon7 Date: Mon, 9 Oct 2023 23:22:53 +0900 Subject: [PATCH 01/13] =?UTF-8?q?chore:=20next=20js=2013=20=EC=84=A4?= =?UTF-8?q?=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc.json | 3 + .gitignore | 35 + next.config.js | 4 + package-lock.json | 3647 +++++++++++++++++++++++++++++++++++++++ package.json | 24 + public/next.svg | 1 + public/vercel.svg | 1 + src/app/favicon.ico | Bin 0 -> 25931 bytes src/app/globals.css | 107 ++ src/app/layout.tsx | 22 + src/app/page.module.css | 229 +++ src/app/page.tsx | 95 + tsconfig.json | 27 + 13 files changed, 4195 insertions(+) create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 next.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/next.svg create mode 100644 public/vercel.svg create mode 100644 src/app/favicon.ico create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/page.module.css create mode 100644 src/app/page.tsx create mode 100644 tsconfig.json diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..bffb357a --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..8f322f0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/next.config.js b/next.config.js new file mode 100644 index 00000000..767719fc --- /dev/null +++ b/next.config.js @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..7a518fc5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3647 @@ +{ + "name": "frontend-rendering", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend-rendering", + "version": "0.1.0", + "dependencies": { + "next": "13.5.4", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "13.5.4", + "typescript": "^5" + } + }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.23.1", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.1.tgz", + "integrity": "sha512-hC2v6p8ZSI/W0HUzh3V8C5g+NwSKzKPtJwSpTjwl0o297GP9+ZLQSkdvHz46CM3LqyoXxq+5G9komY+eSqSO0g==", + "dev": true, + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.9.1.tgz", + "integrity": "sha512-Y27x+MBLjXa+0JWDhykM3+JE+il3kHKAEqabfEWq3SDhZjLYb6/BHL/JKFnH3fe207JaXkyDo685Oc2Glt6ifA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.2.tgz", + "integrity": "sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.51.0.tgz", + "integrity": "sha512-HxjQ8Qn+4SI3/AFv6sOrDB+g6PpUTDwSJiQqOrnneEk8L71161srI9gjzzZvYVbzHiVg/BvcH95+cK/zfIt4pg==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz", + "integrity": "sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^1.2.1", + "debug": "^4.1.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true + }, + "node_modules/@next/env": { + "version": "13.5.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.4.tgz", + "integrity": "sha512-LGegJkMvRNw90WWphGJ3RMHMVplYcOfRWf2Be3td3sUa+1AaxmsYyANsA+znrGCBjXJNi4XAQlSoEfUxs/4kIQ==" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "13.5.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-13.5.4.tgz", + "integrity": "sha512-vI94U+D7RNgX6XypSyjeFrOzxGlZyxOplU0dVE5norIfZGn/LDjJYPHdvdsR5vN1eRtl6PDAsOHmycFEOljK5A==", + "dev": true, + "dependencies": { + "glob": "7.1.7" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "13.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.4.tgz", + "integrity": "sha512-Df8SHuXgF1p+aonBMcDPEsaahNo2TCwuie7VXED4FVyECvdXfRT9unapm54NssV9tF3OQFKBFOdlje4T43VO0w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "13.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.4.tgz", + "integrity": "sha512-siPuUwO45PnNRMeZnSa8n/Lye5ZX93IJom9wQRB5DEOdFrw0JjOMu1GINB8jAEdwa7Vdyn1oJ2xGNaQpdQQ9Pw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "13.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.4.tgz", + "integrity": "sha512-l/k/fvRP/zmB2jkFMfefmFkyZbDkYW0mRM/LB+tH5u9pB98WsHXC0WvDHlGCYp3CH/jlkJPL7gN8nkTQVrQ/2w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "13.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.4.tgz", + "integrity": "sha512-YYGb7SlLkI+XqfQa8VPErljb7k9nUnhhRrVaOdfJNCaQnHBcvbT7cx/UjDQLdleJcfyg1Hkn5YSSIeVfjgmkTg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "13.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.4.tgz", + "integrity": "sha512-uE61vyUSClnCH18YHjA8tE1prr/PBFlBFhxBZis4XBRJoR+txAky5d7gGNUIbQ8sZZ7LVkSVgm/5Fc7mwXmRAg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "13.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.4.tgz", + "integrity": "sha512-qVEKFYML/GvJSy9CfYqAdUexA6M5AklYcQCW+8JECmkQHGoPxCf04iMh7CPR7wkHyWWK+XLt4Ja7hhsPJtSnhg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "13.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.4.tgz", + "integrity": "sha512-mDSQfqxAlfpeZOLPxLymZkX0hYF3juN57W6vFHTvwKlnHfmh12Pt7hPIRLYIShk8uYRsKPtMTth/EzpwRI+u8w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "13.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.4.tgz", + "integrity": "sha512-aoqAT2XIekIWoriwzOmGFAvTtVY5O7JjV21giozBTP5c6uZhpvTWRbmHXbmsjZqY4HnEZQRXWkSAppsIBweKqw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "13.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.4.tgz", + "integrity": "sha512-cyRvlAxwlddlqeB9xtPSfNSCRy8BOa4wtMo0IuI9P7Y0XT2qpDrpFKRyZ7kUngZis59mPVla5k8X1oOJ8RxDYg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.5.1.tgz", + "integrity": "sha512-6i/8UoL0P5y4leBIGzvkZdS85RDMG9y1ihZzmTZQ5LdHUYmZ7pKFoj8X0236s3lusPs1Fa5HTQUpwI+UfTcmeA==", + "dev": true + }, + "node_modules/@swc/helpers": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", + "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.3.tgz", + "integrity": "sha512-jxiZQFpb+NlH5kjW49vXxvxTjeeqlbsnTAdBTKpzEdPs9itay7MscYXz3Fo9VYFEsfQ6LJFitHad3faerLAjCw==", + "dev": true + }, + "node_modules/@types/prop-types": { + "version": "15.7.8", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.8.tgz", + "integrity": "sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.2.25", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.25.tgz", + "integrity": "sha512-24xqse6+VByVLIr+xWaQ9muX1B4bXJKXBbjszbld/UEDslGLY53+ZucF44HCmLbMPejTzGG9XgR+3m2/Wqu1kw==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.2.11", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.11.tgz", + "integrity": "sha512-zq6Dy0EiCuF9pWFW6I6k6W2LdpUixLE4P6XjXU1QHLfak3GPACQfLwEuHzY5pOYa4hzj1d0GxX/P141aFjZsyg==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.4", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.4.tgz", + "integrity": "sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ==", + "dev": true + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.7.4.tgz", + "integrity": "sha512-I5zVZFY+cw4IMZUeNCU7Sh2PO5O57F7Lr0uyhgCJmhN/BuTlnc55KxPonR4+EM3GBdfiCyGZye6DgMjtubQkmA==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "6.7.4", + "@typescript-eslint/types": "6.7.4", + "@typescript-eslint/typescript-estree": "6.7.4", + "@typescript-eslint/visitor-keys": "6.7.4", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.7.4.tgz", + "integrity": "sha512-SdGqSLUPTXAXi7c3Ob7peAGVnmMoGzZ361VswK2Mqf8UOYcODiYvs8rs5ILqEdfvX1lE7wEZbLyELCW+Yrql1A==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.7.4", + "@typescript-eslint/visitor-keys": "6.7.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.7.4.tgz", + "integrity": "sha512-o9XWK2FLW6eSS/0r/tgjAGsYasLAnOWg7hvZ/dGYSSNjCh+49k5ocPN8OmG5aZcSJ8pclSOyVKP2x03Sj+RrCA==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.7.4.tgz", + "integrity": "sha512-ty8b5qHKatlNYd9vmpHooQz3Vki3gG+3PchmtsA4TgrZBKWHNjWfkQid7K7xQogBqqc7/BhGazxMD5vr6Ha+iQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.7.4", + "@typescript-eslint/visitor-keys": "6.7.4", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.7.4.tgz", + "integrity": "sha512-pOW37DUhlTZbvph50x5zZCkFn3xzwkGtNoJHzIM3svpiSkJzwOYr/kVBaXmf+RAQiUDs1AHEZVNPg6UJCJpwRA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.7.4", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", + "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", + "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.2.tgz", + "integrity": "sha512-HuQCHOlk1Weat5jzStICBCd83NxiIMwqDg/dHEsoefabn/hJRj5pVdWcPUSpRrwhwxZOsQassMpgN/xRYFBMIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", + "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==", + "dev": true + }, + "node_modules/asynciterator.prototype": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/asynciterator.prototype/-/asynciterator.prototype-1.0.0.tgz", + "integrity": "sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.8.2.tgz", + "integrity": "sha512-/dlp0fxyM3R8YW7MFzaHWXrf4zzbr0vaYb23VBFCl83R7nWNPg/yaQw2Dc8jzCMmDVLhSdzH8MjrsuIUuvX+6g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", + "integrity": "sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001546", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001546.tgz", + "integrity": "sha512-zvtSJwuQFpewSyRrI3AsftF6rM0X80mZkChIt1spBGEvRglCrjTniXvinc8JKRoqTwXAgvqTImaN9igfSMtUBw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, + "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, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "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 + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", + "dev": true + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/define-data-property": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.0.tgz", + "integrity": "sha512-UzGwzcjyv3OtAvolTj1GoyNYzfFR+iqbGjcnBEENZVCpM4/Ng1yhGNvS3lR/xDS74Tb2wGG9WzNSNIOS9UVb2g==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/enhanced-resolve": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.22.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.2.tgz", + "integrity": "sha512-YoxfFcDmhjOgWPWsV13+2RNjq1F6UQnfs+8TftwNqtzlmFzEXvlUwdrNrYeaizfjQzRMxkZ6ElWMOJIFKdVqwA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "es-set-tostringtag": "^2.0.1", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.1", + "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has": "^1.0.3", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", + "is-weakref": "^1.0.2", + "object-inspect": "^1.12.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", + "safe-regex-test": "^1.0.0", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.11" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", + "integrity": "sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==", + "dev": true, + "dependencies": { + "asynciterator.prototype": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.1", + "es-set-tostringtag": "^2.0.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "iterator.prototype": "^1.1.2", + "safe-array-concat": "^1.0.1" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz", + "integrity": "sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3", + "has": "^1.0.3", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", + "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", + "dev": true, + "dependencies": { + "has": "^1.0.3" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.51.0.tgz", + "integrity": "sha512-2WuxRZBrlwnXi+/vFSJyjMqrNjtJqiasMzehF0shoLaW7DzS3/9Yvrmq5JiT66+pNjiX4UBnLDiKHcWAr/OInA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.2", + "@eslint/js": "8.51.0", + "@humanwhocodes/config-array": "^0.11.11", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-next": { + "version": "13.5.4", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-13.5.4.tgz", + "integrity": "sha512-FzQGIj4UEszRX7fcRSJK6L1LrDiVZvDFW320VVntVKh3BSU8Fb9kpaoxQx0cdFgf3MQXdeSbrCXJ/5Z/NndDkQ==", + "dev": true, + "dependencies": { + "@next/eslint-plugin-next": "13.5.4", + "@rushstack/eslint-patch": "^1.3.3", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "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", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.1.tgz", + "integrity": "sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "enhanced-resolve": "^5.12.0", + "eslint-module-utils": "^2.7.4", + "fast-glob": "^3.3.1", + "get-tsconfig": "^4.5.0", + "is-core-module": "^2.11.0", + "is-glob": "^4.0.3" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts/projects/eslint-import-resolver-ts" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", + "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", + "dev": true, + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.28.1.tgz", + "integrity": "sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.findlastindex": "^1.2.2", + "array.prototype.flat": "^1.3.1", + "array.prototype.flatmap": "^1.3.1", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.7", + "eslint-module-utils": "^2.8.0", + "has": "^1.0.3", + "is-core-module": "^2.13.0", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.6", + "object.groupby": "^1.0.0", + "object.values": "^1.1.6", + "semver": "^6.3.1", + "tsconfig-paths": "^3.14.2" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.7.1.tgz", + "integrity": "sha512-63Bog4iIethyo8smBklORknVjB0T2dwB8Mr/hIC+fBS0uyHdYYpzM/Ed+YC8VxTjlXHEWFOdmgwcDn1U2L9VCA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.20.7", + "aria-query": "^5.1.3", + "array-includes": "^3.1.6", + "array.prototype.flatmap": "^1.3.1", + "ast-types-flow": "^0.0.7", + "axe-core": "^4.6.2", + "axobject-query": "^3.1.1", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "has": "^1.0.3", + "jsx-ast-utils": "^3.3.3", + "language-tags": "=1.0.5", + "minimatch": "^3.1.2", + "object.entries": "^1.1.6", + "object.fromentries": "^2.0.6", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.33.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz", + "integrity": "sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flatmap": "^1.3.1", + "array.prototype.tosorted": "^1.1.1", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.0.12", + "estraverse": "^5.3.0", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.6", + "object.fromentries": "^2.0.6", + "object.hasown": "^1.1.2", + "object.values": "^1.1.6", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.4", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.8" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", + "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.4", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz", + "integrity": "sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.9.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz", + "integrity": "sha512-/qM2b3LUIaIgviBQovTLvijfyOQXPtSRnRK26ksj2J7rzPIecePUIpJsZ4T02Qg+xiAEKIs5K8dsHEd+VaKa/Q==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "dev": true + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", + "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.2.tgz", + "integrity": "sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + }, + "node_modules/globals": { + "version": "13.23.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", + "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz", + "integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", + "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ignore": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", + "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/internal-slot": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", + "integrity": "sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.0", + "has": "^1.0.3", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", + "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==", + "dev": true, + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", + "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", + "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", + "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", + "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.11" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", + "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", + "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/iterator.prototype": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", + "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.4", + "set-function-name": "^2.0.1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", + "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==", + "dev": true + }, + "node_modules/language-tags": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", + "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==", + "dev": true, + "dependencies": { + "language-subtag-registry": "~0.3.2" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/next": { + "version": "13.5.4", + "resolved": "https://registry.npmjs.org/next/-/next-13.5.4.tgz", + "integrity": "sha512-+93un5S779gho8y9ASQhb/bTkQF17FNQOtXLKAj3lsNgltEcF0C5PMLLncDmH+8X1EnJH1kbqAERa29nRXqhjA==", + "dependencies": { + "@next/env": "13.5.4", + "@swc/helpers": "0.5.2", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001406", + "postcss": "8.4.31", + "styled-jsx": "5.1.1", + "watchpack": "2.4.0" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=16.14.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "13.5.4", + "@next/swc-darwin-x64": "13.5.4", + "@next/swc-linux-arm64-gnu": "13.5.4", + "@next/swc-linux-arm64-musl": "13.5.4", + "@next/swc-linux-x64-gnu": "13.5.4", + "@next/swc-linux-x64-musl": "13.5.4", + "@next/swc-win32-arm64-msvc": "13.5.4", + "@next/swc-win32-ia32-msvc": "13.5.4", + "@next/swc-win32-x64-msvc": "13.5.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", + "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", + "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.7.tgz", + "integrity": "sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", + "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1" + } + }, + "node_modules/object.hasown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.3.tgz", + "integrity": "sha512-fFI4VcYpRHvSLXxP7yiZOMAd331cPfd2p7PFDVbgUsYOfCT3tICVqXWngbjr4m49OvsBwUBQ6O2uQoJvy3RexA==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, + "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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" + } + ], + "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", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", + "integrity": "sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "which-builtin-type": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==", + "dev": true + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.6.tgz", + "integrity": "sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", + "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "is-regex": "^1.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz", + "integrity": "sha512-rGXbGmOEosIQi6Qva94HUjgPs9vKW+dkG7Y8Q5O2OYkWL6wFaTRZO8zM4mhP94uX55wgyrXzfS2aGtGzUL7EJQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "regexp.prototype.flags": "^1.5.0", + "set-function-name": "^2.0.0", + "side-channel": "^1.0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", + "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", + "dev": true, + "engines": { + "node": ">=16.13.0" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", + "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", + "dev": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.3.tgz", + "integrity": "sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==", + "dev": true, + "dependencies": { + "function.prototype.name": "^1.1.5", + "has-tostringtag": "^1.0.0", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.0.2", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", + "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", + "dev": true, + "dependencies": { + "is-map": "^2.0.1", + "is-set": "^2.0.1", + "is-weakmap": "^2.0.1", + "is-weakset": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.11.tgz", + "integrity": "sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..2ae36f83 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "frontend-rendering", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "react": "^18", + "react-dom": "^18", + "next": "13.5.4" + }, + "devDependencies": { + "typescript": "^5", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "13.5.4" + } +} diff --git a/public/next.svg b/public/next.svg new file mode 100644 index 00000000..5174b28c --- /dev/null +++ b/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 00000000..d2f84222 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 00000000..d4f491e1 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,107 @@ +:root { + --max-width: 1100px; + --border-radius: 12px; + --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', + 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', + 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; + + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; + + --primary-glow: conic-gradient( + from 180deg at 50% 50%, + #16abff33 0deg, + #0885ff33 55deg, + #54d6ff33 120deg, + #0071ff33 160deg, + transparent 360deg + ); + --secondary-glow: radial-gradient( + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0) + ); + + --tile-start-rgb: 239, 245, 249; + --tile-end-rgb: 228, 232, 233; + --tile-border: conic-gradient( + #00000080, + #00000040, + #00000030, + #00000020, + #00000010, + #00000010, + #00000080 + ); + + --callout-rgb: 238, 240, 241; + --callout-border-rgb: 172, 175, 176; + --card-rgb: 180, 185, 188; + --card-border-rgb: 131, 134, 135; +} + +@media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + + --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); + --secondary-glow: linear-gradient( + to bottom right, + rgba(1, 65, 255, 0), + rgba(1, 65, 255, 0), + rgba(1, 65, 255, 0.3) + ); + + --tile-start-rgb: 2, 13, 46; + --tile-end-rgb: 2, 5, 19; + --tile-border: conic-gradient( + #ffffff80, + #ffffff40, + #ffffff30, + #ffffff20, + #ffffff10, + #ffffff10, + #ffffff80 + ); + + --callout-rgb: 20, 20, 20; + --callout-border-rgb: 108, 108, 108; + --card-rgb: 100, 100, 100; + --card-border-rgb: 200, 200, 200; + } +} + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +html, +body { + max-width: 100vw; + overflow-x: hidden; +} + +body { + color: rgb(var(--foreground-rgb)); + background: linear-gradient( + to bottom, + transparent, + rgb(var(--background-end-rgb)) + ) + rgb(var(--background-start-rgb)); +} + +a { + color: inherit; + text-decoration: none; +} + +@media (prefers-color-scheme: dark) { + html { + color-scheme: dark; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 00000000..ae845621 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,22 @@ +import './globals.css' +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' + +const inter = Inter({ subsets: ['latin'] }) + +export const metadata: Metadata = { + title: 'Create Next App', + description: 'Generated by create next app', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/src/app/page.module.css b/src/app/page.module.css new file mode 100644 index 00000000..6676d2c6 --- /dev/null +++ b/src/app/page.module.css @@ -0,0 +1,229 @@ +.main { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + padding: 6rem; + min-height: 100vh; +} + +.description { + display: inherit; + justify-content: inherit; + align-items: inherit; + font-size: 0.85rem; + max-width: var(--max-width); + width: 100%; + z-index: 2; + font-family: var(--font-mono); +} + +.description a { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; +} + +.description p { + position: relative; + margin: 0; + padding: 1rem; + background-color: rgba(var(--callout-rgb), 0.5); + border: 1px solid rgba(var(--callout-border-rgb), 0.3); + border-radius: var(--border-radius); +} + +.code { + font-weight: 700; + font-family: var(--font-mono); +} + +.grid { + display: grid; + grid-template-columns: repeat(4, minmax(25%, auto)); + max-width: 100%; + width: var(--max-width); +} + +.card { + padding: 1rem 1.2rem; + border-radius: var(--border-radius); + background: rgba(var(--card-rgb), 0); + border: 1px solid rgba(var(--card-border-rgb), 0); + transition: background 200ms, border 200ms; +} + +.card span { + display: inline-block; + transition: transform 200ms; +} + +.card h2 { + font-weight: 600; + margin-bottom: 0.7rem; +} + +.card p { + margin: 0; + opacity: 0.6; + font-size: 0.9rem; + line-height: 1.5; + max-width: 30ch; +} + +.center { + display: flex; + justify-content: center; + align-items: center; + position: relative; + padding: 4rem 0; +} + +.center::before { + background: var(--secondary-glow); + border-radius: 50%; + width: 480px; + height: 360px; + margin-left: -400px; +} + +.center::after { + background: var(--primary-glow); + width: 240px; + height: 180px; + z-index: -1; +} + +.center::before, +.center::after { + content: ''; + left: 50%; + position: absolute; + filter: blur(45px); + transform: translateZ(0); +} + +.logo { + position: relative; +} +/* Enable hover only on non-touch devices */ +@media (hover: hover) and (pointer: fine) { + .card:hover { + background: rgba(var(--card-rgb), 0.1); + border: 1px solid rgba(var(--card-border-rgb), 0.15); + } + + .card:hover span { + transform: translateX(4px); + } +} + +@media (prefers-reduced-motion) { + .card:hover span { + transform: none; + } +} + +/* Mobile */ +@media (max-width: 700px) { + .content { + padding: 4rem; + } + + .grid { + grid-template-columns: 1fr; + margin-bottom: 120px; + max-width: 320px; + text-align: center; + } + + .card { + padding: 1rem 2.5rem; + } + + .card h2 { + margin-bottom: 0.5rem; + } + + .center { + padding: 8rem 0 6rem; + } + + .center::before { + transform: none; + height: 300px; + } + + .description { + font-size: 0.8rem; + } + + .description a { + padding: 1rem; + } + + .description p, + .description div { + display: flex; + justify-content: center; + position: fixed; + width: 100%; + } + + .description p { + align-items: center; + inset: 0 0 auto; + padding: 2rem 1rem 1.4rem; + border-radius: 0; + border: none; + border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); + background: linear-gradient( + to bottom, + rgba(var(--background-start-rgb), 1), + rgba(var(--callout-rgb), 0.5) + ); + background-clip: padding-box; + backdrop-filter: blur(24px); + } + + .description div { + align-items: flex-end; + pointer-events: none; + inset: auto 0 0; + padding: 2rem; + height: 200px; + background: linear-gradient( + to bottom, + transparent 0%, + rgb(var(--background-end-rgb)) 40% + ); + z-index: 1; + } +} + +/* Tablet and Smaller Desktop */ +@media (min-width: 701px) and (max-width: 1120px) { + .grid { + grid-template-columns: repeat(2, 50%); + } +} + +@media (prefers-color-scheme: dark) { + .vercelLogo { + filter: invert(1); + } + + .logo { + filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); + } +} + +@keyframes rotate { + from { + transform: rotate(360deg); + } + to { + transform: rotate(0deg); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 00000000..9ddf9b95 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,95 @@ +import Image from 'next/image' +import styles from './page.module.css' + +export default function Home() { + return ( +
+
+

+ Get started by editing  + src/app/page.tsx +

+ +
+ +
+ Next.js Logo +
+ + +
+ ) +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..e59724b2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} From 14b84a8cc9360efc58f4952576ef2856421e0522 Mon Sep 17 00:00:00 2001 From: gabrielyoon7 Date: Tue, 10 Oct 2023 02:01:09 +0900 Subject: [PATCH 02/13] =?UTF-8?q?chore:=20prettier=20=EC=84=A4=EC=B9=98,?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .prettierrc | 30 +++++++++ package-lock.json | 23 ++++++- package.json | 4 +- src/app/globals.css | 161 +++++++++++++++++++++----------------------- src/app/page.tsx | 114 ++++++------------------------- 5 files changed, 151 insertions(+), 181 deletions(-) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..75e9d553 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,30 @@ +{ + "endOfLine": "auto", + "singleQuote": true, + "tabWidth": 2, + "printWidth": 100, + "semi": true, + "trailingComma": "es5", + "importOrder": [ + "^react(.*)", + "^@tanstack/(.*)$", + "^@map/(.*)$", + "^@marker/(.*)$", + "^@utils/(.*)$", + "^@stores/(.*)$", + "^@hooks/(.*)$", + "^@common/(.*)$", + "^@components/(.*)$", + "^@ui/(.*)$", + "^App", + "^mocks", + "^@style", + "^style/(.*)$", + "^@constants", + "^@type", + "^@assets/(.*)$", + "^[./]" + ], + "importOrderSeparation": true, + "plugins": ["@trivago/prettier-plugin-sort-imports"] +} diff --git a/package-lock.json b/package-lock.json index 7a518fc5..e0351678 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend-rendering", "version": "0.1.0", "dependencies": { + "@googlemaps/react-wrapper": "^1.1.35", "next": "13.5.4", "react": "^18", "react-dom": "^18" @@ -98,6 +99,25 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@googlemaps/js-api-loader": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.2.tgz", + "integrity": "sha512-psGw5u0QM6humao48Hn4lrChOM2/rA43ZCm3tKK9qQsEj1/VzqkCqnvGfEOshDbBQflydfaRovbKwZMF4AyqbA==", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/@googlemaps/react-wrapper": { + "version": "1.1.35", + "resolved": "https://registry.npmjs.org/@googlemaps/react-wrapper/-/react-wrapper-1.1.35.tgz", + "integrity": "sha512-vK+BDQMHN0Oqr66cW3ZPWVK43BUmJJBu6P8T74tc6/fKpUJUlFEaZsupgIIRRRDW9ejB8uGagUmwOnA2gdcvbw==", + "dependencies": { + "@googlemaps/js-api-loader": "^1.13.2" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz", @@ -1531,8 +1551,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.1", diff --git a/package.json b/package.json index 2ae36f83..c9e5a603 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@googlemaps/react-wrapper": "^1.1.35", "react": "^18", "react-dom": "^18", "next": "13.5.4" @@ -19,6 +20,7 @@ "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^8", - "eslint-config-next": "13.5.4" + "eslint-config-next": "13.5.4", + "prettier": "^3.0.1" } } diff --git a/src/app/globals.css b/src/app/globals.css index d4f491e1..6c5bd480 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,107 +1,96 @@ -:root { - --max-width: 1100px; - --border-radius: 12px; - --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', - 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', - 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; +/* http://meyerweb.com/eric/tools/css/reset/ + v5.0.1 | 20191019 + License: none (public domain) +*/ - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, menu, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +main, menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} - --primary-glow: conic-gradient( - from 180deg at 50% 50%, - #16abff33 0deg, - #0885ff33 55deg, - #54d6ff33 120deg, - #0071ff33 160deg, - transparent 360deg - ); - --secondary-glow: radial-gradient( - rgba(255, 255, 255, 1), - rgba(255, 255, 255, 0) - ); +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, main, menu, nav, section { + display: block; +} - --tile-start-rgb: 239, 245, 249; - --tile-end-rgb: 228, 232, 233; - --tile-border: conic-gradient( - #00000080, - #00000040, - #00000030, - #00000020, - #00000010, - #00000010, - #00000080 - ); +/* HTML5 hidden-attribute fix for newer browsers */ +*[hidden] { + display: none; +} - --callout-rgb: 238, 240, 241; - --callout-border-rgb: 172, 175, 176; - --card-rgb: 180, 185, 188; - --card-border-rgb: 131, 134, 135; +body { + line-height: 1; } -@media (prefers-color-scheme: dark) { - :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; +menu, ol, ul, dd { + list-style: none; +} - --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); - --secondary-glow: linear-gradient( - to bottom right, - rgba(1, 65, 255, 0), - rgba(1, 65, 255, 0), - rgba(1, 65, 255, 0.3) - ); +blockquote, q { + quotes: none; +} - --tile-start-rgb: 2, 13, 46; - --tile-end-rgb: 2, 5, 19; - --tile-border: conic-gradient( - #ffffff80, - #ffffff40, - #ffffff30, - #ffffff20, - #ffffff10, - #ffffff10, - #ffffff80 - ); +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} - --callout-rgb: 20, 20, 20; - --callout-border-rgb: 108, 108, 108; - --card-rgb: 100, 100, 100; - --card-border-rgb: 200, 200, 200; - } +table { + border-collapse: collapse; + border-spacing: 0; } -* { +/****** Elad Shechter's RESET *******/ +/*** box sizing border-box for all elements ***/ +*, +*::before, +*::after { box-sizing: border-box; - padding: 0; - margin: 0; } -html, -body { - max-width: 100vw; - overflow-x: hidden; +a { + text-decoration: none; + color: inherit; + cursor: pointer; } -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); +button { + background-color: transparent; + color: inherit; + border-width: 0; + padding: 0; + cursor: pointer; } -a { - color: inherit; - text-decoration: none; +input::-moz-focus-inner { + border: 0; + padding: 0; + margin: 0; } -@media (prefers-color-scheme: dark) { - html { - color-scheme: dark; - } +progress { + border: none; + box-shadow: none; +} +progress[value] { + -webkit-appearance: none; + appearance: none; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 9ddf9b95..56c38f13 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,95 +1,25 @@ -import Image from 'next/image' -import styles from './page.module.css' +'use client'; +import { Status, Wrapper } from '@googlemaps/react-wrapper'; -export default function Home() { - return ( -
-
-

- Get started by editing  - src/app/page.tsx -

- -
- -
- Next.js Logo -
- -
- -

- Docs -> -

-

Find in-depth information about Next.js features and API.

-
+const render = (status: Status) => { + switch (status) { + case Status.LOADING: + return
로딩중
; + case Status.FAILURE: + return <>에러 발생; + case Status.SUCCESS: + return <>지도; + } +}; - -

- Learn -> -

-

Learn about Next.js in an interactive course with quizzes!

-
- - -

- Templates -> -

-

Explore the Next.js 13 playground.

-
+const App = () => { + return ( + + ); +}; - -

- Deploy -> -

-

- Instantly deploy your Next.js site to a shareable URL with Vercel. -

-
-
-
- ) -} +export default App; From 8df92ffc97eb2b228a805a1c53cc35011d51597e Mon Sep 17 00:00:00 2001 From: gabrielyoon7 Date: Tue, 10 Oct 2023 02:15:55 +0900 Subject: [PATCH 03/13] =?UTF-8?q?chore:=20=EB=8B=A8=EC=88=9C=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=ED=98=B8=EC=B6=9C=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + src/app/page.tsx | 22 ++- .../google-maps/map/CarFfeineListener.tsx | 58 +++++++ .../google-maps/map/CarFfeineMap.tsx | 11 ++ .../LargeDeltaAreaMarkerContainer.tsx | 18 ++ .../LargeDeltaAreaMarkerContainer/index.ts | 3 + .../google-maps/marker/MarkerContainers.tsx | 24 +++ .../MaxDeltaAreaMarkerContainer.tsx | 13 ++ .../components/RegionMarker.stories.tsx | 32 ++++ .../components/RegionMarker.tsx | 44 +++++ .../components/RegionMarkerRenderer.tsx | 22 +++ .../hooks/useRegionMarkers.ts | 28 +++ .../hooks/useRenderRegionMarker.tsx | 40 +++++ .../MaxDeltaAreaMarkerContainer/index.ts | 3 + .../types/index.ts | 24 +++ .../SmallMediumDeltaAreaMarkerContainer.tsx | 73 ++++++++ .../CarFfeineMarker/CarFfeine.stories.tsx | 30 ++++ .../CarFfeineMarker/CarFfeineMarker.style.ts | 60 +++++++ .../CarFfeineMarker/CarFfeineMarker.test.tsx | 35 ++++ .../CarFfeineMarker/CarFfeineMarker.tsx | 25 +++ .../components/CarFfeineMarker/index.ts | 3 + .../constants/index.ts | 1 + .../hooks/useRenderStationMarker.tsx | 160 ++++++++++++++++++ .../hooks/useStationMarkers.ts | 120 +++++++++++++ .../index.ts | 3 + src/constants/chargers.ts | 140 +++++++++++++++ src/constants/congestion.ts | 37 ++++ src/constants/errorMessages.ts | 8 + src/constants/googleMaps.ts | 18 ++ src/constants/index.ts | 20 +++ src/constants/queryKeys.ts | 13 ++ src/constants/server.ts | 6 + src/constants/stationSearch.ts | 3 + src/constants/storageKeys.ts | 9 + .../deltaAreaStore/deltaAreaStore.test.ts | 14 ++ .../deltaAreaStore/deltaAreaStore.ts | 31 ++++ .../google-maps/deltaAreaStore/index.ts | 4 + .../google-maps/deltaAreaStore/types.ts | 7 + src/stores/google-maps/googleMapStore.ts | 93 ++++++++++ src/stores/google-maps/markerInstanceStore.ts | 8 + .../google-maps/stationInfoWindowStore.ts | 35 ++++ src/stores/layout/modalStore.ts | 17 ++ src/stores/layout/navigationBarPanelStore.tsx | 13 ++ src/stores/layout/toastStore.tsx | 30 ++++ src/stores/layout/warningModalStore.ts | 17 ++ src/stores/login/memberInfoStore.ts | 52 ++++++ src/stores/login/memberTokenStore.ts | 39 +++++ src/stores/profileMenuOpenStore.ts | 3 + .../clientStationFiltersStore.ts | 16 ++ .../serverStationFiltersStore.ts | 61 +++++++ src/utils/external-state/StateManager.test.ts | 14 ++ src/utils/external-state/StateManager.ts | 48 ++++++ src/utils/external-state/index.ts | 1 + src/utils/external-state/tools.test.tsx | 59 +++++++ src/utils/external-state/tools.ts | 36 ++++ src/utils/google-maps/getBounds.test.ts | 26 +++ src/utils/google-maps/getBounds.ts | 15 ++ .../google-maps/getCalculatedMapDelta.ts | 27 +++ src/utils/google-maps/index.ts | 24 +++ src/utils/google-maps/isCachedRegion.test.ts | 52 ++++++ src/utils/google-maps/isCachedRegion.ts | 41 +++++ src/utils/storage/index.ts | 19 +++ tsconfig.json | 4 +- 63 files changed, 1904 insertions(+), 10 deletions(-) create mode 100644 src/components/google-maps/map/CarFfeineListener.tsx create mode 100644 src/components/google-maps/map/CarFfeineMap.tsx create mode 100644 src/components/google-maps/marker/LargeDeltaAreaMarkerContainer/LargeDeltaAreaMarkerContainer.tsx create mode 100644 src/components/google-maps/marker/LargeDeltaAreaMarkerContainer/index.ts create mode 100644 src/components/google-maps/marker/MarkerContainers.tsx create mode 100644 src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/MaxDeltaAreaMarkerContainer.tsx create mode 100644 src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarker.stories.tsx create mode 100644 src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarker.tsx create mode 100644 src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarkerRenderer.tsx create mode 100644 src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRegionMarkers.ts create mode 100644 src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRenderRegionMarker.tsx create mode 100644 src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/index.ts create mode 100644 src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/types/index.ts create mode 100644 src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/SmallMediumDeltaAreaMarkerContainer.tsx create mode 100644 src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeine.stories.tsx create mode 100644 src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeineMarker.style.ts create mode 100644 src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeineMarker.test.tsx create mode 100644 src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeineMarker.tsx create mode 100644 src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/index.ts create mode 100644 src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/constants/index.ts create mode 100644 src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/hooks/useRenderStationMarker.tsx create mode 100644 src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/hooks/useStationMarkers.ts create mode 100644 src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/index.ts create mode 100644 src/constants/chargers.ts create mode 100644 src/constants/congestion.ts create mode 100644 src/constants/errorMessages.ts create mode 100644 src/constants/googleMaps.ts create mode 100644 src/constants/index.ts create mode 100644 src/constants/queryKeys.ts create mode 100644 src/constants/server.ts create mode 100644 src/constants/stationSearch.ts create mode 100644 src/constants/storageKeys.ts create mode 100644 src/stores/google-maps/deltaAreaStore/deltaAreaStore.test.ts create mode 100644 src/stores/google-maps/deltaAreaStore/deltaAreaStore.ts create mode 100644 src/stores/google-maps/deltaAreaStore/index.ts create mode 100644 src/stores/google-maps/deltaAreaStore/types.ts create mode 100644 src/stores/google-maps/googleMapStore.ts create mode 100644 src/stores/google-maps/markerInstanceStore.ts create mode 100644 src/stores/google-maps/stationInfoWindowStore.ts create mode 100644 src/stores/layout/modalStore.ts create mode 100644 src/stores/layout/navigationBarPanelStore.tsx create mode 100644 src/stores/layout/toastStore.tsx create mode 100644 src/stores/layout/warningModalStore.ts create mode 100644 src/stores/login/memberInfoStore.ts create mode 100644 src/stores/login/memberTokenStore.ts create mode 100644 src/stores/profileMenuOpenStore.ts create mode 100644 src/stores/station-filters/clientStationFiltersStore.ts create mode 100644 src/stores/station-filters/serverStationFiltersStore.ts create mode 100644 src/utils/external-state/StateManager.test.ts create mode 100644 src/utils/external-state/StateManager.ts create mode 100644 src/utils/external-state/index.ts create mode 100644 src/utils/external-state/tools.test.tsx create mode 100644 src/utils/external-state/tools.ts create mode 100644 src/utils/google-maps/getBounds.test.ts create mode 100644 src/utils/google-maps/getBounds.ts create mode 100644 src/utils/google-maps/getCalculatedMapDelta.ts create mode 100644 src/utils/google-maps/index.ts create mode 100644 src/utils/google-maps/isCachedRegion.test.ts create mode 100644 src/utils/google-maps/isCachedRegion.ts create mode 100644 src/utils/storage/index.ts diff --git a/package.json b/package.json index c9e5a603..b059f4c4 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ }, "dependencies": { "@googlemaps/react-wrapper": "^1.1.35", + "@tanstack/react-query": "^4.29.25", + "@types/google.maps": "^3.53.4", "react": "^18", "react-dom": "^18", "next": "13.5.4" diff --git a/src/app/page.tsx b/src/app/page.tsx index 56c38f13..15d27bc5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,24 +1,30 @@ 'use client'; import { Status, Wrapper } from '@googlemaps/react-wrapper'; +import CarFfeineMap from '@/components/google-maps/map/CarFfeineMap'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient(); const render = (status: Status) => { switch (status) { - case Status.LOADING: - return
로딩중
; case Status.FAILURE: return <>에러 발생; + case Status.LOADING: + return
로딩중
; case Status.SUCCESS: - return <>지도; + return ; } }; const App = () => { return ( - + + + ); }; diff --git a/src/components/google-maps/map/CarFfeineListener.tsx b/src/components/google-maps/map/CarFfeineListener.tsx new file mode 100644 index 00000000..b18aa91e --- /dev/null +++ b/src/components/google-maps/map/CarFfeineListener.tsx @@ -0,0 +1,58 @@ +import { useEffect } from 'react'; + +import { useQueryClient } from '@tanstack/react-query'; + +import { useExternalValue, useSetExternalState } from '@utils/external-state'; +import { getDisplayPosition } from '@utils/google-maps'; +import { isCachedRegion } from '@utils/google-maps/isCachedRegion'; +import { setLocalStorage } from '@utils/storage'; + +import { deltaAreaActions, deltaAreaStore } from '@stores/google-maps/deltaAreaStore'; +import { getGoogleMapStore } from '@stores/google-maps/googleMapStore'; +import { profileMenuOpenStore } from '@stores/profileMenuOpenStore'; + +import { QUERY_KEY_STATION_MARKERS } from '@constants/queryKeys'; +import { LOCAL_KEY_LAST_POSITION } from '@constants/storageKeys'; + +const CarFfeineMapListener = () => { + const googleMap = useExternalValue(getGoogleMapStore()); + const queryClient = useQueryClient(); + const setIsProfileMenuOpen = useSetExternalState(profileMenuOpenStore); + const deltaAreaState = useExternalValue(deltaAreaStore); + + const requestStationMarkers = () => { + const displayPosition = getDisplayPosition(googleMap); + if (!isCachedRegion(displayPosition)) { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY_STATION_MARKERS] }); + } + + setIsProfileMenuOpen(false); + + setLocalStorage(LOCAL_KEY_LAST_POSITION, { + lat: googleMap.getCenter().lat(), + lng: googleMap.getCenter().lng(), + }); + }; + + useEffect(() => { + googleMap.addListener('idle', () => { + if (deltaAreaState === 'medium' || deltaAreaState === 'small') { + requestStationMarkers(); + } + + const { latitudeDelta, longitudeDelta } = getDisplayPosition(googleMap); + + deltaAreaActions.setDeltaAreaState(latitudeDelta * longitudeDelta); + }); + + const initMarkersEvent = googleMap.addListener('bounds_changed', async () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY_STATION_MARKERS] }); + + google.maps.event.removeListener(initMarkersEvent); + }); + }, []); + + return <>; +}; + +export default CarFfeineMapListener; diff --git a/src/components/google-maps/map/CarFfeineMap.tsx b/src/components/google-maps/map/CarFfeineMap.tsx new file mode 100644 index 00000000..b61fc612 --- /dev/null +++ b/src/components/google-maps/map/CarFfeineMap.tsx @@ -0,0 +1,11 @@ +import CarFfeineMapListener from './CarFfeineListener'; + +const CarFfeineMap = () => { + return ( + <> + + + ); +}; + +export default CarFfeineMap; diff --git a/src/components/google-maps/marker/LargeDeltaAreaMarkerContainer/LargeDeltaAreaMarkerContainer.tsx b/src/components/google-maps/marker/LargeDeltaAreaMarkerContainer/LargeDeltaAreaMarkerContainer.tsx new file mode 100644 index 00000000..0bdcf87d --- /dev/null +++ b/src/components/google-maps/marker/LargeDeltaAreaMarkerContainer/LargeDeltaAreaMarkerContainer.tsx @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; + +import { warningModalActions } from '@stores/layout/warningModalStore'; + +import ZoomWarningModal from '@ui/WarningModal'; + +const LargeDeltaAreaMarkerContainer = () => { + useEffect(() => { + warningModalActions.openModal(); + + return () => { + warningModalActions.closeModal(); + }; + }, []); + return <>; +}; + +export default LargeDeltaAreaMarkerContainer; diff --git a/src/components/google-maps/marker/LargeDeltaAreaMarkerContainer/index.ts b/src/components/google-maps/marker/LargeDeltaAreaMarkerContainer/index.ts new file mode 100644 index 00000000..c5593231 --- /dev/null +++ b/src/components/google-maps/marker/LargeDeltaAreaMarkerContainer/index.ts @@ -0,0 +1,3 @@ +import LargeDeltaAreaMarkerContainer from './LargeDeltaAreaMarkerContainer'; + +export default LargeDeltaAreaMarkerContainer; diff --git a/src/components/google-maps/marker/MarkerContainers.tsx b/src/components/google-maps/marker/MarkerContainers.tsx new file mode 100644 index 00000000..90e1389d --- /dev/null +++ b/src/components/google-maps/marker/MarkerContainers.tsx @@ -0,0 +1,24 @@ +import { useExternalValue } from '@utils/external-state'; + +import { deltaAreaStore } from '@stores/google-maps/deltaAreaStore'; + +import LargeDeltaAreaMarkerContainer from './LargeDeltaAreaMarkerContainer'; +import MaxDeltaAreaMarkerContainer from './MaxDeltaAreaMarkerContainer'; +import SmallMediumDeltaAreaMarkerContainer from './SmallMediumDeltaAreaMarkerContainer'; + +const MarkerContainers = () => { + const deltaAreaState = useExternalValue(deltaAreaStore); + + return ( + <> + {(deltaAreaState === 'medium' || deltaAreaState === 'small') && ( + + )} + {/* 이 아래는 앞으로 추가될 기능을 미리 대응하는 컴포넌트 */} + {deltaAreaState === 'large' && } + {deltaAreaState === 'max' && } + + ); +}; + +export default MarkerContainers; diff --git a/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/MaxDeltaAreaMarkerContainer.tsx b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/MaxDeltaAreaMarkerContainer.tsx new file mode 100644 index 00000000..4bc5adc1 --- /dev/null +++ b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/MaxDeltaAreaMarkerContainer.tsx @@ -0,0 +1,13 @@ +import RegionMarkerRenderer from './components/RegionMarkerRenderer'; +import { useRegionMarkers } from './hooks/useRegionMarkers'; + +const MaxDeltaAreaMarkerContainer = () => { + const { data: regions, isSuccess, isError } = useRegionMarkers(); + if (!regions || !isSuccess || isError) { + return <>; + } + + return regions.map((region) => ); +}; + +export default MaxDeltaAreaMarkerContainer; diff --git a/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarker.stories.tsx b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarker.stories.tsx new file mode 100644 index 00000000..9f9614d0 --- /dev/null +++ b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarker.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta } from '@storybook/react'; + +import type { RegionMarkerProps } from './RegionMarker'; +import RegionMarker from './RegionMarker'; + +const meta = { + title: 'UI/RegionMarker', + component: RegionMarker, + tags: ['autodocs'], + args: { + regionName: '서울특별시', + count: 2, + }, + argTypes: { + regionName: { + description: '지역명 입니다.', + }, + count: { + description: '특정 지역의 충전소 갯수입니다.', + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = (args: RegionMarkerProps) => { + return ( +
+ +
+ ); +}; diff --git a/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarker.tsx b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarker.tsx new file mode 100644 index 00000000..0a10c2b9 --- /dev/null +++ b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarker.tsx @@ -0,0 +1,44 @@ +import Box from '@common/Box'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import type { RegionName } from '../types'; + +export interface RegionMarkerProps { + count: number; + regionName: RegionName; +} + +const RegionMarker = ({ count, regionName }: RegionMarkerProps) => { + return ( + + + {count} + + + {regionName} + + + ); +}; + +export default RegionMarker; diff --git a/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarkerRenderer.tsx b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarkerRenderer.tsx new file mode 100644 index 00000000..b3233bfd --- /dev/null +++ b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarkerRenderer.tsx @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; + +import { useRenderRegionMarker } from '../hooks/useRenderRegionMarker'; +import type { Region } from '../types'; + +export interface RegionMarkerProps { + region: Region; +} + +const RegionMarkerRenderer = ({ region }: RegionMarkerProps) => { + const { renderRegionMarker } = useRenderRegionMarker(); + + useEffect(() => { + const unmountRegionMarker = renderRegionMarker(region); + + return unmountRegionMarker; + }, []); + + return <>; +}; + +export default RegionMarkerRenderer; diff --git a/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRegionMarkers.ts b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRegionMarkers.ts new file mode 100644 index 00000000..7f6207d7 --- /dev/null +++ b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRegionMarkers.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; + +import { QUERY_KEY_REGION_MARKERS } from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +import type { Region } from '../types'; + +export const fetchRegionMarkers = async () => { + const stationMarkers = await fetch(`${SERVER_URL}/stations/markers/regions?regions=all`) + .then(async (response) => { + const data = await response.json(); + + return data; + }) + .catch((error) => { + throw new Error('지역 마커를 수신을 실패했습니다.', error); + }); + + return stationMarkers; +}; + +export const useRegionMarkers = () => { + return useQuery({ + queryKey: [QUERY_KEY_REGION_MARKERS], + queryFn: fetchRegionMarkers, + refetchOnWindowFocus: false, + }); +}; diff --git a/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRenderRegionMarker.tsx b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRenderRegionMarker.tsx new file mode 100644 index 00000000..55a48210 --- /dev/null +++ b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRenderRegionMarker.tsx @@ -0,0 +1,40 @@ +import { createRoot } from 'react-dom/client'; + +import { useExternalValue } from '@utils/external-state'; + +import { getGoogleMapStore, googleMapActions } from '@stores/google-maps/googleMapStore'; + +import { DELTA_AREA_BREAKPOINTS } from '@constants/googleMaps'; + +import RegionMarker from '../components/RegionMarker'; +import type { Region } from '../types'; + +export const useRenderRegionMarker = () => { + const googleMap = useExternalValue(getGoogleMapStore()); + + const renderRegionMarker = (region: Region) => { + const { latitude, longitude, count, regionName } = region; + + const container = document.createElement('div'); + + const markerInstance = new google.maps.marker.AdvancedMarkerElement({ + position: { lat: latitude, lng: longitude }, + map: googleMap, + title: regionName, + content: container, + }); + + createRoot(container).render(); + + markerInstance.addListener('click', () => { + // TODO: 중간 단계 (서버) 클러스터링 구현 이후에 ZOOM_BREAKPOINTS.middle로 변경 예정 + googleMapActions.moveTo({ lat: latitude, lng: longitude }, DELTA_AREA_BREAKPOINTS.large); + }); + + return () => { + markerInstance.map = null; + }; + }; + + return { renderRegionMarker }; +}; diff --git a/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/index.ts b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/index.ts new file mode 100644 index 00000000..663f80da --- /dev/null +++ b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/index.ts @@ -0,0 +1,3 @@ +import MaxDeltaAreaMarkerContainer from './MaxDeltaAreaMarkerContainer'; + +export default MaxDeltaAreaMarkerContainer; diff --git a/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/types/index.ts b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/types/index.ts new file mode 100644 index 00000000..bfae962a --- /dev/null +++ b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/types/index.ts @@ -0,0 +1,24 @@ +export type RegionName = + | '서울특별시' + | '인천광역시' + | '광주광역시' + | '대구광역시' + | '울산광역시' + | '대전광역시' + | '부산광역시' + | '경기도' + | '강원특별자치도' + | '충청남도' + | '충청북도' + | '경상북도' + | '경상남도' + | '전라북도' + | '전라남도' + | '제주특별자치도'; + +export interface Region { + regionName: RegionName; + latitude: number; + longitude: number; + count: number; +} diff --git a/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/SmallMediumDeltaAreaMarkerContainer.tsx b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/SmallMediumDeltaAreaMarkerContainer.tsx new file mode 100644 index 00000000..5a2ab225 --- /dev/null +++ b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/SmallMediumDeltaAreaMarkerContainer.tsx @@ -0,0 +1,73 @@ +import { useEffect } from 'react'; + +import { useStationMarkers } from '@marker/SmallMediumDeltaAreaMarkerContainer/hooks/useStationMarkers'; + +import { useExternalValue } from '@utils/external-state'; + +import { deltaAreaStore } from '@stores/google-maps/deltaAreaStore'; +import type { DeltaAreaState } from '@stores/google-maps/deltaAreaStore/types'; +import type { StationMarkerInstance } from '@stores/google-maps/markerInstanceStore'; +import { markerInstanceStore } from '@stores/google-maps/markerInstanceStore'; + +import { useRenderStationMarker } from './hooks/useRenderStationMarker'; + +const SmallMediumDeltaAreaMarkerContainer = () => { + const { data: stationMarkers, isSuccess } = useStationMarkers(); + const { + createNewMarkerInstances, + getRemainedMarkerInstances, + removeMarkersOutsideBounds, + removeAllMarkers, + renderDefaultMarkers, + renderCarffeineMarkers, + } = useRenderStationMarker(); + const deltaAreaState = useExternalValue(deltaAreaStore); + + const renderMarkerByDeltaAreaState = ( + deltaAreaState: DeltaAreaState, + markerInstances: StationMarkerInstance[] + ) => { + if (deltaAreaState === 'small') { + renderCarffeineMarkers(markerInstances, stationMarkers); + } + if (deltaAreaState === 'medium') { + renderDefaultMarkers(markerInstances, stationMarkers); + } + }; + + useEffect(() => { + if (stationMarkers !== undefined) { + renderMarkerByDeltaAreaState(deltaAreaState, markerInstanceStore.getState()); + } + }, [deltaAreaState]); + + useEffect(() => { + return () => { + // MarkerContainers 컴포넌트에서 HighZoomMarkerContainer 컴포넌트가 unmount될 때 모든 마커를 지워준다. + removeAllMarkers(markerInstanceStore.getState()); + }; + }, []); + + if (stationMarkers === undefined || !isSuccess) { + return <>; + } + + const newMarkerInstances = createNewMarkerInstances( + markerInstanceStore.getState(), + stationMarkers + ); + + const remainedMarkerInstances = getRemainedMarkerInstances( + markerInstanceStore.getState(), + stationMarkers + ); + + removeMarkersOutsideBounds(markerInstanceStore.getState(), stationMarkers); + renderMarkerByDeltaAreaState(deltaAreaState, newMarkerInstances); + + markerInstanceStore.setState([...remainedMarkerInstances, ...newMarkerInstances]); + + return <>; +}; + +export default SmallMediumDeltaAreaMarkerContainer; diff --git a/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeine.stories.tsx b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeine.stories.tsx new file mode 100644 index 00000000..0ff8b09f --- /dev/null +++ b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeine.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta } from '@storybook/react'; + +import type { StationSummary } from '@type'; + +import CarFfeineMarker from './CarFfeineMarker'; + +const meta = { + title: 'UI/CarFfeineMarker', + component: CarFfeineMarker, + tags: ['autodocs'], + args: { + availableCount: 2, + stationName: '카페인 충전소', + }, + argTypes: { + availableCount: { + description: + '이용 가능한 충전기 개수를 변경할 수 있습니다. 0개를 입력할 경우 색상이 변합니다.', + }, + stationName: { + description: '마커 위에 마우스를 올렸을 때 나오는 충전소 이름을 변경할 수 있습니다.', + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = (args: StationSummary) => { + return ; +}; diff --git a/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeineMarker.style.ts b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeineMarker.style.ts new file mode 100644 index 00000000..f5e62d2f --- /dev/null +++ b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeineMarker.style.ts @@ -0,0 +1,60 @@ +import styled from 'styled-components'; + +import type { StationAvailability } from './CarFfeineMarker'; + +export const MARKER_COLORS = { + noAvailable: { + background: '#EA4335', + border: '#960A0A', + }, + available: { + background: '#3373DC', + border: '#324F8E', + }, +} as const; + +export const Marker = styled.div<{ state: StationAvailability }>` + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 4rem; + height: 4rem; + padding-bottom: 2px; + background: ${({ state }) => MARKER_COLORS[state].background}; + color: #fff; + font-size: 1.3rem; + font-weight: 500; + text-align: center; + border-radius: 50%; + border: 1.5px solid ${({ state }) => MARKER_COLORS[state].border}; + cursor: pointer; + + &::after { + content: ''; + position: absolute; + bottom: -5px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + padding-bottom: 1px; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 10px solid ${({ state }) => MARKER_COLORS[state].background}; + } + + &::before { + content: ''; + position: absolute; + bottom: -6.7px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + padding-bottom: 1px; + border-left: 4.8px solid transparent; + border-right: 4.8px solid transparent; + border-top: 5.6px solid ${({ state }) => MARKER_COLORS[state].border}; + } +`; diff --git a/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeineMarker.test.tsx b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeineMarker.test.tsx new file mode 100644 index 00000000..ae785ab9 --- /dev/null +++ b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeineMarker.test.tsx @@ -0,0 +1,35 @@ +import { render } from '@testing-library/react'; + +import type { StationMarker } from '@type'; + +import CarFfeineMarker from './CarFfeineMarker'; + +describe('CarFfeineMarker 컴포넌트 테스트', () => { + it('CarFfeineMarker 컴포넌트가 렌더링 된다.', () => { + const stationMarker: StationMarker = { + availableCount: 0, + isParkingFree: false, + isPrivate: false, + latitude: 0, + longitude: 0, + quickChargerCount: 0, + stationId: '', + stationName: '', + }; + render(); + }); + it('CarFfeineMarker의 사용 가능 충전기 숫자가 렌더링 된다.', () => { + const stationMarker: StationMarker = { + availableCount: 10, + isParkingFree: false, + isPrivate: false, + latitude: 0, + longitude: 0, + quickChargerCount: 0, + stationId: '', + stationName: '', + }; + const { getByText } = render(); + expect(getByText('10')).toBeInTheDocument(); + }); +}); diff --git a/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeineMarker.tsx b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeineMarker.tsx new file mode 100644 index 00000000..39d0b2f3 --- /dev/null +++ b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeineMarker.tsx @@ -0,0 +1,25 @@ +import type { StationMarker } from '@type'; + +import type { MARKER_COLORS } from './CarFfeineMarker.style'; +import { Marker } from './CarFfeineMarker.style'; + +export type StationAvailability = keyof typeof MARKER_COLORS; + +const CarFfeineMarker = (station: StationMarker) => { + const { stationName, availableCount } = station; + + const state: StationAvailability = availableCount === 0 ? 'noAvailable' : 'available'; + + return ( + + {availableCount} + + ); +}; + +export default CarFfeineMarker; diff --git a/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/index.ts b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/index.ts new file mode 100644 index 00000000..632e64e8 --- /dev/null +++ b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/index.ts @@ -0,0 +1,3 @@ +import CarFfeineMarker from './CarFfeineMarker'; + +export default CarFfeineMarker; diff --git a/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/constants/index.ts b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/constants/index.ts new file mode 100644 index 00000000..b2619c3b --- /dev/null +++ b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/constants/index.ts @@ -0,0 +1 @@ +export const DEFAULT_MARKER_SIZE_RATIO = 0.7; diff --git a/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/hooks/useRenderStationMarker.tsx b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/hooks/useRenderStationMarker.tsx new file mode 100644 index 00000000..6f430728 --- /dev/null +++ b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/hooks/useRenderStationMarker.tsx @@ -0,0 +1,160 @@ +import { createRoot } from 'react-dom/client'; + +import { getStoreSnapshot } from '@utils/external-state/tools'; + +import { getGoogleMapStore } from '@stores/google-maps/googleMapStore'; +import type { StationMarkerInstance } from '@stores/google-maps/markerInstanceStore'; + +import { useStationInfoWindow } from '@hooks/google-maps/useStationInfoWindow'; +import useMediaQueries from '@hooks/useMediaQueries'; + +import { useNavigationBar } from '@ui/Navigator/NavigationBar/hooks/useNavigationBar'; +import StationDetailsWindow from '@ui/StationDetailsWindow'; + +import type { StationDetails, StationMarker, StationSummary } from '@type'; + +import CarFfeineMarker from '../components/CarFfeineMarker'; +import { MARKER_COLORS } from '../components/CarFfeineMarker/CarFfeineMarker.style'; +import { DEFAULT_MARKER_SIZE_RATIO } from '../constants'; + +export const useRenderStationMarker = () => { + const googleMap = getStoreSnapshot(getGoogleMapStore()); + + const { openStationInfoWindow } = useStationInfoWindow(); + const { openLastPanel } = useNavigationBar(); + const screen = useMediaQueries(); + + const createNewMarkerInstance = (marker: StationDetails) => { + const { latitude: lat, longitude: lng, stationName, stationId } = marker; + + const markerInstance = new google.maps.marker.AdvancedMarkerElement({ + position: { lat, lng }, + title: stationName, + }); + + bindMarkerClickHandler([{ stationId, instance: markerInstance }]); + + return markerInstance; + }; + + const createNewMarkerInstances = ( + prevMarkerInstances: StationMarkerInstance[], + markers: StationMarker[] + ) => { + const newMarkers = markers.filter((marker) => + prevMarkerInstances.every((prevMarker) => prevMarker.stationId !== marker.stationId) + ); + + const newMarkerInstances = newMarkers.map((marker) => { + const { latitude: lat, longitude: lng, stationName, stationId } = marker; + + const markerInstance = new google.maps.marker.AdvancedMarkerElement({ + position: { lat, lng }, + title: stationName, + }); + + return { + stationId, + instance: markerInstance, + }; + }); + + bindMarkerClickHandler(newMarkerInstances); + + return newMarkerInstances; + }; + + const removeMarkersOutsideBounds = ( + prevMarkerInstances: StationMarkerInstance[], + currentMarkers: StationMarker[] + ) => { + const markersOutOfBounds = prevMarkerInstances.filter((prevMarker) => + currentMarkers.every((currentMarker) => currentMarker.stationId !== prevMarker.stationId) + ); + + markersOutOfBounds.forEach((marker) => { + marker.instance.map = null; + }); + }; + + const removeAllMarkers = (prevMarkerInstances: StationMarkerInstance[]) => { + prevMarkerInstances.forEach((marker) => { + marker.instance.map = null; + }); + }; + + const getRemainedMarkerInstances = ( + prevMarkerInstances: StationMarkerInstance[], + currentMarkers: StationMarker[] + ) => { + return prevMarkerInstances.filter((markerInstance) => + currentMarkers.some((marker) => marker.stationId === markerInstance.stationId) + ); + }; + + const renderDefaultMarkers = ( + markerInstances: StationMarkerInstance[], + markers: StationMarker[] | StationSummary[] + ) => { + markers.forEach((marker) => { + const markerInstance = markerInstances.find( + (markerInstance) => markerInstance.stationId === marker.stationId + )?.instance; + + const markerColor = + marker.availableCount > 0 ? MARKER_COLORS.available : MARKER_COLORS.noAvailable; + + if (markerInstance) { + const defaultMarkerDesign = new google.maps.marker.PinElement({ + scale: DEFAULT_MARKER_SIZE_RATIO, + background: markerColor.background, + borderColor: markerColor.border, + glyph: '', + }); + + markerInstance.map = googleMap; + markerInstance.content = defaultMarkerDesign.element; + } + }); + }; + + const renderCarffeineMarkers = ( + markerInstances: StationMarkerInstance[], + markers: StationMarker[] | StationSummary[] + ) => { + markerInstances.forEach(({ instance: markerInstance, stationId }) => { + const container = document.createElement('div'); + + markerInstance.content = container; + markerInstance.map = googleMap; + + const markerInformation = markers.find( + (stationMarker) => stationMarker.stationId === stationId + ); + + createRoot(container).render(); + }); + }; + + const bindMarkerClickHandler = (markerInstances: StationMarkerInstance[]) => { + markerInstances.forEach(({ instance: markerInstance, stationId }) => { + markerInstance.addListener('click', () => { + openStationInfoWindow(stationId, markerInstance); + + if (!screen.get('isMobile')) { + openLastPanel(); + } + }); + }); + }; + + return { + createNewMarkerInstance, + createNewMarkerInstances, + removeMarkersOutsideBounds, + getRemainedMarkerInstances, + renderDefaultMarkers, + renderCarffeineMarkers, + removeAllMarkers, + }; +}; diff --git a/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/hooks/useStationMarkers.ts b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/hooks/useStationMarkers.ts new file mode 100644 index 00000000..e95f9277 --- /dev/null +++ b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/hooks/useStationMarkers.ts @@ -0,0 +1,120 @@ +import { useQuery } from '@tanstack/react-query'; + +import { useExternalValue } from '@utils/external-state'; +import { getStoreSnapshot } from '@utils/external-state/tools'; +import { getTypedObjectFromEntries } from '@utils/getTypedObjectFromEntries'; +import { getTypedObjectKeys } from '@utils/getTypedObjectKeys'; +import { getDisplayPosition } from '@utils/google-maps'; +import { getQueryFormattedUrl } from '@utils/request-query-params'; +import { setSessionStorage } from '@utils/storage'; + +import { getGoogleMapStore } from '@stores/google-maps/googleMapStore'; +import { clientStationFiltersStore } from '@stores/station-filters/clientStationFiltersStore'; +import { + selectedCapacitiesFilterStore, + selectedCompaniesFilterStore, + selectedConnectorTypesFilterStore, +} from '@stores/station-filters/serverStationFiltersStore'; + +import { DELIMITER } from '@constants'; +import { COMPANIES } from '@constants/chargers'; +import { DELTA_AREA_BREAKPOINTS, DELTA_MULTIPLE } from '@constants/googleMaps'; +import { QUERY_KEY_STATION_MARKERS } from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; +import { SESSION_KEY_LAST_REQUEST_POSITION } from '@constants/storageKeys'; + +import type { DisplayPosition } from '@type'; +import type { StationMarker } from '@type/stations'; + +const isMapLoaded = (displayPosition: DisplayPosition) => { + const { latitudeDelta, longitudeDelta } = displayPosition; + + return !(latitudeDelta === 0 && longitudeDelta === 0); +}; + +export const fetchStationMarkers = async () => { + const googleMap = getStoreSnapshot(getGoogleMapStore()); + const displayPosition = getDisplayPosition(googleMap); + + if (!isMapLoaded(displayPosition)) { + throw new Error('지도가 로드되지 않았습니다'); + } + + const requestPositionParams: DisplayPosition = { + ...displayPosition, + latitudeDelta: displayPosition.latitudeDelta * DELTA_MULTIPLE, + longitudeDelta: displayPosition.longitudeDelta * DELTA_MULTIPLE, + }; + + if ( + displayPosition.latitudeDelta * displayPosition.longitudeDelta > + DELTA_AREA_BREAKPOINTS.medium + ) { + setSessionStorage(SESSION_KEY_LAST_REQUEST_POSITION, null); + + return new Promise((resolve) => resolve([])); + } + + const displayPositionKeys = getTypedObjectKeys(requestPositionParams); + const displayPositionValues = Object.values(requestPositionParams).map(String); + + const displayPositionString = getTypedObjectFromEntries( + displayPositionKeys, + displayPositionValues + ); + + const companyFilters = selectedCompaniesFilterStore.getState(); + const capacityFilters = selectedCapacitiesFilterStore.getState(); + const connectorTypeFilters = selectedConnectorTypesFilterStore.getState(); + + const requestQueryParams = getQueryFormattedUrl({ + ...displayPositionString, + companyNames: + companyFilters.size > 0 + ? [...companyFilters].map((companyKey) => COMPANIES[companyKey]).join(DELIMITER) + : '', + capacities: capacityFilters.size > 0 ? [...capacityFilters].join(DELIMITER) : '', + chargerTypes: connectorTypeFilters.size > 0 ? [...connectorTypeFilters].join(DELIMITER) : '', + }); + + const stationMarkers = await fetch(`${SERVER_URL}/stations?${requestQueryParams}`, { + method: 'GET', + }).then(async (response) => { + setSessionStorage(SESSION_KEY_LAST_REQUEST_POSITION, requestPositionParams); + + const data = await response.json(); + + return data.stations; + }); + + return stationMarkers; +}; + +export const useStationMarkers = () => { + const { + fastChargeStationFilter, + privateStationFilter, + parkingFreeStationFilter, + availableStationFilter, + } = useExternalValue(clientStationFiltersStore); + + return useQuery({ + queryKey: [QUERY_KEY_STATION_MARKERS], + queryFn: fetchStationMarkers, + select: (data) => { + return data.filter((station) => { + const { availableCount, isParkingFree, isPrivate, quickChargerCount } = station; + + const isNoAvailable = availableStationFilter.isAvailable && availableCount === 0; + const isNoFastCharge = fastChargeStationFilter.isAvailable && quickChargerCount === 0; + const isNoFreeParking = parkingFreeStationFilter.isAvailable && !isParkingFree; + const isNoPublic = privateStationFilter.isAvailable && isPrivate; + + if (isNoAvailable || isNoFastCharge || isNoFreeParking || isNoPublic) { + return false; + } + return true; + }); + }, + }); +}; diff --git a/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/index.ts b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/index.ts new file mode 100644 index 00000000..f87c2658 --- /dev/null +++ b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/index.ts @@ -0,0 +1,3 @@ +import SmallMediumDeltaAreaMarkerContainer from './SmallMediumDeltaAreaMarkerContainer'; + +export default SmallMediumDeltaAreaMarkerContainer; diff --git a/src/constants/chargers.ts b/src/constants/chargers.ts new file mode 100644 index 00000000..e746aabc --- /dev/null +++ b/src/constants/chargers.ts @@ -0,0 +1,140 @@ +// 충전기 +export const CONNECTOR_TYPES = { + DC_FAST: 'DC 차데모', + AC_SLOW: 'AC 완속', + DC_AC_3PHASE: 'DC 차데모+AC 3상', + DC_COMBO: 'DC 콤보', + DC_DC_COMBO: 'DC 차데모+DC 콤보', + DC_AC_DC_COMBO: 'DC 차데모+AC 3상+DC 콤보', + AC_3PHASE: 'AC 3상', +} as const; + +/** + * 충전기상태(1: 통신이상, 2: 충전대기,3: 충전중, 4: 운영중지, 5: 점검중, 9: 상태미확인) + */ +export const CHARGER_STATES = { + COMMUNICATION_ERROR: { + state: '통신이상', + message: '마지막 통신', + }, + STANDBY: { state: '충전대기', message: '마지막 사용' }, + CHARGING_IN_PROGRESS: { state: '충전중', message: '충전 시작' }, + OPERATION_SUSPENDED: { + state: '운영중지', + message: '운영 중지', + }, + UNDER_INSPECTION: { state: '점검중', message: '점검 시작' }, + STATUS_UNKNOWN: { state: '상태미확인', message: '마지막 확인' }, +} as const; + +export const COMPANIES = { + AM: '아마노코리아', + BA: '부안군', + BG: '비긴스', + BK: '비케이에너지', + BN: '블루네트웍스보타리에너지', + BT: '참빛이브이씨', + CB: '캐스트프로', + CP: '한국 EV 충전서비스센터', + CS: '씨티카', + CT: '씨어스', + CU: '대영채비', + DE: '대구환경공단', + DG: '대구시', + DP: '대유플러스', + E0: '에너지플러스', + EA: '에바', + EC: '이지차저', + EG: '에너지파튼즈', + EH: '이엔에이치에너지', + EK: '이노케이텍', + EM: 'evmost', + EN: '이엔', + EP: '이카플러그', + EV: '에버온', + EZ: '차지인', + G1: '광주시', + G2: '광주시', + GN: '지커넥트', + GP: '군포시', + GS: 'GS 칼텍스', + HB: '에이치엘비생명과학', + HD: '현대자동차', + HE: '한국전기차충전서비스', + HL: '에이치엘비일렉', + HM: '휴맥스이브이', + HS: '홈앤서비스', + HW: '한화솔루션', + IK: '익산시', + JA: '중앙제어', + JC: '제주에너지공사', + JD: '제주도청', + JE: '제주전기자동차서비스', + JH: '종하아이앤씨', + JJ: '전주시', + JN: '제이앤씨플랜', + JT: '제주테크노파크', + JU: '정읍시', + KA: '기아자동차', + KC: '한국컴퓨터', + KE: '한국전기차인프라기술', + KI: '기아자동차', + KL: '클린일렉스', + KM: '카카오모빌리티', + KN: '한국환경공단', + KO: '이브이파트너스', + KP: '한국전력', + KS: '한국전기차솔루션', + KT: '케이티', + KU: '한국충전연합', + LD: '롯데정보통신', + LH: 'LG 헬로비전', + MA: '맥플러스', + ME: '환경부', + MO: '매니지온', + MT: '모던텍', + NB: '남부솔루션', + NE: '에너넷', + NJ: '나주시', + NT: '한국전자금융', + OB: '현대오일뱅크', + PC: '파킹클라우드', + PI: '차지비', + PL: '플러그링크', + PS: '이브이파킹서비스', + PW: '파워큐브', + RE: '렏이엔지', + S1: '에스이피', + SA: '설악에너텍', + SB: '소프트베리', + SC: '삼척시', + SD: '스칼라데이터', + SE: '서울시', + SF: '스타코프', + SG: 'SK 시그넷', + SJ: '세종시', + SK: 'SK 에너지', + SM: '성민기업', + SN: '서울에너지공사', + SO: '선광시스템', + SP: '스마트포트테크놀로지', + SR: 'SK 렌터카', + SS: '삼성 EVC', + ST: '에스트래픽', + TB: '태백시', + TD: '타디스테크놀로지', + TL: '티엘컴퍼니', + TM: '티맵', + UN: '유니이브이', + US: '울산시', + YY: '양양군', +} as const; + +// 충전 속도 +export const CAPACITIES = [3, 7, 50, 100, 200] as const; +export const QUICK_CHARGER_CAPACITY_THRESHOLD = 50; + +export const CHARGING_SPEED = { + quick: '급속', + standard: '완속', +} as const; diff --git a/src/constants/congestion.ts b/src/constants/congestion.ts new file mode 100644 index 00000000..ce788bf2 --- /dev/null +++ b/src/constants/congestion.ts @@ -0,0 +1,37 @@ +import { getTypedObjectFromEntries } from '@utils/getTypedObjectFromEntries'; + +export const SHORT_KOREAN_DAYS_OF_WEEK = ['월', '화', '수', '목', '금', '토', '일'] as const; +export const SHORT_ENGLISH_DAYS_OF_WEEK = [ + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + 'SUN', +] as const; +export const ENGLISH_DAYS_OF_WEEK = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', +] as const; +export const ENGLISH_DAYS_TO_KOREAN_DAYS = getTypedObjectFromEntries( + SHORT_ENGLISH_DAYS_OF_WEEK, + SHORT_KOREAN_DAYS_OF_WEEK +); +export const ENGLISH_DAYS_OF_WEEK_SHORT_TO_FULL = getTypedObjectFromEntries( + SHORT_ENGLISH_DAYS_OF_WEEK, + ENGLISH_DAYS_OF_WEEK +); +export const ENGLISH_DAYS_OF_WEEK_FULL_TO_SHORT = Object.fromEntries( + ENGLISH_DAYS_OF_WEEK.map((day, index) => [day, SHORT_ENGLISH_DAYS_OF_WEEK[index]]) +); + +/** + * 혼잡도 정보(RATIO)가 DB에 존재하지 않을 경우 + */ +export const NO_RATIO = -1; diff --git a/src/constants/errorMessages.ts b/src/constants/errorMessages.ts new file mode 100644 index 00000000..eff15dbe --- /dev/null +++ b/src/constants/errorMessages.ts @@ -0,0 +1,8 @@ +const ERROR_PREFIX = '[error]'; + +export const ERROR_MESSAGES = { + NO_STATION_FOUND: `${ERROR_PREFIX} 해당 충전소가 존재하지 않습니다.`, + STATION_DETAILS_FETCH_ERROR: `${ERROR_PREFIX} 충전소 세부 정보를 불러올 수 없습니다.`, + STATION_STATISTICS_FETCH_ERROR: `${ERROR_PREFIX} 충전소 혼잡도 통계를 불러올 수 없습니다.`, + NO_SEARCH_RESULT: `${ERROR_PREFIX} 검색 결과가 없습니다.`, +} as const; diff --git a/src/constants/googleMaps.ts b/src/constants/googleMaps.ts new file mode 100644 index 00000000..842b2088 --- /dev/null +++ b/src/constants/googleMaps.ts @@ -0,0 +1,18 @@ +import type { DeltaAreaBreakpoints } from '@stores/google-maps/deltaAreaStore/types'; + +export const MIN_ZOOM_LEVEL = 8; +export const MAX_ZOOM_LEVEL = 20; +export const INITIAL_ZOOM_LEVEL = 16; + +export const DEFAULT_CENTER = { + lat: 37.5056102333107, + lng: 127.05081496722168, +} as const; + +export const DELTA_AREA_BREAKPOINTS: DeltaAreaBreakpoints = { + small: 0.0000085, + medium: 0.000145, + large: 0.137, +}; + +export const DELTA_MULTIPLE = 2; diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 00000000..a37f4537 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,20 @@ +export const DEFAULT_TOKEN = -1; +export const EMPTY_MEMBER_TOKEN = ''; +export const EMPTY_MEMBER_ID = -1; + +export const INVALID_VALUE_LIST = ['null', '.', '..', '1', '#']; + +export const FORM_ADDRESS_LENGTH_LIMIT = 150; +export const FORM_DETAIL_LOCATION_LENGTH_LIMIT = 200; +export const FORM_OPERATING_TIME_LENGTH_LIMIT = 50; +export const FORM_CONTACT_LENGTH_LIMIT = 20; +export const FORM_PRIVATE_REASON_LENGTH_LIMIT = 100; + +export const MIN_REVIEW_CONTENT_LENGTH = 10; +export const MAX_REVIEW_CONTENT_LENGTH = 100; + +export const BROWSER_WIDTH = document.body.offsetWidth; +export const NAVIGATOR_PANEL_WIDTH = 34; +export const MOBILE_BREAKPOINT = 415; + +export const DELIMITER = ','; diff --git a/src/constants/queryKeys.ts b/src/constants/queryKeys.ts new file mode 100644 index 00000000..2af5d8b1 --- /dev/null +++ b/src/constants/queryKeys.ts @@ -0,0 +1,13 @@ +export const QUERY_KEY_STATION_CHARGER_REPORT = 'isStationChargerReported'; +export const QUERY_KEY_STATION_CONGESTION_STATISTICS = 'StationCongestionStatistics'; +export const QUERY_KEY_STATION_DETAILS = 'stationDetails'; +export const QUERY_KEY_STATION_MARKERS = 'stationMarkers'; +export const QUERY_KEY_REGION_MARKERS = 'regionMarkers'; +export const QUERY_KEY_STATION_SUMMARIES = 'stationSummaries'; +export const QUERY_KEY_SEARCHED_STATION = 'searchedStations'; +export const QUERY_KEY_SERVER_STATION_FILTERS = 'serverStationFilters'; +export const QUERY_KEY_MEMBER_SELECTED_FILTERS = 'memberFilters'; +export const QUERY_KEY_MEMBER_CAR_FILTERS = 'memberCarFilters'; +export const QUERY_KEY_STATION_PREVIEWS = 'previews'; +export const QUERY_KEY_STATION_REVIEWS = 'reviews'; +export const QUERY_KEY_STATION_REPLIES = 'replies'; diff --git a/src/constants/server.ts b/src/constants/server.ts new file mode 100644 index 00000000..6ec9a2da --- /dev/null +++ b/src/constants/server.ts @@ -0,0 +1,6 @@ +export const DEVELOP_SERVER_URL = 'https://dain.carffe.in/api'; +export const PRODUCTION_SERVER_URL = 'https://api.carffe.in/api'; + +const isProductionServer = window.location.href.search(/https:\/\/carffe.in/) !== -1; + +export const SERVER_URL = isProductionServer ? PRODUCTION_SERVER_URL : DEVELOP_SERVER_URL; diff --git a/src/constants/stationSearch.ts b/src/constants/stationSearch.ts new file mode 100644 index 00000000..15ba8887 --- /dev/null +++ b/src/constants/stationSearch.ts @@ -0,0 +1,3 @@ +export const SEARCH_SCOPE = + '&scope=stationId&scope=stationName&scope=address&scope=latitude&scope=longitude&page=1&limit=12'; +export const MAX_SEARCH_RESULTS = 10; diff --git a/src/constants/storageKeys.ts b/src/constants/storageKeys.ts new file mode 100644 index 00000000..a3ef7b90 --- /dev/null +++ b/src/constants/storageKeys.ts @@ -0,0 +1,9 @@ +export const LOCAL_KEY_LAST_POSITION = 'CARFFEINE_LAST_POSITION'; +export const LOCAL_KEY_GOOGLE_MAPS_API = 'CARFFEINE_GOOGLE_MAPS_API'; +export const LOCAL_KEY_GOOGLE_MAPS_API_LAST_LOGIN = 'CARFFEINE_GOOGLE_MAPS_API_LAST_LOGIN'; +export const LOCAL_KEY_TOKEN = 'CARFFEINE_TOKEN'; + +export const SESSION_KEY_REPORTED_STATIONS = 'CARFFEINE_REPORTED_STATIONS'; +export const SESSION_KEY_MEMBER_TOKEN = 'CARFFEINE_MEMBER_TOKEN'; +export const SESSION_KEY_MEMBER_INFO = 'CARFFEINE_MEMBER_INFO'; +export const SESSION_KEY_LAST_REQUEST_POSITION = 'CARFFEINE_LAST_REQUEST_POSITION'; \ No newline at end of file diff --git a/src/stores/google-maps/deltaAreaStore/deltaAreaStore.test.ts b/src/stores/google-maps/deltaAreaStore/deltaAreaStore.test.ts new file mode 100644 index 00000000..b573b21e --- /dev/null +++ b/src/stores/google-maps/deltaAreaStore/deltaAreaStore.test.ts @@ -0,0 +1,14 @@ +import { getDeltaAreaState } from './deltaAreaStore'; + +describe('델타 영역 값에 따라 알맞은 상태를 반환한다.', () => { + test.each([ + [0.137, 'max'], + [0.000145, 'large'], + [0.136, 'large'], + [0.0000085, 'medium'], + [0.000144, 'medium'], + [0.0000084, 'small'], + ])('getZoomState(%s) returns %s', (zoom, expected) => { + expect(getDeltaAreaState(zoom)).toBe(expected); + }); +}); diff --git a/src/stores/google-maps/deltaAreaStore/deltaAreaStore.ts b/src/stores/google-maps/deltaAreaStore/deltaAreaStore.ts new file mode 100644 index 00000000..7762da87 --- /dev/null +++ b/src/stores/google-maps/deltaAreaStore/deltaAreaStore.ts @@ -0,0 +1,31 @@ +import { store } from '@utils/external-state'; + +import type { DeltaAreaState } from '@stores/google-maps/deltaAreaStore/types'; + +import { DELTA_AREA_BREAKPOINTS } from '../../../constants/googleMaps'; + +export const deltaAreaStore = store('medium'); + +export const getDeltaAreaState = (newDeltaArea: number): DeltaAreaState => { + if (newDeltaArea >= DELTA_AREA_BREAKPOINTS.large) { + return 'max'; + } + if (newDeltaArea >= DELTA_AREA_BREAKPOINTS.medium) { + return 'large'; + } + if (newDeltaArea >= DELTA_AREA_BREAKPOINTS.small) { + return 'medium'; + } + + return 'small'; +}; + +export const deltaAreaActions = { + setDeltaAreaState: (newDeltaArea: number) => { + const newDeltaAreaState = getDeltaAreaState(newDeltaArea); + + if (newDeltaAreaState !== deltaAreaStore.getState()) { + deltaAreaStore.setState(newDeltaAreaState); + } + }, +}; diff --git a/src/stores/google-maps/deltaAreaStore/index.ts b/src/stores/google-maps/deltaAreaStore/index.ts new file mode 100644 index 00000000..e3ce8cd8 --- /dev/null +++ b/src/stores/google-maps/deltaAreaStore/index.ts @@ -0,0 +1,4 @@ +import { getDeltaAreaState, deltaAreaActions, deltaAreaStore } from './deltaAreaStore'; +import type { DeltaAreaState } from './types'; + +export { deltaAreaStore, getDeltaAreaState, deltaAreaActions, DeltaAreaState }; diff --git a/src/stores/google-maps/deltaAreaStore/types.ts b/src/stores/google-maps/deltaAreaStore/types.ts new file mode 100644 index 00000000..6732f956 --- /dev/null +++ b/src/stores/google-maps/deltaAreaStore/types.ts @@ -0,0 +1,7 @@ +export interface DeltaAreaBreakpoints { + small: number; + medium: number; + large: number; +} + +export type DeltaAreaState = keyof DeltaAreaBreakpoints | 'max'; diff --git a/src/stores/google-maps/googleMapStore.ts b/src/stores/google-maps/googleMapStore.ts new file mode 100644 index 00000000..3fbd1367 --- /dev/null +++ b/src/stores/google-maps/googleMapStore.ts @@ -0,0 +1,93 @@ +import { store } from '@utils/external-state'; +import { getLocalStorage } from '@utils/storage'; + +import { + DEFAULT_CENTER, + INITIAL_ZOOM_LEVEL, + MAX_ZOOM_LEVEL, + MIN_ZOOM_LEVEL, +} from '@constants/googleMaps'; +import { LOCAL_KEY_LAST_POSITION } from '@constants/storageKeys'; + +export const getGoogleMapStore = (() => { + let googleMap: google.maps.Map; + let container: HTMLDivElement; + + if (typeof window !== 'undefined') { + container = document.createElement('div'); + + container.id = 'map'; + container.style.minHeight = '100vh'; + + document.body.appendChild(container); + } + + return () => { + if (!googleMap) { + const initialCenter = getLocalStorage( + LOCAL_KEY_LAST_POSITION, + DEFAULT_CENTER + ); + + googleMap = new window.google.maps.Map(container, { + center: initialCenter, + zoom: INITIAL_ZOOM_LEVEL, + disableDefaultUI: true, + clickableIcons: false, + mapId: '92cb7201b7d43b21', + minZoom: MIN_ZOOM_LEVEL, + maxZoom: MAX_ZOOM_LEVEL, + gestureHandling: 'greedy', + restriction: { + latLngBounds: { + north: 39, + south: 32, + east: 132, + west: 124, + }, + strictBounds: true, + }, + }); + } + + return store(googleMap); + }; +})(); + +export const googleMapActions = { + zoomUp: () => { + const googleMap = getGoogleMapStore().getState(); + const prevZoom = googleMap.getZoom(); + googleMap.setZoom(prevZoom + 1); + }, + zoomDown: () => { + const googleMap = getGoogleMapStore().getState(); + const prevZoom = googleMap.getZoom(); + googleMap.setZoom(prevZoom - 1); + }, + moveToCurrentPosition: () => { + const googleMap = getGoogleMapStore().getState(); + + navigator.geolocation.getCurrentPosition( + (position) => { + googleMap.panTo({ lat: position.coords.latitude, lng: position.coords.longitude }); + googleMap.setZoom(INITIAL_ZOOM_LEVEL); + }, + () => { + alert('위치 권한을 허용해주세요.'); + }, + { + enableHighAccuracy: true, + } + ); + }, + moveTo: (latLng: google.maps.LatLngLiteral, newZoom?: number) => { + const googleMap = getGoogleMapStore().getState(); + + /** + * 아래 메서드의 순서를 바꾸게 되면 지도 경계면에 있는 도시들의 중심을 제대로 잡을 수 없는 문제가 있습니다. + */ + googleMap.setZoom(newZoom || INITIAL_ZOOM_LEVEL); + googleMap.panTo(latLng); + }, +}; diff --git a/src/stores/google-maps/markerInstanceStore.ts b/src/stores/google-maps/markerInstanceStore.ts new file mode 100644 index 00000000..4cd35f87 --- /dev/null +++ b/src/stores/google-maps/markerInstanceStore.ts @@ -0,0 +1,8 @@ +import { store } from '@utils/external-state'; + +export interface StationMarkerInstance { + stationId: string; + instance: google.maps.marker.AdvancedMarkerElement; +} + +export const markerInstanceStore = store([]); diff --git a/src/stores/google-maps/stationInfoWindowStore.ts b/src/stores/google-maps/stationInfoWindowStore.ts new file mode 100644 index 00000000..b185037b --- /dev/null +++ b/src/stores/google-maps/stationInfoWindowStore.ts @@ -0,0 +1,35 @@ +import type { Root } from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; + +import { store } from '@utils/external-state'; +import type StateManager from '@utils/external-state/StateManager'; + +interface StationInfoWindowStore { + stationInfoWindowRoot: Root; + infoWindowInstance: google.maps.InfoWindow; +} + +export const getStationInfoWindowStore = (() => { + let stationInfoWindowStore: StateManager; + + return () => { + if (!stationInfoWindowStore) { + const container = document.createElement('div'); + const stationInfoWindowRoot = createRoot(container); + const infoWindowInstance = new google.maps.InfoWindow({ + content: container, + maxWidth: 320, + minWidth: 320, + }); + + const initialStationInfoWindow: StationInfoWindowStore = { + stationInfoWindowRoot, + infoWindowInstance, + }; + + stationInfoWindowStore = store(initialStationInfoWindow); + } + + return stationInfoWindowStore; + }; +})(); diff --git a/src/stores/layout/modalStore.ts b/src/stores/layout/modalStore.ts new file mode 100644 index 00000000..0e5355c7 --- /dev/null +++ b/src/stores/layout/modalStore.ts @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react'; + +import { store } from '@utils/external-state'; + +export const modalOpenStore = store(false); +export const modalContentStore = store(null); + +export const modalActions = { + openModal: (component: ReactNode) => { + modalOpenStore.setState(true); + modalContentStore.setState(component); + }, + closeModal: () => { + modalOpenStore.setState(false); + modalContentStore.setState(null); + }, +}; diff --git a/src/stores/layout/navigationBarPanelStore.tsx b/src/stores/layout/navigationBarPanelStore.tsx new file mode 100644 index 00000000..e5e3b893 --- /dev/null +++ b/src/stores/layout/navigationBarPanelStore.tsx @@ -0,0 +1,13 @@ +import type { ReactElement } from 'react'; + +import { store } from '@utils/external-state'; + +export interface Panels { + basePanel: ReactElement | null; + lastPanel: ReactElement | null; +} + +export const navigationBarPanelStore = store({ + basePanel: null, + lastPanel: null, +}); diff --git a/src/stores/layout/toastStore.tsx b/src/stores/layout/toastStore.tsx new file mode 100644 index 00000000..17676209 --- /dev/null +++ b/src/stores/layout/toastStore.tsx @@ -0,0 +1,30 @@ +import { store } from '@utils/external-state'; + +import type { ToastProps } from '@common/Toast/Toast'; + +import type { Color, ToastPosition } from '@type/style'; + +export const toastListStore = store([]); + +type PositionedToast = `${ToastPosition['column']}-${ToastPosition['row']}`; + +export const toastActions = { + /** + * @param message 토스트로 보여줄 문구 + * @param color 토스트 색상, [기본값] primary + * @param position 토스트가 튀어나오는 곳, [기본값] bottom-center + */ + showToast: ( + message: string, + color: Color = 'primary', + position: PositionedToast = 'bottom-center' + ) => { + const newToast = { toastId: Date.now(), message, position, color }; + + toastListStore.setState((prev) => [...prev, newToast]); + }, + + deleteToast: (toastId: number) => { + toastListStore.setState((prev) => prev.filter((toast) => toast.toastId !== toastId)); + }, +}; diff --git a/src/stores/layout/warningModalStore.ts b/src/stores/layout/warningModalStore.ts new file mode 100644 index 00000000..aee0207c --- /dev/null +++ b/src/stores/layout/warningModalStore.ts @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react'; + +import { store } from '@utils/external-state'; + +export const warningModalOpenStore = store(false); +export const warningModalContentStore = store(null); + +export const warningModalActions = { + openModal: (component: ReactNode) => { + warningModalOpenStore.setState(true); + warningModalContentStore.setState(component); + }, + closeModal: () => { + warningModalOpenStore.setState(false); + warningModalContentStore.setState(null); + }, +}; diff --git a/src/stores/login/memberInfoStore.ts b/src/stores/login/memberInfoStore.ts new file mode 100644 index 00000000..2afe9227 --- /dev/null +++ b/src/stores/login/memberInfoStore.ts @@ -0,0 +1,52 @@ +import { store } from '@utils/external-state'; +import { getSessionStorage } from '@utils/storage'; + +import { DEFAULT_TOKEN } from '@constants'; +import { SESSION_KEY_MEMBER_INFO } from '@constants/storageKeys'; + +export interface MemberCar { + carId: number; + name: string; + vintage: string; +} + +export interface MemberInfo { + memberId: number; + car: MemberCar | null; +} + +export const memberInfoStore = store( + JSON.parse( + getSessionStorage( + SESSION_KEY_MEMBER_INFO, + `{ + "memberId": ${DEFAULT_TOKEN}, + "car": null + }` + ) + ) +); + +export const memberInfoAction = { + setMemberInfo(memberInfo: MemberInfo) { + memberInfoStore.setState(memberInfo); + }, + setMemberId(memberId: number) { + memberInfoStore.setState((prev) => ({ + ...prev, + memberId, + })); + }, + setMemberCar(car: MemberCar) { + memberInfoStore.setState((prev) => ({ + ...prev, + car, + })); + }, + resetMemberInfo() { + memberInfoStore.setState({ + memberId: -1, + car: null, + }); + }, +}; diff --git a/src/stores/login/memberTokenStore.ts b/src/stores/login/memberTokenStore.ts new file mode 100644 index 00000000..f5eef753 --- /dev/null +++ b/src/stores/login/memberTokenStore.ts @@ -0,0 +1,39 @@ +import { store } from '@utils/external-state'; +import { getSessionStorage } from '@utils/storage'; + +import { EMPTY_MEMBER_TOKEN } from '@constants'; +import { SESSION_KEY_MEMBER_TOKEN } from '@constants/storageKeys'; + +import { toastActions } from '../layout/toastStore'; + +export const memberTokenStore = store(EMPTY_MEMBER_TOKEN); + +export const memberTokenActions = { + /** + * 발급 받은 토큰을 저장해 "로그인 되었습니다" 혹은 "로그아웃 되었습니다" 메세지를 토스트로 띄워줄 때 사용하는 메서드 + * + * @param memberToken 발급받은 토큰 + */ + setMemberToken: async (memberToken: string, isInitial?: boolean) => { + memberTokenStore.setState(memberToken); + + if (memberToken === EMPTY_MEMBER_TOKEN && isInitial !== true) { + toastActions.showToast('로그아웃 되었습니다'); + } + + if (memberToken !== EMPTY_MEMBER_TOKEN) { + toastActions.showToast('로그인 되었습니다'); + } + }, + /** + * 로그아웃을 시키는 메서드지만 "로그아웃 되었습니다" 메세지를 토스트로 띄우고 싶지 않을 때 사용하는 메서드 + */ + resetMemberToken() { + memberTokenStore.setState(EMPTY_MEMBER_TOKEN); + }, +}; + +memberTokenActions.setMemberToken( + getSessionStorage(SESSION_KEY_MEMBER_TOKEN, EMPTY_MEMBER_TOKEN), + true +); diff --git a/src/stores/profileMenuOpenStore.ts b/src/stores/profileMenuOpenStore.ts new file mode 100644 index 00000000..c5d4fdf7 --- /dev/null +++ b/src/stores/profileMenuOpenStore.ts @@ -0,0 +1,3 @@ +import { store } from '@utils/external-state'; + +export const profileMenuOpenStore = store(false); diff --git a/src/stores/station-filters/clientStationFiltersStore.ts b/src/stores/station-filters/clientStationFiltersStore.ts new file mode 100644 index 00000000..22c2f126 --- /dev/null +++ b/src/stores/station-filters/clientStationFiltersStore.ts @@ -0,0 +1,16 @@ +import { store } from '@utils/external-state'; + +import { CHARGING_SPEED } from '../../constants/chargers'; + +const initialClientStationFilter = { + availableStationFilter: { isAvailable: false, label: '현재 사용 가능' }, + fastChargeStationFilter: { isAvailable: false, label: CHARGING_SPEED.quick }, + parkingFreeStationFilter: { isAvailable: false, label: '주차 무료' }, + privateStationFilter: { isAvailable: false, label: '외부인 개방' }, +}; + +export type ClientStationFilter = typeof initialClientStationFilter; + +export const clientStationFiltersStore = store({ + ...initialClientStationFilter, +}); diff --git a/src/stores/station-filters/serverStationFiltersStore.ts b/src/stores/station-filters/serverStationFiltersStore.ts new file mode 100644 index 00000000..9fcb1942 --- /dev/null +++ b/src/stores/station-filters/serverStationFiltersStore.ts @@ -0,0 +1,61 @@ +import { store } from '@utils/external-state'; + +import type { StationFilters } from '@type'; +import type { CapaCityBigDecimal, CompanyKey, ConnectorTypeKey } from '@type/serverStationFilter'; + +export const selectedCompaniesFilterStore = store>(new Set([])); +export const selectedConnectorTypesFilterStore = store>(new Set([])); +export const selectedCapacitiesFilterStore = store>(new Set([])); + +export const serverStationFilterAction = { + getAllServerStationFilters(): StationFilters { + return { + companies: [...selectedCompaniesFilterStore.getState()], + connectorTypes: [...selectedConnectorTypesFilterStore.getState()], + capacities: [...selectedCapacitiesFilterStore.getState()], + }; + }, + setAllServerStationFilters(stationFilters: StationFilters) { + if (stationFilters === undefined) { + return; + } + const { companies, capacities, connectorTypes } = stationFilters; + + selectedCompaniesFilterStore.setState((prev) => new Set([...prev, ...companies])); + selectedConnectorTypesFilterStore.setState((prev) => new Set([...prev, ...connectorTypes])); + selectedCapacitiesFilterStore.setState((prev) => new Set([...prev, ...capacities])); + }, + resetAllServerStationFilters(stationFilters: StationFilters) { + const { companies, capacities, connectorTypes } = stationFilters; + + selectedCompaniesFilterStore.setState(new Set([...companies])); + selectedConnectorTypesFilterStore.setState(new Set([...connectorTypes])); + selectedCapacitiesFilterStore.setState(new Set([...capacities])); + }, + getMemberFilterRequestBody() { + const { capacities, companies, connectorTypes } = + serverStationFilterAction.getAllServerStationFilters(); + + return { + filters: [ + ...capacities.map((capacity) => ({ + type: 'capacity', + name: capacity, + })), + ...companies.map((company) => ({ + type: 'company', + name: company, + })), + ...connectorTypes.map((connectorType) => ({ + type: 'connectorType', + name: connectorType, + })), + ], + }; + }, + deleteAllServerStationFilters() { + selectedCompaniesFilterStore.setState(new Set([])); + selectedConnectorTypesFilterStore.setState(new Set([])); + selectedCapacitiesFilterStore.setState(new Set([])); + }, +}; diff --git a/src/utils/external-state/StateManager.test.ts b/src/utils/external-state/StateManager.test.ts new file mode 100644 index 00000000..459eef8c --- /dev/null +++ b/src/utils/external-state/StateManager.test.ts @@ -0,0 +1,14 @@ +import StateManager from '@utils/external-state/StateManager'; + +describe('StateManager를 테스트한다', () => { + it('setState에 값을 넘기면 state가 변경된다.', () => { + const stateManager = new StateManager(0); + stateManager.setState(1); + expect(stateManager.state).toBe(1); + }); + it('setState에 함수를 넘기면 state가 변경된다.', () => { + const stateManager = new StateManager(0); + stateManager.setState((prevState) => prevState + 1); + expect(stateManager.state).toBe(1); + }); +}); diff --git a/src/utils/external-state/StateManager.ts b/src/utils/external-state/StateManager.ts new file mode 100644 index 00000000..45204b99 --- /dev/null +++ b/src/utils/external-state/StateManager.ts @@ -0,0 +1,48 @@ +export type SetStateCallbackType = (prevState: T) => T; + +export interface DataObserver { + setState: (param: SetStateCallbackType | T) => void; + getState: () => T; + subscribe: (listener: () => void) => () => void; + emitChange: () => void; +} + +class StateManager implements DataObserver { + public state: T; + private listeners: Array<() => void> = []; + + constructor(initialState: T) { + this.state = initialState; + } + + setState = (param: SetStateCallbackType | T) => { + if (param instanceof Function) { + const newState = param(this.state); + this.state = newState; + } else { + this.state = param; + } + + this.emitChange(); + }; + + getState = () => { + return this.state; + }; + + subscribe = (listener: () => void) => { + this.listeners = [...this.listeners, listener]; + + return () => { + this.listeners = this.listeners.filter((l) => l !== listener); + }; + }; + + emitChange = () => { + for (const listener of this.listeners) { + listener(); + } + }; +} + +export default StateManager; diff --git a/src/utils/external-state/index.ts b/src/utils/external-state/index.ts new file mode 100644 index 00000000..c71c3d3a --- /dev/null +++ b/src/utils/external-state/index.ts @@ -0,0 +1 @@ +export { store, useExternalState, useSetExternalState, useExternalValue } from './tools'; diff --git a/src/utils/external-state/tools.test.tsx b/src/utils/external-state/tools.test.tsx new file mode 100644 index 00000000..c82d91bd --- /dev/null +++ b/src/utils/external-state/tools.test.tsx @@ -0,0 +1,59 @@ +// Counter.test.js +import { fireEvent, render } from '@testing-library/react'; + +import { store, useExternalState } from '@utils/external-state/tools'; + +const counterStore = store(0); + +function Counter() { + const [count, setCount] = useExternalState(counterStore); + + const increment = () => { + setCount(count + 1); + }; + + const decrement = () => { + setCount(count - 1); + }; + + return ( +
+

Count: {count}

+ + +
+ ); +} + +describe('useExternalState', () => { + test('전역 상태가 0임을 확인한다.', () => { + const { getByTestId } = render(); + const countValue = getByTestId('count-value'); + + expect(countValue).toHaveTextContent('Count: 0'); + }); + + test('이전 테스트 결과에서 1을 증가한다.', () => { + const { getByTestId } = render(); + const incrementButton = getByTestId('increment-button'); + const countValue = getByTestId('count-value'); + + fireEvent.click(incrementButton); + + expect(countValue).toHaveTextContent('Count: 1'); + }); + + test('이전 테스트 결과에서 1을 감소한다.', () => { + const { getByTestId } = render(); + const decrementButton = getByTestId('decrement-button'); + const countValue = getByTestId('count-value'); + + fireEvent.click(decrementButton); + + expect(countValue).toHaveTextContent('Count: 0'); + }); +}); diff --git a/src/utils/external-state/tools.ts b/src/utils/external-state/tools.ts new file mode 100644 index 00000000..4f0f6327 --- /dev/null +++ b/src/utils/external-state/tools.ts @@ -0,0 +1,36 @@ +import { useSyncExternalStore } from 'react'; + +import StateManager from './StateManager'; +import type { DataObserver, SetStateCallbackType } from './StateManager'; + +export const store = (initialState: T) => { + const stateManager = new StateManager(initialState); + + return stateManager; +}; + +export const useExternalState = ( + store: DataObserver +): [T, (param: SetStateCallbackType | T) => void] => { + const { subscribe, getState, setState } = store; + const state = useSyncExternalStore(subscribe, getState); + + return [state, setState]; +}; + +export const useSetExternalState = (store: DataObserver) => { + const { setState } = store; + + return setState; +}; + +export const useExternalValue = (store: DataObserver) => { + const { subscribe, getState } = store; + const state = useSyncExternalStore(subscribe, getState); + + return state; +}; + +export const getStoreSnapshot = (store: DataObserver) => { + return store.getState(); +}; diff --git a/src/utils/google-maps/getBounds.test.ts b/src/utils/google-maps/getBounds.test.ts new file mode 100644 index 00000000..a29040b8 --- /dev/null +++ b/src/utils/google-maps/getBounds.test.ts @@ -0,0 +1,26 @@ +import { getBounds } from '@utils/google-maps/getBounds'; + +import type { DisplayPosition } from '@type'; + +describe('getBounds를 테스트한다', () => { + it('displayPosition에 값을 넘기면 Bounds 객체를 반환한다.', () => { + const displayPosition: DisplayPosition = { + latitude: 1, + longitude: 2, + latitudeDelta: 3, + longitudeDelta: 4, + zoom: 5, + }; + const result = getBounds(displayPosition); + expect(result).toEqual({ + northEast: { + latitude: 4, + longitude: 6, + }, + southWest: { + latitude: -2, + longitude: -2, + }, + }); + }); +}); diff --git a/src/utils/google-maps/getBounds.ts b/src/utils/google-maps/getBounds.ts new file mode 100644 index 00000000..454c8ba4 --- /dev/null +++ b/src/utils/google-maps/getBounds.ts @@ -0,0 +1,15 @@ +import type { DisplayPosition } from '@type'; +import type { Bounds } from '@type/map'; + +export const getBounds = (displayPosition: DisplayPosition): Bounds => { + return { + northEast: { + latitude: displayPosition.latitude + displayPosition.latitudeDelta, + longitude: displayPosition.longitude + displayPosition.longitudeDelta, + }, + southWest: { + latitude: displayPosition.latitude - displayPosition.latitudeDelta, + longitude: displayPosition.longitude - displayPosition.longitudeDelta, + }, + }; +}; diff --git a/src/utils/google-maps/getCalculatedMapDelta.ts b/src/utils/google-maps/getCalculatedMapDelta.ts new file mode 100644 index 00000000..3ab576df --- /dev/null +++ b/src/utils/google-maps/getCalculatedMapDelta.ts @@ -0,0 +1,27 @@ +import { getGoogleMapStore } from '@stores/google-maps/googleMapStore'; +import { navigationBarPanelStore } from '@stores/layout/navigationBarPanelStore'; + +import { BROWSER_WIDTH, NAVIGATOR_PANEL_WIDTH } from '@constants'; + +import { getDisplayPosition } from '.'; + +export const getCalculatedMapDelta = () => { + const navigationComponentWidth = getNavigationComponentWidth(); + const browserWidth = BROWSER_WIDTH; + const googleMap = getGoogleMapStore().getState(); + + const navigatorAccordionWidthRatio = (navigationComponentWidth * 10) / browserWidth; + const calculatedMapDelta = + getDisplayPosition(googleMap).longitudeDelta * 2 * navigatorAccordionWidthRatio; + + return calculatedMapDelta; +}; + +export const getNavigationComponentWidth = () => { + const { basePanel } = navigationBarPanelStore.getState(); + + if (basePanel !== null) { + return NAVIGATOR_PANEL_WIDTH * 2; + } + return NAVIGATOR_PANEL_WIDTH; +}; diff --git a/src/utils/google-maps/index.ts b/src/utils/google-maps/index.ts new file mode 100644 index 00000000..ba1e8e95 --- /dev/null +++ b/src/utils/google-maps/index.ts @@ -0,0 +1,24 @@ +import type { DisplayPosition } from '@type'; + +export const getDisplayPosition = (map: google.maps.Map): DisplayPosition => { + const center = map.getCenter(); + const bounds = map.getBounds(); + + const longitudeDelta = bounds + ? (bounds.getNorthEast().lng() - bounds.getSouthWest().lng()) / 2 + : 0; + const latitudeDelta = bounds + ? (bounds.getNorthEast().lat() - bounds.getSouthWest().lat()) / 2 + : 0; + const longitude = center.lng(); + const latitude = center.lat(); + const zoom = map.getZoom(); + + return { + longitude, + latitude, + longitudeDelta: longitudeDelta, + latitudeDelta: latitudeDelta, + zoom, + }; +}; diff --git a/src/utils/google-maps/isCachedRegion.test.ts b/src/utils/google-maps/isCachedRegion.test.ts new file mode 100644 index 00000000..4da3fd3c --- /dev/null +++ b/src/utils/google-maps/isCachedRegion.test.ts @@ -0,0 +1,52 @@ +import { isBoundsWithinCachedBounds } from '@utils/google-maps/isCachedRegion'; + +describe('isCachedRegion을 테스트합니다.', () => { + it('새로운 범위가 캐시된 범위 이내입니다.', () => { + const cachedBounds = { + northEast: { + latitude: 1, + longitude: 1, + }, + southWest: { + latitude: 0, + longitude: 0, + }, + }; + const bounds = { + northEast: { + latitude: 0.5, + longitude: 0.5, + }, + southWest: { + latitude: 0, + longitude: 0, + }, + }; + + expect(isBoundsWithinCachedBounds(cachedBounds, bounds)).toBe(true); + }); + it('새로운 범위가 캐시된 범위 이내가 아닙니다.', () => { + const cachedBounds = { + northEast: { + latitude: 1, + longitude: 1, + }, + southWest: { + latitude: 0, + longitude: 0, + }, + }; + const bounds = { + northEast: { + latitude: 2, + longitude: 2, + }, + southWest: { + latitude: 1, + longitude: 1, + }, + }; + + expect(isBoundsWithinCachedBounds(cachedBounds, bounds)).toBe(false); + }); +}); diff --git a/src/utils/google-maps/isCachedRegion.ts b/src/utils/google-maps/isCachedRegion.ts new file mode 100644 index 00000000..413f44cb --- /dev/null +++ b/src/utils/google-maps/isCachedRegion.ts @@ -0,0 +1,41 @@ +import { getSessionStorage } from '@utils/storage'; + +import { SESSION_KEY_LAST_REQUEST_POSITION } from '@constants/storageKeys'; + +import type { DisplayPosition } from '@type'; +import type { Bounds } from '@type/map'; + +import { getBounds } from './getBounds'; + +export const isBoundsWithinCachedBounds = (cachedBounds: Bounds, bounds: Bounds) => { + if (cachedBounds.northEast.latitude < bounds.northEast.latitude) { + return false; + } + if (cachedBounds.northEast.longitude < bounds.northEast.longitude) { + return false; + } + if (cachedBounds.southWest.latitude > bounds.southWest.latitude) { + return false; + } + if (cachedBounds.southWest.longitude > bounds.southWest.longitude) { + return false; + } + + return true; +}; + +export const isCachedRegion = (displayPosition: DisplayPosition) => { + const cachedDisplayPosition = getSessionStorage( + SESSION_KEY_LAST_REQUEST_POSITION, + null + ); + + if (cachedDisplayPosition === null) { + return false; + } + + const cachedBounds = getBounds(cachedDisplayPosition); + const bounds = getBounds(displayPosition); + + return isBoundsWithinCachedBounds(cachedBounds, bounds); +}; diff --git a/src/utils/storage/index.ts b/src/utils/storage/index.ts new file mode 100644 index 00000000..7c5ed83e --- /dev/null +++ b/src/utils/storage/index.ts @@ -0,0 +1,19 @@ +export const getLocalStorage = (key: string, initialValue: T): T => { + const item = localStorage.getItem(key); + + return item ? (JSON.parse(item) as T) : initialValue; +}; + +export const setLocalStorage = (key: string, value: T) => { + localStorage.setItem(key, JSON.stringify(value)); +}; + +export const getSessionStorage = (key: string, initialValue: T): T => { + const item = sessionStorage.getItem(key); + + return item ? (JSON.parse(item) as T) : initialValue; +}; + +export const setSessionStorage = (key: string, value: T) => { + sessionStorage.setItem(key, JSON.stringify(value)); +}; diff --git a/tsconfig.json b/tsconfig.json index e59724b2..16621c47 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, - "strict": true, + "strict": false, "noEmit": true, "esModuleInterop": true, "module": "esnext", @@ -19,7 +19,7 @@ } ], "paths": { - "@/*": ["./src/*"] + "@*": ["./src/*"], } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], From 344628d545618461ea2b5f9eb5f51cd426fb27ff Mon Sep 17 00:00:00 2001 From: gabrielyoon7 Date: Tue, 10 Oct 2023 02:31:05 +0900 Subject: [PATCH 04/13] =?UTF-8?q?chore:=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index b059f4c4..902e870e 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,14 @@ }, "dependencies": { "@googlemaps/react-wrapper": "^1.1.35", + "@heroicons/react": "^2.0.18", "@tanstack/react-query": "^4.29.25", "@types/google.maps": "^3.53.4", + "@types/styled-components": "^5.1.26", "react": "^18", "react-dom": "^18", + "react-icons": "^4.11.0", + "styled-components": "^6.0.4", "next": "13.5.4" }, "devDependencies": { From 8ea29884fe128f00ce9131ea25615fd56a58b3d8 Mon Sep 17 00:00:00 2001 From: gabrielyoon7 Date: Tue, 10 Oct 2023 14:51:47 +0900 Subject: [PATCH 05/13] =?UTF-8?q?chore:=20=EA=B8=B0=EC=A1=B4=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/globals.css | 37 ++ src/assets/google-logo.svg | 1 + src/assets/loading.svg | 27 ++ src/assets/logo-icon.svg | 21 + src/assets/logo-md.svg | 5 + src/assets/logo-sm.svg | 5 + src/assets/logo.svg | 5 + src/components/common/Alert/Alert.stories.tsx | 42 ++ src/components/common/Alert/Alert.style.ts | 70 +++ src/components/common/Alert/Alert.tsx | 17 + src/components/common/Alert/index.ts | 3 + src/components/common/Box/Box.stories.tsx | 145 ++++++ src/components/common/Box/Box.style.ts | 49 ++ src/components/common/Box/Box.tsx | 55 +++ src/components/common/Box/index.ts | 3 + .../common/Button/Button.stories.tsx | 177 +++++++ src/components/common/Button/Button.style.ts | 76 +++ src/components/common/Button/Button.tsx | 36 ++ src/components/common/Button/index.ts | 3 + .../common/ButtonNext/ButtonNext.stories.tsx | 231 +++++++++ .../common/ButtonNext/ButtonNext.style.ts | 96 ++++ .../common/ButtonNext/ButtonNext.tsx | 33 ++ src/components/common/ButtonNext/index.ts | 3 + .../common/FlexBox/FlexBox.stories.tsx | 221 +++++++++ .../common/FlexBox/FlexBox.style.ts | 82 ++++ src/components/common/FlexBox/FlexBox.tsx | 72 +++ src/components/common/FlexBox/index.ts | 3 + src/components/common/List/List.stories.tsx | 82 ++++ src/components/common/List/List.style.ts | 18 + src/components/common/List/List.tsx | 23 + src/components/common/List/index.ts | 3 + .../common/ListItem/ListItem.stories.tsx | 63 +++ .../common/ListItem/ListItem.style.ts | 17 + src/components/common/ListItem/ListItem.tsx | 27 ++ src/components/common/ListItem/index.ts | 3 + .../common/Loader/Loader.stories.tsx | 43 ++ src/components/common/Loader/Loader.style.ts | 59 +++ src/components/common/Loader/Loader.tsx | 17 + src/components/common/Loader/index.ts | 3 + src/components/common/Modal/Modal.stories.tsx | 202 ++++++++ src/components/common/Modal/Modal.style.ts | 42 ++ src/components/common/Modal/Modal.tsx | 61 +++ src/components/common/Modal/index.ts | 3 + .../common/SelectBox/SelectBox.stories.tsx | 43 ++ .../common/SelectBox/SelectBox.style.ts | 36 ++ src/components/common/SelectBox/SelectBox.tsx | 42 ++ src/components/common/SelectBox/index.ts | 0 .../common/Skeleton/Skeleton.stories.tsx | 61 +++ .../common/Skeleton/Skeleton.style.ts | 33 ++ src/components/common/Skeleton/Skeleton.tsx | 18 + src/components/common/Skeleton/index.ts | 3 + src/components/common/Text/Text.stories.tsx | 159 +++++++ src/components/common/Text/Text.style.ts | 121 +++++ src/components/common/Text/Text.tsx | 48 ++ src/components/common/Text/index.ts | 3 + .../common/TextField/TextField.stories.tsx | 99 ++++ .../common/TextField/TextField.style.ts | 54 +++ src/components/common/TextField/TextField.tsx | 50 ++ src/components/common/TextField/index.ts | 3 + src/components/common/Toast/Toast.stories.tsx | 124 +++++ src/components/common/Toast/Toast.style.ts | 85 ++++ src/components/common/Toast/Toast.tsx | 37 ++ src/components/common/Toast/index.ts | 3 + src/components/common/styles/spacing.ts | 100 ++++ src/components/common/utils/addUnit.ts | 9 + .../google-maps/map/CarFfeineMap.tsx | 24 + .../google-maps/map/UserFilterListener.tsx | 25 + .../hooks/useRegionMarkers.ts | 2 +- src/components/login-page/GoogleLogin.tsx | 66 +++ src/components/ui/ChargingSpeedIcon.tsx | 26 + src/components/ui/ClientStationFilters.tsx | 113 +++++ src/components/ui/Error.tsx | 54 +++ src/components/ui/Loading/Loading.stories.tsx | 14 + src/components/ui/Loading/Loading.style.ts | 20 + src/components/ui/Loading/Loading.tsx | 18 + src/components/ui/Loading/index.ts | 3 + src/components/ui/MapController.tsx | 104 ++++ src/components/ui/ModalContainer.tsx | 18 + .../ui/Navigator/NavigationBar/BasePanel.tsx | 31 ++ .../Navigator/NavigationBar/CloseButton.tsx | 32 ++ .../ui/Navigator/NavigationBar/LastPanel.tsx | 23 + .../ui/Navigator/NavigationBar/Menu.tsx | 179 +++++++ .../NavigationBar/NavigationBar.stories.tsx | 81 ++++ .../Navigator/NavigationBar/NavigationBar.tsx | 34 ++ .../Navigator/NavigationBar/PersonalMenu.tsx | 56 +++ .../NavigationBar/hooks/useNavigationBar.ts | 53 +++ .../ui/Navigator/NavigationBar/index.tsx | 3 + .../ui/Navigator/Navigator.test.tsx | 99 ++++ src/components/ui/Navigator/Navigator.tsx | 21 + .../ui/Navigator/ProfileMenu/Menus.tsx | 94 ++++ .../ProfileMenu/ProfileMenu.stories.tsx | 64 +++ .../ui/Navigator/ProfileMenu/ProfileMenu.tsx | 62 +++ .../ui/Navigator/ProfileMenu/index.tsx | 3 + src/components/ui/Navigator/index.tsx | 3 + .../ui/ServerStationFilters/FilterOption.tsx | 59 +++ .../FilterOptionSkeleton.tsx | 35 ++ .../ServerStationFilters.test.tsx | 138 ++++++ .../ServerStationFilters.tsx | 182 +++++++ .../ServerStationFiltersSkeleton.tsx | 42 ++ .../hooks/useStationFilters.ts | 76 +++ .../ui/ServerStationFilters/index.ts | 3 + src/components/ui/ShowHideButton.tsx | 37 ++ src/components/ui/Star/Star.stories.tsx | 60 +++ src/components/ui/Star/Star.tsx | 63 +++ src/components/ui/Star/index.ts | 3 + .../ui/StarRatings/StarRatings.stories.tsx | 61 +++ src/components/ui/StarRatings/StarRatings.tsx | 37 ++ src/components/ui/StarRatings/index.tsx | 3 + .../StationDetailsView.stories.tsx | 113 +++++ .../StationDetailsView.tsx | 78 +++ .../StationDetailsViewSkeleton.tsx | 25 + .../StationDetailsWindow.tsx | 63 +++ .../chargers/ChargerCard.stories.tsx | 31 ++ .../chargers/ChargerCard.test.tsx | 49 ++ .../chargers/ChargerCard.tsx | 91 ++++ .../chargers/ChargerCardSkeleton.tsx | 20 + .../chargers/ChargerList.tsx | 81 ++++ .../CongestionBarContainerSkeleton.tsx | 29 ++ .../congestion/CongestionStatistics.tsx | 30 ++ .../congestion/Help.stories.tsx | 19 + .../StationDetailsWindow/congestion/Help.tsx | 151 ++++++ .../congestion/Statistics.tsx | 96 ++++ .../StationDetailsWindow/congestion/Title.tsx | 28 ++ .../ui/StationDetailsWindow/index.tsx | 3 + .../reports/ReportButton.tsx | 83 ++++ .../reports/charger/ChargerReportButton.tsx | 51 ++ .../charger/ChargerReportConfirmation.tsx | 50 ++ .../reports/station/StationReportButton.tsx | 30 ++ .../station/StationReportConfirmation.tsx | 196 ++++++++ ...ationReportPreConfirmation.render.test.tsx | 49 ++ .../station/StationReportPreConfirmation.tsx | 81 ++++ .../reports/station/domain.ts | 11 + .../stationReportConfirmation.render.test.tsx | 58 +++ .../station/stationReportConfirmation.test.ts | 128 +++++ .../reviews/common/ContentField.tsx | 30 ++ .../reviews/common/HeaderWithRating.tsx | 28 ++ .../reviews/previews/ReviewPreview.tsx | 108 +++++ .../reviews/previews/ReviewPreviewList.tsx | 67 +++ .../previews/ReviewPreviewSkeleton.tsx | 21 + .../reviews/previews/UserRatings.stories.tsx | 25 + .../reviews/previews/UserRatings.tsx | 62 +++ .../reviews/previews/UserRatingsSkeleton.tsx | 16 + .../reviews/replies/ReplyCard.tsx | 103 ++++ .../reviews/replies/ReplyCardSkeleton.tsx | 18 + .../reviews/replies/ReplyCreate.tsx | 83 ++++ .../reviews/replies/ReplyList.tsx | 72 +++ .../reviews/replies/ReplyListLoading.tsx | 16 + .../reviews/replies/ReplyModify.tsx | 58 +++ .../reviews/reviews/ReviewCard.stories.tsx | 55 +++ .../reviews/reviews/ReviewCard.tsx | 135 ++++++ .../reviews/reviews/ReviewCardSkeleton.tsx | 17 + .../reviews/reviews/ReviewCardsLoading.tsx | 16 + .../reviews/reviews/ReviewCreate.tsx | 105 +++++ .../reviews/reviews/ReviewList.tsx | 96 ++++ .../reviews/reviews/ReviewModify.tsx | 37 ++ .../reviews/reviews/ReviewModifyButton.tsx | 62 +++ .../station/StationInformation.tsx | 93 ++++ .../station/StationInformationSkeleton.tsx | 28 ++ .../ui/StationDetailsWindow/validation.ts | 0 .../StationInfoWindow/StationInfo.stories.tsx | 78 +++ .../ui/StationInfoWindow/StationInfo.tsx | 136 ++++++ .../StationInfoWindow/StationInfoWindow.tsx | 56 +++ .../ui/StationInfoWindow/SummaryButtons.tsx | 66 +++ src/components/ui/StationInfoWindow/index.tsx | 3 + .../useFetchStationDetails.ts | 28 ++ .../ui/StationListWindow/StationList.tsx | 103 ++++ .../StationListWindow/StationListWindow.tsx | 51 ++ .../StationListWindow/StationSummaryCard.tsx | 94 ++++ .../StationSummaryCardList.tsx | 32 ++ .../fallbacks/EmptyStationsNotice.tsx | 21 + .../fallbacks/StationListSkeletons.tsx | 13 + .../StationSummaryCardSkeleton.stories.tsx | 33 ++ .../fallbacks/StationSummaryCardSkeleton.tsx | 37 ++ .../hooks/useInfiniteStationSummaries.ts | 64 +++ src/components/ui/StationListWindow/index.ts | 3 + .../tools/cachedStationSummaries.ts | 57 +++ .../ui/StationSearchWindow/NoResult.tsx | 19 + .../SearchResult.stories.tsx | 127 +++++ .../StationSearchWindow/SearchResult.style.ts | 31 ++ .../ui/StationSearchWindow/SearchResult.tsx | 80 ++++ .../StationSearchWindow/SearchedCityCard.tsx | 54 +++ .../SearchedStationCard.tsx | 55 +++ .../StationSearchBar.stories.tsx | 175 +++++++ .../StationSearchBar.style.ts | 45 ++ .../StationSearchWindow/StationSearchBar.tsx | 79 ++++ .../StationSearchWindow.stories.tsx | 47 ++ .../StationSearchWindow.tsx | 48 ++ .../hooks/useStationSearchWindow.tsx | 119 +++++ .../ui/StationSearchWindow/index.ts | 3 + .../tools/convertStationDetailsToSummary.ts | 41 ++ .../ui/StatisticsGraph/Graph/Bar.stories.tsx | 27 ++ .../ui/StatisticsGraph/Graph/Bar.tsx | 80 ++++ .../ui/StatisticsGraph/Graph/BarContainer.tsx | 37 ++ .../Graph/CircleDaySelectButton.tsx | 72 +++ .../ui/StatisticsGraph/Graph/DayMenus.tsx | 18 + .../ui/StatisticsGraph/Graph/index.tsx | 27 ++ .../StatisticsGraph.stories.tsx | 42 ++ src/components/ui/StatisticsGraph/index.tsx | 44 ++ src/components/ui/Svg/LogoIcon.tsx | 68 +++ src/components/ui/ToastContainer.tsx | 14 + .../ui/WarningModal/ZoomWarningModal.style.ts | 24 + .../ui/WarningModal/ZoomWarningModal.tsx | 22 + src/components/ui/WarningModal/index.tsx | 3 + src/components/ui/WarningModalContainer.tsx | 53 +++ .../ui/modal/CarModal/CarModal.stories.tsx | 15 + src/components/ui/modal/CarModal/CarModal.tsx | 180 +++++++ .../ui/modal/CarModal/fetch/index.ts | 64 +++ .../modal/LoginModal/LoginModal.stories.tsx | 15 + .../ui/modal/LoginModal/LoginModal.tsx | 74 +++ src/hooks/fetch/fetchStationSummaries.ts | 22 + .../google-maps/useStationInfoWindow.tsx | 54 +++ src/hooks/tanstack-query/car/useCars.ts | 22 + .../reports/useStationChargerReport.ts | 36 ++ .../reports/useUpdateStationChargerReport.ts | 39 ++ .../reports/useUpdateStationReport.ts | 42 ++ .../station-details/reviews/useCreateReply.ts | 50 ++ .../reviews/useCreateReview.ts | 52 ++ .../reviews/useInfiniteReplies.ts | 28 ++ .../reviews/useInfiniteReviews.ts | 28 ++ .../station-details/reviews/useModifyReply.ts | 58 +++ .../reviews/useModifyReview.ts | 58 +++ .../station-details/reviews/useRemoveReply.ts | 56 +++ .../reviews/useRemoveReview.ts | 55 +++ .../reviews/useReviewRatings.ts | 20 + .../station-details/reviews/useReviews.ts | 22 + .../useStationCongestionStatistics.ts | 35 ++ .../station-details/useStationDetails.ts | 34 ++ .../station-filters/useCarFilters.ts | 36 ++ .../station-filters/useMemberFilters.ts | 35 ++ .../useServerStationFilters.ts | 21 + src/hooks/tanstack-query/useSearchStations.ts | 39 ++ src/hooks/useDebounce.ts | 11 + src/hooks/useMediaQueries.ts | 39 ++ src/hooks/useServerStationFilterActions.ts | 91 ++++ src/mocks/browser.ts | 5 + src/mocks/data.ts | 444 ++++++++++++++++++ src/mocks/handlers/car/carHandler.ts | 18 + src/mocks/handlers/index.ts | 27 ++ src/mocks/handlers/login/loginHandlers.ts | 19 + src/mocks/handlers/memberHandlers.ts | 48 ++ .../reports/stationReportHandlers.ts | 41 ++ .../reviews/stationReviewHandlers.ts | 131 ++++++ .../station-details/stationDetailHandlers.ts | 18 + .../station-details/statisticsHandlers.ts | 36 ++ .../station-filters/memberFilterHandlers.ts | 46 ++ .../station-filters/serverFilterHandlers.ts | 19 + .../station-markers/stationHandlers.ts | 60 +++ .../station-markers/stationMarkerHandlers.ts | 120 +++++ src/mocks/handlers/stationSearchHandlers.ts | 24 + src/mocks/node.ts | 8 + src/router/index.tsx | 16 + src/style/GlobalStyle.ts | 41 ++ src/style/index.ts | 128 +++++ src/style/mediaQuery.ts | 15 + src/style/reset.ts | 102 ++++ src/types/assets.d.ts | 6 + src/types/cars.ts | 5 + src/types/chargers.ts | 21 + src/types/congestion.ts | 22 + src/types/index.ts | 5 + src/types/map.ts | 18 + src/types/serverStationFilter.ts | 7 + src/types/stations.ts | 132 ++++++ src/types/style.ts | 23 + src/utils/calculateToastDuration.test.ts | 14 + src/utils/calculateToastDuration.ts | 13 + src/utils/debounce.test.ts | 20 + src/utils/debounce.ts | 16 + src/utils/fetch/index.ts | 69 +++ src/utils/getTypedObjectEntries.ts | 7 + src/utils/getTypedObjectFromEntries.ts | 37 ++ src/utils/getTypedObjectKeys.ts | 3 + src/utils/index.ts | 24 + src/utils/login/index.ts | 96 ++++ src/utils/mswModeActions.ts | 17 + src/utils/randomDataGenerator.ts | 49 ++ .../getQueryFormattedUrl.test.ts | 25 + src/utils/request-query-params/index.ts | 9 + tsconfig.json | 16 + 279 files changed, 14113 insertions(+), 1 deletion(-) create mode 100644 src/assets/google-logo.svg create mode 100644 src/assets/loading.svg create mode 100644 src/assets/logo-icon.svg create mode 100644 src/assets/logo-md.svg create mode 100644 src/assets/logo-sm.svg create mode 100644 src/assets/logo.svg create mode 100644 src/components/common/Alert/Alert.stories.tsx create mode 100644 src/components/common/Alert/Alert.style.ts create mode 100644 src/components/common/Alert/Alert.tsx create mode 100644 src/components/common/Alert/index.ts create mode 100644 src/components/common/Box/Box.stories.tsx create mode 100644 src/components/common/Box/Box.style.ts create mode 100644 src/components/common/Box/Box.tsx create mode 100644 src/components/common/Box/index.ts create mode 100644 src/components/common/Button/Button.stories.tsx create mode 100644 src/components/common/Button/Button.style.ts create mode 100644 src/components/common/Button/Button.tsx create mode 100644 src/components/common/Button/index.ts create mode 100644 src/components/common/ButtonNext/ButtonNext.stories.tsx create mode 100644 src/components/common/ButtonNext/ButtonNext.style.ts create mode 100644 src/components/common/ButtonNext/ButtonNext.tsx create mode 100644 src/components/common/ButtonNext/index.ts create mode 100644 src/components/common/FlexBox/FlexBox.stories.tsx create mode 100644 src/components/common/FlexBox/FlexBox.style.ts create mode 100644 src/components/common/FlexBox/FlexBox.tsx create mode 100644 src/components/common/FlexBox/index.ts create mode 100644 src/components/common/List/List.stories.tsx create mode 100644 src/components/common/List/List.style.ts create mode 100644 src/components/common/List/List.tsx create mode 100644 src/components/common/List/index.ts create mode 100644 src/components/common/ListItem/ListItem.stories.tsx create mode 100644 src/components/common/ListItem/ListItem.style.ts create mode 100644 src/components/common/ListItem/ListItem.tsx create mode 100644 src/components/common/ListItem/index.ts create mode 100644 src/components/common/Loader/Loader.stories.tsx create mode 100644 src/components/common/Loader/Loader.style.ts create mode 100644 src/components/common/Loader/Loader.tsx create mode 100644 src/components/common/Loader/index.ts create mode 100644 src/components/common/Modal/Modal.stories.tsx create mode 100644 src/components/common/Modal/Modal.style.ts create mode 100644 src/components/common/Modal/Modal.tsx create mode 100644 src/components/common/Modal/index.ts create mode 100644 src/components/common/SelectBox/SelectBox.stories.tsx create mode 100644 src/components/common/SelectBox/SelectBox.style.ts create mode 100644 src/components/common/SelectBox/SelectBox.tsx create mode 100644 src/components/common/SelectBox/index.ts create mode 100644 src/components/common/Skeleton/Skeleton.stories.tsx create mode 100644 src/components/common/Skeleton/Skeleton.style.ts create mode 100644 src/components/common/Skeleton/Skeleton.tsx create mode 100644 src/components/common/Skeleton/index.ts create mode 100644 src/components/common/Text/Text.stories.tsx create mode 100644 src/components/common/Text/Text.style.ts create mode 100644 src/components/common/Text/Text.tsx create mode 100644 src/components/common/Text/index.ts create mode 100644 src/components/common/TextField/TextField.stories.tsx create mode 100644 src/components/common/TextField/TextField.style.ts create mode 100644 src/components/common/TextField/TextField.tsx create mode 100644 src/components/common/TextField/index.ts create mode 100644 src/components/common/Toast/Toast.stories.tsx create mode 100644 src/components/common/Toast/Toast.style.ts create mode 100644 src/components/common/Toast/Toast.tsx create mode 100644 src/components/common/Toast/index.ts create mode 100644 src/components/common/styles/spacing.ts create mode 100644 src/components/common/utils/addUnit.ts create mode 100644 src/components/google-maps/map/UserFilterListener.tsx create mode 100644 src/components/login-page/GoogleLogin.tsx create mode 100644 src/components/ui/ChargingSpeedIcon.tsx create mode 100644 src/components/ui/ClientStationFilters.tsx create mode 100644 src/components/ui/Error.tsx create mode 100644 src/components/ui/Loading/Loading.stories.tsx create mode 100644 src/components/ui/Loading/Loading.style.ts create mode 100644 src/components/ui/Loading/Loading.tsx create mode 100644 src/components/ui/Loading/index.ts create mode 100644 src/components/ui/MapController.tsx create mode 100644 src/components/ui/ModalContainer.tsx create mode 100644 src/components/ui/Navigator/NavigationBar/BasePanel.tsx create mode 100644 src/components/ui/Navigator/NavigationBar/CloseButton.tsx create mode 100644 src/components/ui/Navigator/NavigationBar/LastPanel.tsx create mode 100644 src/components/ui/Navigator/NavigationBar/Menu.tsx create mode 100644 src/components/ui/Navigator/NavigationBar/NavigationBar.stories.tsx create mode 100644 src/components/ui/Navigator/NavigationBar/NavigationBar.tsx create mode 100644 src/components/ui/Navigator/NavigationBar/PersonalMenu.tsx create mode 100644 src/components/ui/Navigator/NavigationBar/hooks/useNavigationBar.ts create mode 100644 src/components/ui/Navigator/NavigationBar/index.tsx create mode 100644 src/components/ui/Navigator/Navigator.test.tsx create mode 100644 src/components/ui/Navigator/Navigator.tsx create mode 100644 src/components/ui/Navigator/ProfileMenu/Menus.tsx create mode 100644 src/components/ui/Navigator/ProfileMenu/ProfileMenu.stories.tsx create mode 100644 src/components/ui/Navigator/ProfileMenu/ProfileMenu.tsx create mode 100644 src/components/ui/Navigator/ProfileMenu/index.tsx create mode 100644 src/components/ui/Navigator/index.tsx create mode 100644 src/components/ui/ServerStationFilters/FilterOption.tsx create mode 100644 src/components/ui/ServerStationFilters/FilterOptionSkeleton.tsx create mode 100644 src/components/ui/ServerStationFilters/ServerStationFilters.test.tsx create mode 100644 src/components/ui/ServerStationFilters/ServerStationFilters.tsx create mode 100644 src/components/ui/ServerStationFilters/ServerStationFiltersSkeleton.tsx create mode 100644 src/components/ui/ServerStationFilters/hooks/useStationFilters.ts create mode 100644 src/components/ui/ServerStationFilters/index.ts create mode 100644 src/components/ui/ShowHideButton.tsx create mode 100644 src/components/ui/Star/Star.stories.tsx create mode 100644 src/components/ui/Star/Star.tsx create mode 100644 src/components/ui/Star/index.ts create mode 100644 src/components/ui/StarRatings/StarRatings.stories.tsx create mode 100644 src/components/ui/StarRatings/StarRatings.tsx create mode 100644 src/components/ui/StarRatings/index.tsx create mode 100644 src/components/ui/StationDetailsWindow/StationDetailsView.stories.tsx create mode 100644 src/components/ui/StationDetailsWindow/StationDetailsView.tsx create mode 100644 src/components/ui/StationDetailsWindow/StationDetailsViewSkeleton.tsx create mode 100644 src/components/ui/StationDetailsWindow/StationDetailsWindow.tsx create mode 100644 src/components/ui/StationDetailsWindow/chargers/ChargerCard.stories.tsx create mode 100644 src/components/ui/StationDetailsWindow/chargers/ChargerCard.test.tsx create mode 100644 src/components/ui/StationDetailsWindow/chargers/ChargerCard.tsx create mode 100644 src/components/ui/StationDetailsWindow/chargers/ChargerCardSkeleton.tsx create mode 100644 src/components/ui/StationDetailsWindow/chargers/ChargerList.tsx create mode 100644 src/components/ui/StationDetailsWindow/congestion/CongestionBarContainerSkeleton.tsx create mode 100644 src/components/ui/StationDetailsWindow/congestion/CongestionStatistics.tsx create mode 100644 src/components/ui/StationDetailsWindow/congestion/Help.stories.tsx create mode 100644 src/components/ui/StationDetailsWindow/congestion/Help.tsx create mode 100644 src/components/ui/StationDetailsWindow/congestion/Statistics.tsx create mode 100644 src/components/ui/StationDetailsWindow/congestion/Title.tsx create mode 100644 src/components/ui/StationDetailsWindow/index.tsx create mode 100644 src/components/ui/StationDetailsWindow/reports/ReportButton.tsx create mode 100644 src/components/ui/StationDetailsWindow/reports/charger/ChargerReportButton.tsx create mode 100644 src/components/ui/StationDetailsWindow/reports/charger/ChargerReportConfirmation.tsx create mode 100644 src/components/ui/StationDetailsWindow/reports/station/StationReportButton.tsx create mode 100644 src/components/ui/StationDetailsWindow/reports/station/StationReportConfirmation.tsx create mode 100644 src/components/ui/StationDetailsWindow/reports/station/StationReportPreConfirmation.render.test.tsx create mode 100644 src/components/ui/StationDetailsWindow/reports/station/StationReportPreConfirmation.tsx create mode 100644 src/components/ui/StationDetailsWindow/reports/station/domain.ts create mode 100644 src/components/ui/StationDetailsWindow/reports/station/stationReportConfirmation.render.test.tsx create mode 100644 src/components/ui/StationDetailsWindow/reports/station/stationReportConfirmation.test.ts create mode 100644 src/components/ui/StationDetailsWindow/reviews/common/ContentField.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/common/HeaderWithRating.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/previews/ReviewPreview.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/previews/ReviewPreviewList.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/previews/ReviewPreviewSkeleton.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/previews/UserRatings.stories.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/previews/UserRatings.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/previews/UserRatingsSkeleton.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/replies/ReplyCard.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/replies/ReplyCardSkeleton.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/replies/ReplyCreate.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/replies/ReplyList.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/replies/ReplyListLoading.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/replies/ReplyModify.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCard.stories.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCard.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCardSkeleton.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCardsLoading.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCreate.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/reviews/ReviewList.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/reviews/ReviewModify.tsx create mode 100644 src/components/ui/StationDetailsWindow/reviews/reviews/ReviewModifyButton.tsx create mode 100644 src/components/ui/StationDetailsWindow/station/StationInformation.tsx create mode 100644 src/components/ui/StationDetailsWindow/station/StationInformationSkeleton.tsx create mode 100644 src/components/ui/StationDetailsWindow/validation.ts create mode 100644 src/components/ui/StationInfoWindow/StationInfo.stories.tsx create mode 100644 src/components/ui/StationInfoWindow/StationInfo.tsx create mode 100644 src/components/ui/StationInfoWindow/StationInfoWindow.tsx create mode 100644 src/components/ui/StationInfoWindow/SummaryButtons.tsx create mode 100644 src/components/ui/StationInfoWindow/index.tsx create mode 100644 src/components/ui/StationInfoWindow/useFetchStationDetails.ts create mode 100644 src/components/ui/StationListWindow/StationList.tsx create mode 100644 src/components/ui/StationListWindow/StationListWindow.tsx create mode 100644 src/components/ui/StationListWindow/StationSummaryCard.tsx create mode 100644 src/components/ui/StationListWindow/StationSummaryCardList.tsx create mode 100644 src/components/ui/StationListWindow/fallbacks/EmptyStationsNotice.tsx create mode 100644 src/components/ui/StationListWindow/fallbacks/StationListSkeletons.tsx create mode 100644 src/components/ui/StationListWindow/fallbacks/StationSummaryCardSkeleton.stories.tsx create mode 100644 src/components/ui/StationListWindow/fallbacks/StationSummaryCardSkeleton.tsx create mode 100644 src/components/ui/StationListWindow/hooks/useInfiniteStationSummaries.ts create mode 100644 src/components/ui/StationListWindow/index.ts create mode 100644 src/components/ui/StationListWindow/tools/cachedStationSummaries.ts create mode 100644 src/components/ui/StationSearchWindow/NoResult.tsx create mode 100644 src/components/ui/StationSearchWindow/SearchResult.stories.tsx create mode 100644 src/components/ui/StationSearchWindow/SearchResult.style.ts create mode 100644 src/components/ui/StationSearchWindow/SearchResult.tsx create mode 100644 src/components/ui/StationSearchWindow/SearchedCityCard.tsx create mode 100644 src/components/ui/StationSearchWindow/SearchedStationCard.tsx create mode 100644 src/components/ui/StationSearchWindow/StationSearchBar.stories.tsx create mode 100644 src/components/ui/StationSearchWindow/StationSearchBar.style.ts create mode 100644 src/components/ui/StationSearchWindow/StationSearchBar.tsx create mode 100644 src/components/ui/StationSearchWindow/StationSearchWindow.stories.tsx create mode 100644 src/components/ui/StationSearchWindow/StationSearchWindow.tsx create mode 100644 src/components/ui/StationSearchWindow/hooks/useStationSearchWindow.tsx create mode 100644 src/components/ui/StationSearchWindow/index.ts create mode 100644 src/components/ui/StationSearchWindow/tools/convertStationDetailsToSummary.ts create mode 100644 src/components/ui/StatisticsGraph/Graph/Bar.stories.tsx create mode 100644 src/components/ui/StatisticsGraph/Graph/Bar.tsx create mode 100644 src/components/ui/StatisticsGraph/Graph/BarContainer.tsx create mode 100644 src/components/ui/StatisticsGraph/Graph/CircleDaySelectButton.tsx create mode 100644 src/components/ui/StatisticsGraph/Graph/DayMenus.tsx create mode 100644 src/components/ui/StatisticsGraph/Graph/index.tsx create mode 100644 src/components/ui/StatisticsGraph/StatisticsGraph.stories.tsx create mode 100644 src/components/ui/StatisticsGraph/index.tsx create mode 100644 src/components/ui/Svg/LogoIcon.tsx create mode 100644 src/components/ui/ToastContainer.tsx create mode 100644 src/components/ui/WarningModal/ZoomWarningModal.style.ts create mode 100644 src/components/ui/WarningModal/ZoomWarningModal.tsx create mode 100644 src/components/ui/WarningModal/index.tsx create mode 100644 src/components/ui/WarningModalContainer.tsx create mode 100644 src/components/ui/modal/CarModal/CarModal.stories.tsx create mode 100644 src/components/ui/modal/CarModal/CarModal.tsx create mode 100644 src/components/ui/modal/CarModal/fetch/index.ts create mode 100644 src/components/ui/modal/LoginModal/LoginModal.stories.tsx create mode 100644 src/components/ui/modal/LoginModal/LoginModal.tsx create mode 100644 src/hooks/fetch/fetchStationSummaries.ts create mode 100644 src/hooks/google-maps/useStationInfoWindow.tsx create mode 100644 src/hooks/tanstack-query/car/useCars.ts create mode 100644 src/hooks/tanstack-query/station-details/reports/useStationChargerReport.ts create mode 100644 src/hooks/tanstack-query/station-details/reports/useUpdateStationChargerReport.ts create mode 100644 src/hooks/tanstack-query/station-details/reports/useUpdateStationReport.ts create mode 100644 src/hooks/tanstack-query/station-details/reviews/useCreateReply.ts create mode 100644 src/hooks/tanstack-query/station-details/reviews/useCreateReview.ts create mode 100644 src/hooks/tanstack-query/station-details/reviews/useInfiniteReplies.ts create mode 100644 src/hooks/tanstack-query/station-details/reviews/useInfiniteReviews.ts create mode 100644 src/hooks/tanstack-query/station-details/reviews/useModifyReply.ts create mode 100644 src/hooks/tanstack-query/station-details/reviews/useModifyReview.ts create mode 100644 src/hooks/tanstack-query/station-details/reviews/useRemoveReply.ts create mode 100644 src/hooks/tanstack-query/station-details/reviews/useRemoveReview.ts create mode 100644 src/hooks/tanstack-query/station-details/reviews/useReviewRatings.ts create mode 100644 src/hooks/tanstack-query/station-details/reviews/useReviews.ts create mode 100644 src/hooks/tanstack-query/station-details/useStationCongestionStatistics.ts create mode 100644 src/hooks/tanstack-query/station-details/useStationDetails.ts create mode 100644 src/hooks/tanstack-query/station-filters/useCarFilters.ts create mode 100644 src/hooks/tanstack-query/station-filters/useMemberFilters.ts create mode 100644 src/hooks/tanstack-query/station-filters/useServerStationFilters.ts create mode 100644 src/hooks/tanstack-query/useSearchStations.ts create mode 100644 src/hooks/useDebounce.ts create mode 100644 src/hooks/useMediaQueries.ts create mode 100644 src/hooks/useServerStationFilterActions.ts create mode 100644 src/mocks/browser.ts create mode 100644 src/mocks/data.ts create mode 100644 src/mocks/handlers/car/carHandler.ts create mode 100644 src/mocks/handlers/index.ts create mode 100644 src/mocks/handlers/login/loginHandlers.ts create mode 100644 src/mocks/handlers/memberHandlers.ts create mode 100644 src/mocks/handlers/station-details/reports/stationReportHandlers.ts create mode 100644 src/mocks/handlers/station-details/reviews/stationReviewHandlers.ts create mode 100644 src/mocks/handlers/station-details/stationDetailHandlers.ts create mode 100644 src/mocks/handlers/station-details/statisticsHandlers.ts create mode 100644 src/mocks/handlers/station-filters/memberFilterHandlers.ts create mode 100644 src/mocks/handlers/station-filters/serverFilterHandlers.ts create mode 100644 src/mocks/handlers/station-markers/stationHandlers.ts create mode 100644 src/mocks/handlers/station-markers/stationMarkerHandlers.ts create mode 100644 src/mocks/handlers/stationSearchHandlers.ts create mode 100644 src/mocks/node.ts create mode 100644 src/router/index.tsx create mode 100644 src/style/GlobalStyle.ts create mode 100644 src/style/index.ts create mode 100644 src/style/mediaQuery.ts create mode 100644 src/style/reset.ts create mode 100644 src/types/assets.d.ts create mode 100644 src/types/cars.ts create mode 100644 src/types/chargers.ts create mode 100644 src/types/congestion.ts create mode 100644 src/types/index.ts create mode 100644 src/types/map.ts create mode 100644 src/types/serverStationFilter.ts create mode 100644 src/types/stations.ts create mode 100644 src/types/style.ts create mode 100644 src/utils/calculateToastDuration.test.ts create mode 100644 src/utils/calculateToastDuration.ts create mode 100644 src/utils/debounce.test.ts create mode 100644 src/utils/debounce.ts create mode 100644 src/utils/fetch/index.ts create mode 100644 src/utils/getTypedObjectEntries.ts create mode 100644 src/utils/getTypedObjectFromEntries.ts create mode 100644 src/utils/getTypedObjectKeys.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/login/index.ts create mode 100644 src/utils/mswModeActions.ts create mode 100644 src/utils/randomDataGenerator.ts create mode 100644 src/utils/request-query-params/getQueryFormattedUrl.test.ts create mode 100644 src/utils/request-query-params/index.ts diff --git a/src/app/globals.css b/src/app/globals.css index 6c5bd480..c63024b7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -94,3 +94,40 @@ progress[value] { -webkit-appearance: none; appearance: none; } + +/* 이하는 GlobalStyle 이식 */ + + +* { + font-family: 'Noto Sans KR', sans-serif !important; +} + +html, +body { + font-size: 62.5%; + /********** hidden scroll **********/ + scrollbar-width: none; +} + +::-webkit-scrollbar { + display: none; + } + +:root { + --light-color: #e9edf8; + --lighter-color: #eef0f5; + + --gray-200-color: #ebebeb; +} + +body:has(.modal-open) { + overflow: hidden; +} + +button.gm-ui-hover-effect { + visibility: hidden; +} + +div.gm-style .gm-style-iw-c { + padding: 0; +} diff --git a/src/assets/google-logo.svg b/src/assets/google-logo.svg new file mode 100644 index 00000000..986851c0 --- /dev/null +++ b/src/assets/google-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/loading.svg b/src/assets/loading.svg new file mode 100644 index 00000000..5ca2adf1 --- /dev/null +++ b/src/assets/loading.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/logo-icon.svg b/src/assets/logo-icon.svg new file mode 100644 index 00000000..6b1e9db8 --- /dev/null +++ b/src/assets/logo-icon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/logo-md.svg b/src/assets/logo-md.svg new file mode 100644 index 00000000..bcf31ce4 --- /dev/null +++ b/src/assets/logo-md.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/logo-sm.svg b/src/assets/logo-sm.svg new file mode 100644 index 00000000..dee75a15 --- /dev/null +++ b/src/assets/logo-sm.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/logo.svg b/src/assets/logo.svg new file mode 100644 index 00000000..594b90db --- /dev/null +++ b/src/assets/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/common/Alert/Alert.stories.tsx b/src/components/common/Alert/Alert.stories.tsx new file mode 100644 index 00000000..0c3fa94b --- /dev/null +++ b/src/components/common/Alert/Alert.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta } from '@storybook/react'; + +import type { AlertProps } from './Alert'; +import Alert from './Alert'; + +const meta = { + title: 'Components/Alert', + component: Alert, + tags: ['autodocs'], + args: { + color: 'primary', + text: 'You forget a thousand things every day. Make sure this is one of them.', + }, + argTypes: { + color: { + description: '선택한 색상에 따라 배경색이 변합니다.', + }, + text: { + description: '원하는 글자를 입력해 테스트를 할 수 있습니다.', + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = (args: AlertProps) => { + return ; +}; +export const Colors = () => { + return ( + <> + + + + + + + + + + ); +}; diff --git a/src/components/common/Alert/Alert.style.ts b/src/components/common/Alert/Alert.style.ts new file mode 100644 index 00000000..695670cc --- /dev/null +++ b/src/components/common/Alert/Alert.style.ts @@ -0,0 +1,70 @@ +import styled, { css } from 'styled-components'; + +import type { AlertProps } from '@common/Alert/Alert'; + +export const StyledAlert = styled.div` + ${({ color }) => { + switch (color) { + case 'primary': + return css` + color: #052c65; + background: #cfe2ff; + border: 1px solid #9ec5fe; + `; + case 'secondary': + return css` + color: #2b2f32; + background: #e9ecef; + border: 1px solid #c4c8cb; + `; + case 'success': + return css` + color: #0a3622; + background: #d1e7dd; + border: 1px solid #a3cfbb; + `; + case 'error': + return css` + color: #58151c; + background: #f8d7da; + border: 1px solid #f1aeb5; + `; + case 'warning': + return css` + color: #664d03; + background: #fff3cd; + border: 1px solid #ffe69c; + `; + case 'info': + return css` + color: #055160; + background: #cff4fc; + border: 1px solid #9eeaf9; + `; + case 'light': + return css` + color: #495057; + background: #fcfcfd; + border: 1px solid #e9ecef; + `; + case 'dark': + return css` + color: #495057; + background: #ced4da; + border: 1px solid #adb5bd; + `; + default: + return css` + color: #495057; + background: #fcfcfd; + border: 1px solid #e9ecef; + `; + } + }} + padding: 2rem; + border-radius: 4px; + margin-bottom: 1rem; + font-size: 1.5rem; + + ${({ css }) => css} +`; diff --git a/src/components/common/Alert/Alert.tsx b/src/components/common/Alert/Alert.tsx new file mode 100644 index 00000000..f5090696 --- /dev/null +++ b/src/components/common/Alert/Alert.tsx @@ -0,0 +1,17 @@ +import type { CSSProp } from 'styled-components'; + +import type { Color } from '@type/style'; + +import { StyledAlert } from './Alert.style'; + +export interface AlertProps { + color: Color; + text: string; + css?: CSSProp; +} + +const Alert = ({ ...props }: AlertProps) => { + return {props.text}; +}; + +export default Alert; diff --git a/src/components/common/Alert/index.ts b/src/components/common/Alert/index.ts new file mode 100644 index 00000000..96112311 --- /dev/null +++ b/src/components/common/Alert/index.ts @@ -0,0 +1,3 @@ +import Alert from './Alert'; + +export default Alert; diff --git a/src/components/common/Box/Box.stories.tsx b/src/components/common/Box/Box.stories.tsx new file mode 100644 index 00000000..38435231 --- /dev/null +++ b/src/components/common/Box/Box.stories.tsx @@ -0,0 +1,145 @@ +import type { Meta } from '@storybook/react'; + +import Text from '../Text'; +import type { BoxProps } from './Box'; +import Box from './Box'; + +const meta = { + title: 'Components/Box', + component: Box, + tags: ['autodocs'], + args: { + border: false, + pl: 0, + pr: 0, + pt: 0, + pb: 0, + px: 0, + py: 0, + p: 0, + ml: 0, + mr: 0, + mt: 0, + mb: 0, + mx: 0, + my: 0, + m: 0, + height: 0, + minHeight: 0, + maxHeight: 0, + width: 0, + minWidth: 0, + maxWidth: 0, + position: 'absolute', + }, + argTypes: { + children: { + description: 'div처럼 사용할 수 있습니다.', + }, + border: { + description: '테두리를 그릴 수 있습니다.', + }, + pl: { + description: '왼쪽의 방향으로 패딩을 줍니다. 우선 순위가 제일 높습니다.', + }, + pr: { + description: '오른쪽의 방향으로 패딩을 줍니다. 우선 순위가 제일 높습니다.', + }, + pt: { + description: '천장의 방향으로 패딩을 줍니다. 우선 순위가 제일 높습니다.', + }, + pb: { + description: '바닥의 방향으로 패딩을 줍니다. 우선 순위가 제일 높습니다.', + }, + px: { + description: 'x축의 방향으로 패딩을 줍니다. 우선 순위가 중간입니다.', + }, + py: { + description: 'y축의 방향으로 패딩을 줍니다. 우선 순위가 중간입니다.', + }, + p: { + description: '4방향으로 패딩을 줍니다. 우선 순위가 제일 낮습니다.', + }, + ml: { + description: '왼쪽의 방향으로 마진을 줍니다. 우선 순위가 제일 높습니다.', + }, + mr: { + description: '오른쪽의 방향으로 마진을 줍니다. 우선 순위가 제일 높습니다.', + }, + mt: { + description: '천장의 방향으로 마진을 줍니다. 우선 순위가 제일 높습니다.', + }, + mb: { + description: '바닥의 방향으로 마진을 줍니다. 우선 순위가 제일 높습니다.', + }, + mx: { + description: 'x축의 방향으로 마진을 줍니다. 우선 순위가 중간입니다.', + }, + my: { + description: 'y축의 방향으로 마진을 줍니다. 우선 순위가 중간입니다.', + }, + m: { + description: '4방향으로 마진을 줍니다. 우선 순위가 제일 낮습니다.', + }, + height: { + description: '높이 길이를 설정할 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', + }, + minHeight: { + description: '최소 높이 길이를 설정할 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', + }, + maxHeight: { + description: '최대 높이 길이를 설정할 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', + }, + width: { + description: '너비 길이를 설정할 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', + }, + minWidth: { + description: '최소 너비 길이를 설정할 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', + }, + maxWidth: { + description: '최대 너비 길이를 설정할 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', + }, + bgColor: { + description: '배경 색을 정할 수 있습니다.', + }, + color: { + description: '대표 글자 색을 정할 수 있습니다.', + }, + position: { + options: { + none: false, + static: 'static', + relative: 'relative', + absolute: 'absolute', + fixed: 'fixed', + sticky: 'sticky', + }, + control: { + type: 'select', + }, + description: 'position을 설정합니다.', + }, + top: { + description: 'position과 함께 쓸 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', + }, + left: { + description: 'position과 함께 쓸 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', + }, + bottom: { + description: 'position과 함께 쓸 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', + }, + right: { + description: 'position과 함께 쓸 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = (args: BoxProps) => { + return ( + + 이것은 아무것도 없는 박스입니다. + + ); +}; diff --git a/src/components/common/Box/Box.style.ts b/src/components/common/Box/Box.style.ts new file mode 100644 index 00000000..a14076ed --- /dev/null +++ b/src/components/common/Box/Box.style.ts @@ -0,0 +1,49 @@ +import styled, { css } from 'styled-components'; + +import type { BoxProps } from '@common/Box/Box'; +import { spacing } from '@common/styles/spacing'; + +const addUnitForBorder = (borderProp: number | string) => { + return typeof borderProp === 'number' ? `${borderProp}px` : borderProp; +}; +const borderStyle = ({ + border, + borderColor, + borderWidth, + borderRadius, +}: Pick) => css` + ${border === true && `border: 0.1px solid #66666666; border-radius: 4px;`} + + ${typeof border !== 'boolean' && `border-${border}: 0.1px solid #66666666`}; + + ${borderColor !== undefined && `border-color: ${borderColor}`}; + ${borderWidth !== undefined && `border-width: ${addUnitForBorder(borderWidth)}`}; + + ${borderRadius !== undefined && `border-radius: ${addUnitForBorder(borderRadius)}`}; +`; + +export const StyledBox = styled.div` + ${spacing} + + ${({ border, borderColor, borderWidth, borderRadius }) => + borderStyle({ border, borderColor, borderWidth, borderRadius })} + + height: ${({ height }) => (typeof height === 'string' ? height : `${height * 0.4}rem`)}; + min-height: ${({ minHeight }) => (typeof minHeight === 'string' ? minHeight : `${minHeight}rem`)}; + max-height: ${({ maxHeight }) => (typeof maxHeight === 'string' ? maxHeight : `${maxHeight}rem`)}; + + width: ${({ width }) => (typeof width === 'string' ? width : `${width * 0.4}rem`)}; + min-width: ${({ minWidth }) => (typeof minWidth === 'string' ? minWidth : `${minWidth}rem`)}; + max-width: ${({ maxWidth }) => (typeof maxWidth === 'string' ? maxWidth : `${maxWidth}rem`)}; + + ${({ bgColor }) => bgColor && `background: ${bgColor}`}; + ${({ color }) => color && `color: ${color}`}; + + ${({ position }) => position && `position: ${position}`}; + ${({ top }) => top && `top: ${top * 0.4}rem`}; + ${({ right }) => right && `right: ${right * 0.4}rem`}; + ${({ bottom }) => bottom && `bottom: ${bottom * 0.4}rem`}; + ${({ left }) => left && `left: ${left * 0.4}rem`}; + + ${({ css }) => css}; +`; diff --git a/src/components/common/Box/Box.tsx b/src/components/common/Box/Box.tsx new file mode 100644 index 00000000..dedf81d0 --- /dev/null +++ b/src/components/common/Box/Box.tsx @@ -0,0 +1,55 @@ +import type { CSSProp } from 'styled-components'; + +import type { HTMLAttributes, ReactNode, ElementType } from 'react'; + +import type { SpacingProps } from '@common/styles/spacing'; + +import { StyledBox } from './Box.style'; + +type FourSides = 'left' | 'right' | 'top' | 'bottom'; + +export interface BoxProps extends HTMLAttributes, SpacingProps { + tag?: ElementType; + children?: ReactNode; + /** 테두리에 둥글고(border-radius: 4px) 얇은 선(0.1px, #66666666)이 생김 + * - 특정 방향(ex. 'left')을 넣으면 해당 부분만 얇은 선이 생김 + * @default false + */ + border?: boolean | FourSides; + /** border 색깔 변경 가능, **border가 false가 아닐 때 사용 가능** */ + borderColor?: string; + /** border 두께 변경 가능, **border가 false가 아닐 때 사용 가능** + *- [string] 단위까지 적어줘야 함 (ex. 2px, 1%) + *- [number] 숫자만 적을 경우 px로 자동 변환 + */ + borderWidth?: number | string; + /** border 곡률 변경 가능 + *- [string] 단위까지 적어줘야 함 (ex. 2px, 1%) + *- [number] 숫자만 적을 경우 px로 자동 변환 + */ + borderRadius?: number | string; + height?: number | string; + minHeight?: number | string; + maxHeight?: number | string; + width?: number | string; + minWidth?: number | string; + maxWidth?: number | string; + bgColor?: string; + color?: string; + position?: 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'; + top?: number; + right?: number; + bottom?: number; + left?: number; + css?: CSSProp; +} + +const Box = ({ tag = 'div', border = false, children, ...props }: BoxProps) => { + return ( + + {children} + + ); +}; + +export default Box; diff --git a/src/components/common/Box/index.ts b/src/components/common/Box/index.ts new file mode 100644 index 00000000..3451dc4c --- /dev/null +++ b/src/components/common/Box/index.ts @@ -0,0 +1,3 @@ +import Box from './Box'; + +export default Box; diff --git a/src/components/common/Button/Button.stories.tsx b/src/components/common/Button/Button.stories.tsx new file mode 100644 index 00000000..028a102e --- /dev/null +++ b/src/components/common/Button/Button.stories.tsx @@ -0,0 +1,177 @@ +import type { Meta } from '@storybook/react'; +import styled from 'styled-components'; + +import { BUTTON_PADDING_SIZE } from '@common/Button/Button.style'; + +import type { ButtonProps } from './Button'; +import Button from './Button'; + +const meta = { + title: 'Components/Button', + component: Button, + tags: ['autodocs'], + args: { + children: 'Button', + width: 'auto', + height: 'auto', + outlined: true, + shadow: false, + hover: true, + size: 'sm', + onClick: () => { + alert('click'); + }, + }, + argTypes: { + children: { + control: { + type: 'text', + }, + description: + '버튼 내용을 입력할 수 있습니다.
원하는 컴포넌트, 텍스트 등을 넣을 수 있습니다.', + }, + width: { + description: + '숫자를 입력하면 `입력한 숫자 x 10px` 만큼 버튼 너비가 늘어납니다.
단위를 포함해 문자(ex. "100%")로 입력하면 원하는 만큼 버튼 너비가 늘어납니다.', + }, + height: { + description: + '숫자를 입력하면 `입력한 숫자 x 10px` 버튼 높이가 늘어납니다.
단위를 포함해 문자(ex. "100%")로 입력하면 원하는 만큼 버튼 높이가 늘어납니다.', + }, + variant: { + options: { none: false, pill: 'pill' }, + control: { + type: 'select', + }, + description: '버튼 모양을 변경할 수 있습니다.', + }, + noRadius: { + options: { none: false, all: 'all', top: 'top', bottom: 'bottom' }, + control: { + type: 'select', + }, + description: '특정 방향의 radius 속성을 제거할 수 있습니다.', + }, + shadow: { + control: { + type: 'boolean', + }, + description: 'true: 버튼 주변으로 그림자가 생깁니다.', + }, + size: { + options: Object.keys({ ...BUTTON_PADDING_SIZE, none: undefined }), + control: { + type: 'select', + }, + description: '선택한 사이즈에 따라 버튼의 크기가 변합니다.', + }, + outlined: { + control: { + type: 'boolean', + }, + description: 'true: 버튼에 검은색 테두리가 생깁니다.', + }, + background: { + control: { + type: 'color', + }, + description: '선택한 색상에 따라 버튼의 배경색이 변합니다.', + }, + hover: { + description: '호버했을 때 버튼 배경색이 변합니다.', + }, + css: { + description: '원하는 css를 적용할 수 있습니다.', + }, + onClick: { + control: false, + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = (args: ButtonProps) => { + return + + + + + + + + + + + + + + ); +}; + +export const Styles = () => { + return ( + + + + + + + + + + ); +}; + +const Container = styled.div` + margin-top: 2rem; + + &:first-child { + margin-top: 0; + } + + & > button { + margin-right: 2rem; + } +`; diff --git a/src/components/common/Button/Button.style.ts b/src/components/common/Button/Button.style.ts new file mode 100644 index 00000000..d73e6b4a --- /dev/null +++ b/src/components/common/Button/Button.style.ts @@ -0,0 +1,76 @@ +import styled, { css } from 'styled-components'; + +import type { ButtonProps } from '@common/Button/Button'; +import { spacing } from '@common/styles/spacing'; +import { addUnit } from '@common/utils/addUnit'; + +import { borderRadius, pillStyle } from '@style'; + +import type { BorderRadiusDirectionType } from '@type'; + +export const BUTTON_PADDING_SIZE = { + xs: '0.3rem 0.8rem 0.4rem', + sm: '0.7rem 1.2rem 0.8rem', + md: '1.1rem 3.2rem 1.2rem', + lg: '1.7rem 4rem 1.8rem', + xl: '2.1rem 4.8rem 2.2rem', +} as const; + +export const BUTTON_FONT_SIZE = { + xs: '1.2rem', + sm: '1.4rem', + md: '1.5rem', + lg: '2rem', + xl: '2.2rem', +} as const; + +export type StyledButtonType = Omit & { + $noRadius: BorderRadiusDirectionType; +}; + +export const StyledButton = styled.button` + ${({ width }) => width !== undefined && `width: ${addUnit(width)}`}; + ${({ height }) => height !== undefined && `height: ${addUnit(height)}`}; + + padding: ${({ size }) => BUTTON_PADDING_SIZE[size] || 0}; + background: ${({ background }) => background || '#fff'}; + border: ${({ outlined }) => (outlined ? '0.15rem solid #000' : 'none')}; + ${({ size }) => `font-size: ${BUTTON_FONT_SIZE[size]}`}; + box-shadow: ${({ shadow }) => `${shadow ? '0 0.3rem 0.8rem 0 gray' : 'none'}`}; + + cursor: pointer; + border-radius: 1.2rem; + text-align: center; + + ${spacing} + ${({ $noRadius }) => $noRadius && borderRadius($noRadius)}; + ${({ variant }) => { + switch (variant) { + case 'pill': + return pillStyle; + case 'label': + return labelButtonStyle; + default: + return; + } + }}; + + &:hover { + ${({ hover }) => hover && 'font-weight: 500'}; + } + + ${({ css }) => css}; +`; + +const labelButtonStyle = css` + position: absolute; + top: 1.2rem; + right: -3.68rem; + height: 7.6rem; + padding: 0 0.6rem 0 0.4rem; + background: #fcfcfc; + border: 2.2px solid #e1e4eb; + border-left: 0.2px solid #e1e4eb; + + ${borderRadius('left')} +`; diff --git a/src/components/common/Button/Button.tsx b/src/components/common/Button/Button.tsx new file mode 100644 index 00000000..65898e39 --- /dev/null +++ b/src/components/common/Button/Button.tsx @@ -0,0 +1,36 @@ +import type { CSSProp } from 'styled-components'; + +import type { ButtonHTMLAttributes, MouseEventHandler } from 'react'; + +import type { SpacingProps } from '@common/styles/spacing'; + +import type { BorderRadiusDirectionType } from '@type/style'; + +import type { BUTTON_PADDING_SIZE } from './Button.style'; +import { StyledButton } from './Button.style'; + +export type VariantType = 'pill' | 'label'; + +export interface ButtonProps extends ButtonHTMLAttributes, SpacingProps { + variant?: VariantType; + width?: string | number; + height?: string | number; + noRadius?: BorderRadiusDirectionType; + shadow?: boolean; + size?: keyof typeof BUTTON_PADDING_SIZE; + outlined?: boolean; + background?: string; + hover?: boolean; + css?: CSSProp; + onClick?: MouseEventHandler; +} + +const Button = ({ children, noRadius, ...props }: ButtonProps) => { + return ( + + {children} + + ); +}; + +export default Button; diff --git a/src/components/common/Button/index.ts b/src/components/common/Button/index.ts new file mode 100644 index 00000000..803f51fb --- /dev/null +++ b/src/components/common/Button/index.ts @@ -0,0 +1,3 @@ +import Button from './Button'; + +export default Button; diff --git a/src/components/common/ButtonNext/ButtonNext.stories.tsx b/src/components/common/ButtonNext/ButtonNext.stories.tsx new file mode 100644 index 00000000..efd2bc84 --- /dev/null +++ b/src/components/common/ButtonNext/ButtonNext.stories.tsx @@ -0,0 +1,231 @@ +import { useState } from 'react'; + +import Box from '@common/Box'; +import ButtonNext from '@common/ButtonNext'; +import type { ButtonNextProps } from '@common/ButtonNext/ButtonNext'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +const meta = { + title: 'Components/ButtonNext', + component: ButtonNext, + tags: ['autodocs'], + args: { + children: 'Button', + }, + argTypes: { + children: { + control: { + type: 'text', + }, + description: + '버튼 내용을 입력할 수 있습니다.
원하는 컴포넌트, 텍스트 등을 넣을 수 있습니다.', + }, + noTheme: { + description: '테마를 끌 수 있습니다.', + }, + variant: { + description: '버튼의 형태를 정할 수 있습니다. 가득참, 빈, 텍스트 모드로 전환할 수 있습니다.', + }, + size: { + description: '사이즈를 정할 수 있습니다.', + }, + disabled: { + description: '버튼을 비활성화 할 수 있습니다.', + }, + fullWidth: { + description: '버튼을 가득 채울 수 있습니다.', + }, + pill: { + description: '버튼을 알약 모양으로 만들 수 있습니다.', + }, + css: { + description: '버튼에 CSS를 부여할 수 있습니다.', + }, + }, +}; +export default meta; + +export const Default = (args: ButtonNextProps) => { + return Button; +}; + +export const Variant = () => { + return ( + <> + Text + Outlined + Contained + + ); +}; + +export const Clickable = () => { + const [isSelected, setIsSelected] = useState(false); + return ( + <> + Click Me! + setIsSelected(!isSelected)} + > + {isSelected ? 'OFF' : 'ON'} + + + ); +}; + +export const Pill = () => { + return ( + <> + pill + no pill + + ); +}; + +export const Size = () => { + return ( + <> + sm + sm + md + lg + xl + xxl + + ); +}; + +export const Colors = () => { + return ( + <> + + + primary + + + primary + + + primary + + + + + secondary + + + secondary + + + secondary + + + + + info + + + info + + + info + + + + + success + + + success + + + success + + + + + warning + + + warning + + + warning + + + + + error + + + error + + + error + + + + + dark + + + dark + + + dark + + + + + light + + + light + + + light + + + None + + ); +}; + +export const Disabled = () => { + return 사용할 수 없는 버튼; +}; + +export const NoTheme = () => { + return 테마가 모두 사라져버린 버튼; +}; + +export const FullWidth = () => { + return 길쭉이; +}; + +export const FullWidthExample = () => { + return ( + <> + + with fullWidth + + + 취소 + + + 제출 + + + + + without fullWidth + + 취소 + 제출 + + + + ); +}; diff --git a/src/components/common/ButtonNext/ButtonNext.style.ts b/src/components/common/ButtonNext/ButtonNext.style.ts new file mode 100644 index 00000000..b73a3d59 --- /dev/null +++ b/src/components/common/ButtonNext/ButtonNext.style.ts @@ -0,0 +1,96 @@ +import styled, { css } from 'styled-components'; + +import type { ButtonNextProps } from '@common/ButtonNext/ButtonNext'; +import { spacing } from '@common/styles/spacing'; + +import { getColor, getHoverColor } from '@style'; + +export const StyledButtonNext = styled.button` + border-radius: 6px; + ${({ pill }) => pill && 'border-radius: 20px;'} + + ${({ fullWidth }) => fullWidth && 'width: 100%;'} + ${({ disabled }) => disabled && `cursor: unset;`} + ${({ variant, color, disabled }) => { + switch (variant) { + case 'text': + return css` + color: ${disabled ? '#a0a0a0' : color === 'light' ? '#000' : getColor(color)}; + background: transparent; + border: none; + + &:hover { + background: ${disabled ? 'transparent' : '#1976d20a'}; + } + `; + case 'outlined': + return css` + color: ${disabled ? '#a0a0a0' : color === 'light' ? '#333' : getColor(color)}; + background: transparent; + border: 1.5px solid ${disabled ? '#a0a0a0' : getColor(color)}; + + &:hover { + color: ${disabled ? '#a0a0a0' : color === 'light' ? '#333' : '#fff'}; + background: ${disabled ? 'transparent' : getHoverColor(color)}; + } + `; + case 'contained': + default: + return css` + color: ${disabled ? '#a0a0a0' : color === 'light' ? '#000' : '#ffffff'}; + background: ${disabled ? '#e0e0e0' : getColor(color)}; + border: 1.5px solid ${disabled ? '#e0e0e0' : getColor(color)}; + + &:hover { + background: ${disabled ? '#e0e0e0' : getHoverColor(color)}; + } + `; + } + }} + + padding: ${({ size }) => { + switch (size) { + case 'xs': + return '2px 8px'; + case 'sm': + return '4px 12px'; + case 'md': + return '6px 16px'; + case 'lg': + return '8px 20px'; + case 'xl': + return '10px 24px'; + case 'xxl': + return '12px 28px'; + default: + return '6px 16px'; + } + }}; + + font-size: ${({ size }) => { + switch (size) { + case 'xs': + return '14px'; + case 'sm': + return '16px'; + case 'md': + return '18px'; + case 'lg': + return '20px'; + case 'xl': + return '22px'; + case 'xxl': + return '24px'; + default: + return '18px'; + } + }}; + + ${spacing}; + + ${({ css }) => css}; +`; + +export const StyledPureButton = styled.button` + ${({ css }) => css}; +`; diff --git a/src/components/common/ButtonNext/ButtonNext.tsx b/src/components/common/ButtonNext/ButtonNext.tsx new file mode 100644 index 00000000..53963436 --- /dev/null +++ b/src/components/common/ButtonNext/ButtonNext.tsx @@ -0,0 +1,33 @@ +import type { CSSProp } from 'styled-components'; + +import type { ButtonHTMLAttributes, MouseEventHandler, ReactNode } from 'react'; +import { forwardRef } from 'react'; + +import type { SpacingProps } from '@common/styles/spacing'; + +import type { Color, Size } from '@type/style'; + +import { StyledButtonNext, StyledPureButton } from './ButtonNext.style'; + +export interface ButtonNextProps extends SpacingProps, ButtonHTMLAttributes { + noTheme?: boolean; + variant?: 'text' | 'outlined' | 'contained'; + size?: Size; + children?: ReactNode; + color?: Color; + disabled?: boolean; + fullWidth?: boolean; + pill?: boolean; + onClick?: MouseEventHandler; + css?: CSSProp; +} + +const ButtonNext = ({ children, noTheme, ...props }: ButtonNextProps) => { + return noTheme ? ( + {children} + ) : ( + {children} + ); +}; + +export default forwardRef(ButtonNext); diff --git a/src/components/common/ButtonNext/index.ts b/src/components/common/ButtonNext/index.ts new file mode 100644 index 00000000..271ba7e1 --- /dev/null +++ b/src/components/common/ButtonNext/index.ts @@ -0,0 +1,3 @@ +import ButtonNext from './ButtonNext'; + +export default ButtonNext; diff --git a/src/components/common/FlexBox/FlexBox.stories.tsx b/src/components/common/FlexBox/FlexBox.stories.tsx new file mode 100644 index 00000000..f46e62be --- /dev/null +++ b/src/components/common/FlexBox/FlexBox.stories.tsx @@ -0,0 +1,221 @@ +import type { Meta } from '@storybook/react'; +import styled from 'styled-components'; + +import { FLEX_BOX_ITEM_POSITION } from '@common/FlexBox/FlexBox.style'; + +import type { FlexBoxProps } from './FlexBox'; +import FlexBox from './FlexBox'; + +const Box = styled.div` + width: 30%; + padding: 1rem; + border: 0.15rem solid #000; + border-radius: 0.8rem; +`; + +const Boxes = (tag?: 'li') => { + return ( + <> + 1 박스 + 2 박스 + 3 박스 + 1 박스 + 2 박스 + 3 박스 + + ); +}; + +const meta = { + title: 'Components/FlexBox', + component: FlexBox, + tags: ['autodocs'], + args: { + tag: 'ul', + width: '100%', + height: 24, + justifyContent: 'center', + alignContent: 'center', + outlined: true, + direction: 'row', + nowrap: false, + children: Boxes('li'), + }, + argTypes: { + tag: { + description: '태그명(ex. ul, section)을 입력해 플렉스 컨테이너의 태그를 바꿀 수 있습니다.', + }, + width: { + description: + '숫자를 입력하면 `입력한 숫자 x 10px` 만큼 플렉스 박스 너비가 늘어납니다.
단위를 포함해 문자(ex. "100%")로 입력하면 원하는 만큼 플렉스 박스 너비가 늘어납니다.', + }, + height: { + description: + '숫자를 입력하면 `입력한 숫자 x 10px` 플렉스 박스 높이가 늘어납니다.
단위를 포함해 문자(ex. "100%")로 입력하면 원하는 만큼 플렉스 박스 높이가 늘어납니다.', + }, + justifyContent: { + options: Object.keys({ ...FLEX_BOX_ITEM_POSITION, none: undefined }), + control: { + type: 'select', + }, + description: + '플렉스 컨테이너 안에 있는 아이템의 위치를 조절할 수 있습니다.
- direction이 row일 경우: 수평을 기준으로 아이템이 이동합니다.
- direction이 column일 경우: 수직을 기준으로 아이템이 이동합니다.', + }, + alignContent: { + options: Object.keys({ ...FLEX_BOX_ITEM_POSITION, none: undefined }), + control: { + type: 'select', + }, + description: + '플렉스 컨테이너 안에 있는 아이템의 위치를 조절할 수 있습니다.
- direction이 row일 경우: 수직을 기준으로 아이템이 이동합니다.
- direction이 column일 경우: 수평을 기준으로 아이템이 이동합니다.
❗`wrap`(noWrap이 false)일 때만 사용 가능합니다.', + }, + alignItems: { + options: Object.keys({ ...FLEX_BOX_ITEM_POSITION, none: undefined }), + control: { + type: 'select', + }, + description: + '플렉스 컨테이너 안에 있는 아이템의 위치를 조절할 수 있습니다.
- direction이 row일 경우: 수직을 기준으로 아이템이 이동합니다.
- direction이 column일 경우: 수평을 기준으로 아이템이 이동합니다.', + }, + noRadius: { + options: { none: false, all: 'all', top: 'top', bottom: 'bottom' }, + control: { + type: 'select', + }, + description: '특정 방향의 radius 속성을 제거할 수 있습니다.', + }, + outlined: { + control: { + type: 'boolean', + }, + description: 'true: 플렉스 컨테이너에 검은색 테두리가 생깁니다.', + }, + background: { + control: { + type: 'color', + }, + description: '선택한 색상에 따라 플렉스 컨테이너의 배경색이 변합니다.', + }, + direction: { + description: `row: 플렉스 컨테이너 안 아이템이 수직 방향으로 정렬됩니다. 기본값입니다.
column: 플렉스 컨테이너 안 아이템이 수직 방향으로 정렬됩니다.`, + }, + nowrap: { + description: + 'true: 플렉스 컨테이너, 아이템 크기에 상관없이 무조건 한 줄로 정렬합니다. 아이템의 width, height가 정해져 있지 않다면 플렉스 컨테이너의 크기만큼 늘어납니다.', + }, + gap: { + description: + '숫자를 입력해 플렉스 컨테이너 안 아이템 간의 간격을 조절할 수 있습니다. `입력한 숫자 x 4px` 만큼 간격이 늘어납니다.', + }, + rowGap: { + description: + '숫자를 입력해 플렉스 컨테이너 안 아이템 간의 행 높이를 조절할 수 있습니다. `입력한 숫자 x 4px` 만큼 간격이 늘어납니다.
❗gap이 적용되어 있으면(undefined가 아니면) row gap은 적용되지 않습니다.', + }, + columnGap: { + description: + '숫자를 입력해 플렉스 컨테이너 안 아이템 간의 열 너비를 조절할 수 있습니다. `입력한 숫자 x 4px` 만큼 간격이 늘어납니다.
❗gap이 적용되어 있으면(undefined가 아니면) column gap은 적용되지 않습니다.', + }, + children: { + table: { + disable: true, + }, + }, + css: { + description: '원하는 css를 적용할 수 있습니다.', + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = (args: FlexBoxProps) => { + return ; +}; + +export const JustifyContent = () => { + return ( + + + + {Boxes()} + + + {Boxes()} + + + {Boxes()} + + + {Boxes()} + + + + {Boxes()} + + + {Boxes()} + + + {Boxes()} + + + {Boxes()} + + + ); +}; + +export const AlignItems = () => { + return ( + + + + {Boxes()} + + + {Boxes()} + + + {Boxes()} + + + {Boxes()} + + + + {Boxes()} + + + {Boxes()} + + + {Boxes()} + + + {Boxes()} + + + ); +}; + +export const Gap = () => { + return ( + + + {Boxes()} + + + ); +}; + +const Container = styled.div` + margin-top: 20px; + + &:first-child { + margin-top: 0; + } + + & > button { + margin-right: 20px; + } +`; diff --git a/src/components/common/FlexBox/FlexBox.style.ts b/src/components/common/FlexBox/FlexBox.style.ts new file mode 100644 index 00000000..80722ec3 --- /dev/null +++ b/src/components/common/FlexBox/FlexBox.style.ts @@ -0,0 +1,82 @@ +import styled from 'styled-components'; + +import type { FlexBoxProps } from '@common/FlexBox/FlexBox'; +import { spacing } from '@common/styles/spacing'; + +import { borderRadius, getSize } from '@style'; + +import type { BorderRadiusDirectionType } from '@type'; + +export const FLEX_BOX_ITEM_POSITION = { + start: 'start', + center: 'center', + end: 'end', + between: 'space-between', +} as const; + +export type StyledFlexBoxType = Omit< + FlexBoxProps, + | 'noRadius' + | 'rowGap' + | 'columnGap' + | 'justifyContent' + | 'alignItems' + | 'alignContent' + | 'minHeight' + | 'maxHeight' + | 'minWidth' + | 'maxWidth' +> & { + $noRadius?: BorderRadiusDirectionType; + $rowGap?: number; + $columnGap?: number; + $justifyContent?: keyof typeof FLEX_BOX_ITEM_POSITION; + $alignItems?: keyof typeof FLEX_BOX_ITEM_POSITION; + $alignContent?: keyof typeof FLEX_BOX_ITEM_POSITION; + $minHeight?: number | string; + $maxHeight?: number | string; + $minWidth?: number | string; + $maxWidth?: number | string; +}; + +const getGap = ({ gap, rowGap, columnGap }: Pick) => { + if (gap !== undefined) { + return `${gap * 0.4}rem`; + } + + const row = rowGap !== undefined ? rowGap * 0.4 : 0.4; + const column = columnGap !== undefined ? columnGap * 0.4 : 0.4; + + return `${row}rem ${column}rem`; +}; + +export const StyledFlexBox = styled.div` + ${spacing}; + + width: ${({ width }) => getSize(width)}; + min-width: ${({ $minWidth }) => (typeof $minWidth === 'string' ? $minWidth : `${$minWidth}rem`)}; + max-width: ${({ $maxWidth }) => (typeof $maxWidth === 'string' ? $maxWidth : `${$maxWidth}rem`)}; + + height: ${({ height }) => getSize(height)}; + min-height: ${({ $minHeight }) => + typeof $minHeight === 'string' ? $minHeight : `${$minHeight}rem`}; + max-height: ${({ $maxHeight }) => + typeof $maxHeight === 'string' ? $maxHeight : `${$maxHeight}rem`}; + + flex-wrap: ${({ nowrap }) => (nowrap ? 'nowrap' : 'wrap')}; + flex-direction: ${({ direction }) => (direction ? direction : 'row')}; + justify-content: ${({ $justifyContent }) => FLEX_BOX_ITEM_POSITION[$justifyContent]}; + align-items: ${({ $alignItems }) => FLEX_BOX_ITEM_POSITION[$alignItems]}; + align-content: ${({ $alignContent }) => FLEX_BOX_ITEM_POSITION[$alignContent]}; + gap: ${({ gap, $rowGap, $columnGap }) => getGap({ gap, rowGap: $rowGap, columnGap: $columnGap })}; + ${({ background }) => background && `background: ${background};`} + border: ${({ outlined }) => (outlined ? '0.15rem solid #000' : 'none')}; + + display: flex; + border-radius: 1rem; + font-size: 1.5rem; + + ${({ $noRadius }) => $noRadius && borderRadius($noRadius)}; + + ${({ css }) => css}; +`; diff --git a/src/components/common/FlexBox/FlexBox.tsx b/src/components/common/FlexBox/FlexBox.tsx new file mode 100644 index 00000000..6e0a2114 --- /dev/null +++ b/src/components/common/FlexBox/FlexBox.tsx @@ -0,0 +1,72 @@ +import type { CSSProp } from 'styled-components'; + +import type { ReactNode, ComponentPropsWithoutRef, ElementType } from 'react'; + +import type { SpacingProps } from '@common/styles/spacing'; + +import type { AxisType, BorderRadiusDirectionType } from '@type/style'; + +import type { FLEX_BOX_ITEM_POSITION } from './FlexBox.style'; +import { StyledFlexBox } from './FlexBox.style'; + +export interface FlexBoxProps extends ComponentPropsWithoutRef, SpacingProps { + tag?: string; + height?: number | string; + minHeight?: number | string; + maxHeight?: number | string; + width?: number | string; + minWidth?: number | string; + maxWidth?: number | string; + justifyContent?: keyof typeof FLEX_BOX_ITEM_POSITION; + alignItems?: keyof typeof FLEX_BOX_ITEM_POSITION; + alignContent?: keyof typeof FLEX_BOX_ITEM_POSITION; + noRadius?: BorderRadiusDirectionType; + outlined?: boolean; + background?: string; + direction?: AxisType; + nowrap?: boolean; + gap?: number; + rowGap?: number; + columnGap?: number; + css?: CSSProp; + children: ReactNode; +} + +const FlexBox = ({ + children, + tag, + noRadius, + rowGap, + columnGap, + justifyContent, + alignItems, + alignContent, + minHeight, + maxHeight, + minWidth, + maxWidth, + ...props +}: FlexBoxProps) => { + const changeableTag = tag || 'div'; + + return ( + + {children} + + ); +}; + +export default FlexBox; diff --git a/src/components/common/FlexBox/index.ts b/src/components/common/FlexBox/index.ts new file mode 100644 index 00000000..21e3a037 --- /dev/null +++ b/src/components/common/FlexBox/index.ts @@ -0,0 +1,3 @@ +import FlexBox from './FlexBox'; + +export default FlexBox; diff --git a/src/components/common/List/List.stories.tsx b/src/components/common/List/List.stories.tsx new file mode 100644 index 00000000..49ca1a72 --- /dev/null +++ b/src/components/common/List/List.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta } from '@storybook/react'; + +import ListItem from '../ListItem'; +import Text from '../Text'; +import type { ListProps } from './List'; +import List from './List'; + +const meta = { + title: 'Components/List', + component: List, + tags: ['autodocs'], + args: { + children: 'List', + p: 0, + border: false, + }, + argTypes: { + children: { + control: { + type: 'text', + }, + description: '리스트의 하위 요소는 `` 컴포넌트가 와야합니다.', + }, + p: { + control: { + type: 'number', + }, + description: 'padding을 줄 수 있습니다. 숫자 * 0.4rem 만큼 적용됩니다.', + }, + border: { + control: { + type: 'boolean', + }, + description: 'border를 기본적으로 줄 수 있습니다.', + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = (args: ListProps) => { + return ; +}; + +export const ListWithoutPadding = () => { + return ( + + + 패딩이 없는 + + + 리스트라니 + + + ); +}; + +export const ListWithPadding = () => { + return ( + + + 패딩이 있는 + + + 리스트라니 + + + ); +}; + +export const ListWithBorder = () => { + return ( + + + 패딩이 있는 + + + 리스트라니 + + + ); +}; diff --git a/src/components/common/List/List.style.ts b/src/components/common/List/List.style.ts new file mode 100644 index 00000000..0414c9eb --- /dev/null +++ b/src/components/common/List/List.style.ts @@ -0,0 +1,18 @@ +import styled from 'styled-components'; + +import { spacing } from '@common/styles/spacing'; + +import type { ListProps } from './List'; + +export type StyledListType = Omit & { $fontSize: number }; + +export const StyledList = styled.ul` + ${spacing}; + + list-style-type: none; + font-size: ${({ $fontSize }) => `${$fontSize}rem`}; + + ${({ border }) => border && `border: 0.01rem solid #66666666; border-radius:0.4rem;`} + + ${({ css }) => css}; +`; diff --git a/src/components/common/List/List.tsx b/src/components/common/List/List.tsx new file mode 100644 index 00000000..b5004998 --- /dev/null +++ b/src/components/common/List/List.tsx @@ -0,0 +1,23 @@ +import type { CSSProp } from 'styled-components'; + +import type { HTMLAttributes, ReactNode } from 'react'; + +import { StyledList } from '@common/List/List.style'; +import type { SpacingProps } from '@common/styles/spacing'; + +export interface ListProps extends HTMLAttributes, SpacingProps { + children: ReactNode; + border?: boolean; + fontSize?: number; + css?: CSSProp; +} + +const List = ({ children, fontSize, ...props }: ListProps) => { + return ( + + {children} + + ); +}; + +export default List; diff --git a/src/components/common/List/index.ts b/src/components/common/List/index.ts new file mode 100644 index 00000000..1e2ddb43 --- /dev/null +++ b/src/components/common/List/index.ts @@ -0,0 +1,3 @@ +import List from './List'; + +export default List; diff --git a/src/components/common/ListItem/ListItem.stories.tsx b/src/components/common/ListItem/ListItem.stories.tsx new file mode 100644 index 00000000..d2aea42b --- /dev/null +++ b/src/components/common/ListItem/ListItem.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta } from '@storybook/react'; + +import List from '../List'; +import Text from '../Text'; +import type { ListItemProps } from './ListItem'; +import ListItem from './index'; + +const meta = { + title: 'Components/ListItem', + component: ListItem, + tags: ['autodocs'], + args: { + children: 'ListItem', + divider: false, + NoLastDivider: false, + }, + argTypes: { + children: { + control: { + type: 'text', + }, + description: '이 컴포넌트는 반드시 ``로 감싸야 합니다.', + }, + divider: { + description: 'true: 하단에 밑줄을 그을 수 있습니다.', + }, + NoLastDivider: { + description: 'true: 마지막 리스트 아이템의 하단 밑줄을 제거할 수 있습니다.', + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = (args: ListItemProps) => { + return ( + + + + + ); +}; + +export const Menu = () => { + return ( +
+ + + 메뉴1 + + + 메뉴2 + + + 메뉴3 + + + 로그아웃 + + +
+ ); +}; diff --git a/src/components/common/ListItem/ListItem.style.ts b/src/components/common/ListItem/ListItem.style.ts new file mode 100644 index 00000000..17db49d1 --- /dev/null +++ b/src/components/common/ListItem/ListItem.style.ts @@ -0,0 +1,17 @@ +import styled from 'styled-components'; + +import type { ListItemProps } from '@common/ListItem/ListItem'; +import { spacing } from '@common/styles/spacing'; + +export const StyledListItem = styled.li` + padding: 1rem 2rem; + + ${spacing}; + + ${({ divider }) => divider && `border-bottom: 0.0625rem solid #ccc;`} + &:last-child { + border-bottom: ${({ NoLastDivider }) => NoLastDivider && 0}; + } + + ${({ css }) => css}; +`; diff --git a/src/components/common/ListItem/ListItem.tsx b/src/components/common/ListItem/ListItem.tsx new file mode 100644 index 00000000..89c8309a --- /dev/null +++ b/src/components/common/ListItem/ListItem.tsx @@ -0,0 +1,27 @@ +import type { CSSProp } from 'styled-components'; + +import type { HTMLAttributes, ReactNode } from 'react'; + +import type { SpacingProps } from '@common/styles/spacing'; + +import { StyledListItem } from './ListItem.style'; + +export interface ListItemProps extends HTMLAttributes, SpacingProps { + children: ReactNode; + divider?: boolean; + NoLastDivider?: boolean; + css?: CSSProp; + tag?: string; +} + +const ListItem = ({ children, tag, ...props }: ListItemProps) => { + const changeableTag = tag || 'li'; + + return ( + + {children} + + ); +}; + +export default ListItem; diff --git a/src/components/common/ListItem/index.ts b/src/components/common/ListItem/index.ts new file mode 100644 index 00000000..038fb1ed --- /dev/null +++ b/src/components/common/ListItem/index.ts @@ -0,0 +1,3 @@ +import ListItem from './ListItem'; + +export default ListItem; diff --git a/src/components/common/Loader/Loader.stories.tsx b/src/components/common/Loader/Loader.stories.tsx new file mode 100644 index 00000000..82423361 --- /dev/null +++ b/src/components/common/Loader/Loader.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta } from '@storybook/react'; + +import type { LoaderProps } from './Loader'; +import Loader from './index'; + +const meta = { + title: 'Components/Loader', + component: Loader, + tags: ['autodocs'], + args: { + size: 'xs', + }, + argTypes: { + size: { + options: { none: false, xs: 'xs', sm: 'sm', md: 'md', lg: 'lg', xl: 'xl', xxl: 'xxl' }, + control: { + type: 'select', + }, + description: + '사이즈를 부여할 수 있습니다. Size Props이외에도 필요에 따라 수동으로도 제어할 수 있습니다.', + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = (args: LoaderProps) => { + return ; +}; +export const Sizes = () => { + return ( + <> + + + + + + + + + + ); +}; diff --git a/src/components/common/Loader/Loader.style.ts b/src/components/common/Loader/Loader.style.ts new file mode 100644 index 00000000..2ac3f269 --- /dev/null +++ b/src/components/common/Loader/Loader.style.ts @@ -0,0 +1,59 @@ +import styled from 'styled-components'; + +import type { LoaderProps } from '@common/Loader/Loader'; + +export const StyledLoader = styled.div` + width: ${({ size }) => { + switch (size) { + case 'xs': + return '1.2rem'; + case 'sm': + return '1.6rem'; + case 'md': + return '2.0rem'; + case 'lg': + return '2.4rem'; + case 'xl': + return '2.8rem'; + case 'xxl': + return '3.2rem'; + default: + return size || '2.0rem'; + } + }}; + height: ${({ size }) => { + switch (size) { + case 'xs': + return '1.2rem'; + case 'sm': + return '1.6rem'; + case 'md': + return '2.0rem'; + case 'lg': + return '2.4rem'; + case 'xl': + return '2.8rem'; + case 'xxl': + return '3.2rem'; + default: + return size || '2.0rem'; + } + }}; + border: ${({ border }) => (border ? `${border}px` : '2px')} solid #e9ecef; + border-bottom-color: #212529bf; + border-radius: 50%; + display: inline-block; + box-sizing: border-box; + animation: rotation 1s linear infinite; + + @keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + + ${({ css }) => css} +`; diff --git a/src/components/common/Loader/Loader.tsx b/src/components/common/Loader/Loader.tsx new file mode 100644 index 00000000..641def36 --- /dev/null +++ b/src/components/common/Loader/Loader.tsx @@ -0,0 +1,17 @@ +import type { CSSProp } from 'styled-components'; + +import type { Size } from '@type'; + +import { StyledLoader } from './Loader.style'; + +export interface LoaderProps { + size?: Size | string; + border?: number; + css?: CSSProp; +} + +const Loader = ({ size, border, css }: LoaderProps) => { + return ; +}; + +export default Loader; diff --git a/src/components/common/Loader/index.ts b/src/components/common/Loader/index.ts new file mode 100644 index 00000000..45ded85b --- /dev/null +++ b/src/components/common/Loader/index.ts @@ -0,0 +1,3 @@ +import Loader from './Loader'; + +export default Loader; diff --git a/src/components/common/Modal/Modal.stories.tsx b/src/components/common/Modal/Modal.stories.tsx new file mode 100644 index 00000000..319e9f67 --- /dev/null +++ b/src/components/common/Modal/Modal.stories.tsx @@ -0,0 +1,202 @@ +import type { Meta } from '@storybook/react'; + +import { useState } from 'react'; + +import Button from '../Button'; +import Text from '../Text'; +import Modal from './Modal'; + +const meta = { + title: 'Components/Modal', + component: Modal, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +export const Default = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + return ( + <> + + setIsModalOpen(false)}> + 버튼으로 모달을 열어봤어요 + + + ); +}; + +export const StaticBackdropModal = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + return ( + <> + + setIsModalOpen(false)} staticBackdrop> + 백 드롭을 눌러도 꺼지지 않아요. + + + ); +}; + +export const LongModal = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + return ( + <> + + setIsModalOpen(false)}> + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc euismod ultrices elit vitae + pharetra. Vestibulum volutpat molestie viverra. Pellentesque libero mauris, tristique et + tortor luctus, volutpat aliquet urna. Nunc ut urna enim. Morbi maximus, mi ac sollicitudin + fermentum, massa nunc dapibus est, in pellentesque lacus est in massa. Curabitur et + elementum metus, non hendrerit est. Curabitur venenatis id leo in dapibus. Duis in ipsum + quis enim aliquam ornare. Cras in elementum augue. Donec tincidunt nisi nec neque maximus + eleifend. Donec finibus vitae magna ut volutpat. Mauris vitae finibus eros, nec + sollicitudin mi. Cras vitae nisi vel mi consequat sagittis. Nulla eget arcu vel velit + sodales venenatis in eu arcu. Duis et cursus nisl, ac fermentum enim. Ut posuere bibendum + ligula eu sagittis. Curabitur imperdiet rhoncus leo, nec faucibus eros tempus vel. + Pellentesque ut eros in mi porta ultricies. Mauris viverra dolor sit amet enim elementum, + eu consectetur dui interdum. Donec in malesuada nisi, vitae congue lorem. Sed lacinia ante + arcu, quis hendrerit mauris condimentum ac. Pellentesque non dapibus justo, sit amet + sollicitudin nibh. Quisque vitae lorem sed lorem rutrum elementum sed sed tortor. Proin + fermentum tellus sed iaculis aliquam. Suspendisse in quam non dui varius dictum sit amet + et nisi. In facilisis neque arcu, a mattis felis aliquam non. Sed id nisl non tortor + placerat interdum et sit amet ipsum. Aliquam volutpat sed nisl sit amet blandit. Ut sit + amet lacus nibh. Aliquam tristique, tortor a bibendum mollis, nunc magna dictum purus, + venenatis mattis orci quam in lorem. Fusce suscipit pretium nunc, id egestas magna dapibus + sed. Nam quis felis quis mauris condimentum consectetur tristique id lacus. Aliquam sed + dui vel nulla consequat iaculis. Praesent feugiat quam in accumsan laoreet. Aliquam cursus + arcu neque, porta fringilla urna suscipit eleifend. Ut rutrum erat eu mauris eleifend + ultricies vitae ac tortor. Suspendisse consequat aliquet mi imperdiet gravida. Vivamus + iaculis urna mauris, ac efficitur ligula aliquet suscipit. Mauris accumsan laoreet nisi, + eu suscipit mauris interdum imperdiet. Donec viverra libero sit amet leo mollis, eget + pulvinar ligula lobortis. Nullam nec viverra lorem. Duis faucibus odio enim, vel finibus + neque sollicitudin a. Phasellus venenatis viverra ex, ac tempor ante. In egestas erat vel + nisl dictum ornare. Aliquam ultricies purus turpis, a faucibus quam feugiat vel. Duis at + elementum sem. Proin condimentum, quam eu consequat posuere, leo nibh commodo eros, at + suscipit diam diam non urna. Ut mollis ultricies diam, a tempus nisl mollis at. + Suspendisse potenti. Nulla blandit rhoncus consectetur. Nam viverra velit ut aliquam + iaculis. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus + mus. Donec tincidunt nunc erat, ac iaculis metus varius a. Duis feugiat semper blandit. + Curabitur lobortis dignissim fermentum. Nunc nec nisl dui. Praesent tincidunt elit non + hendrerit accumsan. Pellentesque habitant morbi tristique senectus et netus et malesuada + fames ac turpis egestas. Nullam quis enim sed lorem tempus interdum. Nunc rhoncus lacinia + felis non scelerisque. Pellentesque iaculis fringilla lorem, et mollis dui sodales quis. + Vivamus ornare sapien eu ullamcorper porttitor. Duis hendrerit nibh odio, bibendum + placerat ipsum commodo tempor. Nam turpis tortor, sollicitudin in pretium a, consequat a + magna. Nam bibendum lacinia nulla id vestibulum. Curabitur id enim leo. Maecenas + consectetur, lorem eu iaculis dapibus, ex diam lobortis risus, eget lobortis dolor ante + rhoncus ex. Nullam quis nibh sed nunc gravida volutpat a et ligula. Sed sit amet venenatis + purus. Nullam pulvinar leo sit amet augue fermentum fermentum. Ut vel interdum mi. Duis + sapien nisl, consequat ut nisi eu, dignissim auctor odio. Nullam elementum lacus vitae + scelerisque fringilla. Morbi ultricies pellentesque vulputate. Sed laoreet lacus non elit + condimentum, eget tristique dui imperdiet. Ut dictum ex eu convallis aliquam. Nulla + tristique, nibh a gravida congue, nisi ligula faucibus urna, ut mollis dui lectus quis + massa. Fusce vel tellus sit amet lacus molestie egestas vel nec metus. Nullam facilisis + euismod sollicitudin. Ut vel vulputate nisi. Aliquam sed convallis arcu. Duis eros velit, + molestie vel turpis pellentesque, sollicitudin egestas enim. Donec faucibus finibus + egestas. Curabitur molestie velit vel viverra semper. Maecenas non lacus varius, semper + augue sed, fringilla lorem. Vestibulum mattis egestas porta. Nullam at leo molestie, + placerat magna blandit, sollicitudin eros. Aliquam lorem nisl, suscipit nec nisi vitae, + ultrices pretium est. Pellentesque non est ultricies, tincidunt orci ac, sagittis arcu. + Maecenas eget odio ut velit interdum tristique eu quis libero. Suspendisse efficitur diam + sem, lacinia mattis magna auctor tincidunt. Morbi rhoncus nec elit ac molestie. Vestibulum + facilisis arcu quis accumsan aliquet. Cras ullamcorper laoreet tempor. Nam ipsum leo, + eleifend eget lectus nec, fermentum facilisis lacus. Ut egestas neque ac nulla ultricies + semper. Cras condimentum, nisi sed euismod commodo, justo orci iaculis metus, vitae + porttitor ipsum turpis at tortor. Integer fermentum vestibulum mauris quis venenatis. + Integer in sollicitudin est. Vivamus nec augue vel ante sagittis mattis. Duis nulla nibh, + imperdiet in porttitor vitae, hendrerit aliquet sem. Proin blandit vehicula tellus ac + cursus. Proin nec euismod justo. Ut nec euismod nunc. Quisque fermentum tristique lorem, + sit amet lacinia elit tempor eget. Proin dignissim purus eget odio elementum facilisis. + Mauris aliquam, dolor at tincidunt volutpat, felis velit sollicitudin nunc, a porttitor + ligula dui tempor ex. Donec at turpis vitae elit aliquam facilisis convallis in neque. + Fusce volutpat eget lacus vulputate feugiat. Mauris vel turpis hendrerit lorem lobortis + dictum ultrices dapibus orci. Suspendisse euismod vehicula volutpat. Nulla malesuada + faucibus felis ac finibus. Class aptent taciti sociosqu ad litora torquent per conubia + nostra, per inceptos himenaeos. Integer fermentum a elit quis interdum. Maecenas at neque + ultrices, dapibus sem sed, faucibus urna. Sed sagittis nulla sed ultrices consequat. Nunc + auctor, purus ultricies condimentum pretium, ante tortor auctor massa, id ornare tortor + diam quis lacus. Cras dictum orci a arcu dignissim pulvinar. Donec ac lectus ac velit + volutpat lobortis. Curabitur et laoreet risus. Mauris efficitur volutpat blandit. + Phasellus pharetra ac mi ultrices vehicula. Nullam cursus neque sed est ultricies, vel + iaculis metus pellentesque. Curabitur sit amet risus convallis nisi faucibus pulvinar eget + sit amet nunc. Pellentesque habitant morbi tristique senectus et netus et malesuada fames + ac turpis egestas. Aliquam sit amet euismod massa, non cursus odio. Vestibulum arcu nulla, + accumsan vel ligula vitae, tempus suscipit nulla. Nullam at bibendum dui, interdum + ultricies tellus. Nullam vel lacus et sapien sodales lacinia. In posuere enim nec egestas + imperdiet. Maecenas in egestas purus. Ut hendrerit suscipit eros sed ornare. Nullam a mi + et justo dictum aliquam. Quisque a laoreet massa. Pellentesque laoreet risus ut augue + pulvinar faucibus. Maecenas eu dignissim arcu, quis egestas magna. Sed volutpat est leo, + eu sollicitudin metus aliquam et. In purus purus, vulputate quis magna eu, sodales maximus + nisl. Phasellus fringilla ut lorem volutpat sodales. Nam fermentum auctor dolor sed + blandit. Praesent consectetur ligula in massa dictum, quis euismod nisi mattis. Quisque + tincidunt mollis tempus. Ut vehicula leo diam, nec sollicitudin nunc maximus at. Nunc + turpis sapien, faucibus vitae posuere in, malesuada id massa. Ut laoreet dictum velit ac + sagittis. Integer ac blandit felis, ac porta nulla. Cras placerat efficitur lacus vitae + egestas. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia + curae; Aliquam sed maximus justo. Sed viverra posuere nisl vitae facilisis. Sed interdum + massa nulla, malesuada finibus nisl elementum lobortis. In finibus finibus odio ut + porttitor. Donec pellentesque massa non faucibus imperdiet. Donec eget turpis cursus, + commodo quam quis, dapibus velit. Maecenas hendrerit arcu sed tincidunt tincidunt. + Phasellus lobortis ultricies augue, et volutpat arcu feugiat quis. Integer maximus, justo + at ultrices sagittis, tortor nibh accumsan sapien, lacinia rhoncus elit elit ac lorem. In + at fermentum mauris. In maximus consectetur risus, quis posuere lectus lacinia et. + Curabitur suscipit ex nec dui vestibulum, sit amet placerat velit varius. Donec ultrices, + nisl sed pellentesque aliquet, mi sem porta ipsum, a placerat nulla lorem at nunc. Nullam + et tristique massa. Praesent a nisi vehicula erat consectetur interdum sodales non magna. + Vestibulum faucibus, nunc id suscipit pulvinar, purus nisl commodo mi, non posuere lorem + est quis enim. Vivamus vel elit malesuada turpis consequat aliquet ac et massa. Fusce + iaculis eget massa eget convallis. Vestibulum consequat sollicitudin leo, in lobortis + sapien consequat eu. In congue at erat eu convallis. Curabitur id interdum ipsum. Nulla + varius gravida accumsan. Nam lobortis ornare tincidunt. Aliquam rhoncus purus id lacus + faucibus, ac congue justo pellentesque. Cras luctus mauris non nisl posuere pulvinar. + Pellentesque feugiat, dui vitae euismod euismod, urna nulla eleifend lectus, a pretium + risus est vitae augue. Integer pulvinar metus a magna bibendum, at egestas ex posuere. + Quisque in sodales nulla, ac vestibulum enim. Praesent sed urna purus. Nullam non mollis + neque, ut venenatis ligula. Nulla facilisi. Nulla accumsan pharetra venenatis. Proin + mollis suscipit massa, id viverra augue fermentum sit amet. Aliquam malesuada neque sit + amet lorem varius tincidunt. Quisque elit dolor, finibus vestibulum metus non, convallis + blandit eros. Sed gravida nec dolor quis euismod. Quisque nec sem orci. Sed varius + faucibus justo in accumsan. Nam quis est vel sapien vulputate feugiat semper a augue. + Suspendisse potenti. Interdum et malesuada fames ac ante ipsum primis in faucibus. + Curabitur id pretium magna. Aenean a est justo. Vestibulum diam diam, porttitor vitae + tellus non, fermentum cursus turpis. Vivamus eu eros nisi. Suspendisse hendrerit risus a + ex egestas, eu consectetur ante fermentum. Ut vel justo sit amet odio lacinia pulvinar sed + ac nibh. Vivamus vulputate ipsum ante, eget iaculis ex tincidunt ac. Sed semper tellus + interdum tristique tempor. Proin massa arcu, ullamcorper interdum nisl sed, aliquam + commodo ligula. Morbi vel metus fermentum, molestie quam ac, ullamcorper ante. Nam + fermentum metus vel egestas molestie. In eget lacus gravida ligula eleifend fringilla sit + amet quis libero. Nunc vehicula vulputate ante, vitae elementum elit ullamcorper quis. + Donec vel felis id urna ultricies viverra. Proin sed diam vel ex vehicula tempus at quis + risus. Donec mollis nisi sed leo tincidunt, non faucibus enim mattis. Ut aliquam fermentum + neque, a porttitor ex consequat et. Ut congue libero volutpat, iaculis mauris in, + consequat diam. Vivamus vel ullamcorper lectus. Aliquam lectus felis, sollicitudin sed + faucibus vitae, lacinia id sapien. Aliquam lobortis aliquet metus, condimentum ornare mi + vestibulum at. Nullam pulvinar ac est vel egestas. Quisque varius ex sem, sit amet tempus + metus rhoncus id. Aliquam suscipit nibh nulla, ut volutpat velit feugiat in. Mauris + aliquam neque vel mi vehicula euismod. Vivamus vehicula turpis sed nulla malesuada tempor. + Maecenas interdum scelerisque accumsan. Duis eu mattis nunc, id lacinia tortor. Maecenas + hendrerit condimentum libero a scelerisque. Donec eros enim, volutpat nec fermentum id, + facilisis fringilla erat. Donec vel ipsum lobortis, porttitor velit sed, pulvinar justo. + Proin interdum magna accumsan ligula porttitor commodo. Curabitur sollicitudin sed orci ac + ullamcorper. Nullam non felis at mauris porttitor varius a vel velit. Integer id nunc + turpis. Integer tempus odio at enim efficitur, sed maximus arcu feugiat. Interdum et + malesuada fames ac ante ipsum primis in faucibus. Morbi laoreet mauris sed malesuada + ultrices. Vivamus lacinia lacus nec velit elementum condimentum. Sed eleifend tristique + dolor quis egestas. Proin vehicula volutpat nisi et rutrum. Nullam venenatis molestie + viverra. Maecenas felis neque, luctus sit amet tincidunt vitae, euismod sed sem. Nunc + volutpat mollis urna, at laoreet odio viverra non. Class aptent taciti sociosqu ad litora + torquent per conubia nostra, per inceptos himenaeos. Donec sed lectus lorem. Proin + volutpat diam nibh, eu lacinia neque euismod a. + + + + ); +}; diff --git a/src/components/common/Modal/Modal.style.ts b/src/components/common/Modal/Modal.style.ts new file mode 100644 index 00000000..0e089b5b --- /dev/null +++ b/src/components/common/Modal/Modal.style.ts @@ -0,0 +1,42 @@ +import type { CSSProp } from 'styled-components'; +import styled, { keyframes } from 'styled-components'; + +export const fadeIn = keyframes` + from { + opacity: 0; + } + to { + opacity: 1; + } +`; + +export const ModalWrapper = styled.div<{ css: CSSProp }>` + display: flex; + align-items: center; + justify-content: center; + + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + + background: rgba(0, 0, 0, 0.5); + overflow: auto; + + animation: ${fadeIn} 0.2s ease-in-out; + + z-index: 9999; + + ${({ css }) => css}; +`; + +export const ModalContent = styled.div<{ noBackdrop: boolean; css: CSSProp }>` + background: #fff; + margin: 1rem; + border-radius: 10px; + max-height: calc(100% - 4rem); + overflow-y: auto; + + ${({ noBackdrop, css }) => noBackdrop && css}; +`; diff --git a/src/components/common/Modal/Modal.tsx b/src/components/common/Modal/Modal.tsx new file mode 100644 index 00000000..8b6b9bd5 --- /dev/null +++ b/src/components/common/Modal/Modal.tsx @@ -0,0 +1,61 @@ +import type { CSSProp } from 'styled-components'; + +import type { MouseEvent, ReactNode } from 'react'; + +import { ModalContent, ModalWrapper } from './Modal.style'; + +export interface ModalProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; + staticBackdrop?: boolean; + noOverflowHidden?: boolean; + noBackdrop?: boolean; + css?: CSSProp; +} + +const Modal = ({ + isOpen, + onClose, + children, + staticBackdrop = false, + noOverflowHidden, + noBackdrop, + css, +}: ModalProps) => { + const handleClickModalContent = (event: MouseEvent) => { + event.stopPropagation(); + }; + + const handleBackdropClick = () => { + if (!staticBackdrop) { + onClose(); + } + }; + + if (!isOpen) { + return null; + } + + const modalContent = ( + + {children} + + ); + + if (noBackdrop) { + return modalContent; + } + + return ( + + {modalContent} + + ); +}; + +export default Modal; diff --git a/src/components/common/Modal/index.ts b/src/components/common/Modal/index.ts new file mode 100644 index 00000000..8144af51 --- /dev/null +++ b/src/components/common/Modal/index.ts @@ -0,0 +1,3 @@ +import Modal from './Modal'; + +export default Modal; diff --git a/src/components/common/SelectBox/SelectBox.stories.tsx b/src/components/common/SelectBox/SelectBox.stories.tsx new file mode 100644 index 00000000..453b9159 --- /dev/null +++ b/src/components/common/SelectBox/SelectBox.stories.tsx @@ -0,0 +1,43 @@ +import styled from 'styled-components'; + +import { useState } from 'react'; + +import Text from '@common/Text'; + +import SelectBox from './SelectBox'; + +const meta = { + title: 'Components/SelectBox', + component: SelectBox, + tags: ['autodocs'], + args: {}, + argTypes: {}, +}; + +export default meta; + +const Container = styled.div` + width: 300px; + height: 1000px; + margin: 20px auto; +`; + +const options = [ + { value: 'IONIC5', label: '아이오닉5' }, + { value: 'TSLA3', label: '테슬라3' }, + { value: 'tajiri', label: '지리자동차' }, +]; + +export const Default = () => { + const [selectedValue, setSelectedValue] = useState(''); + + const handleSelectChange = (value: string) => { + setSelectedValue(value); + }; + return ( + + selectedValue: {selectedValue} + + + ); +}; diff --git a/src/components/common/SelectBox/SelectBox.style.ts b/src/components/common/SelectBox/SelectBox.style.ts new file mode 100644 index 00000000..a4d554b1 --- /dev/null +++ b/src/components/common/SelectBox/SelectBox.style.ts @@ -0,0 +1,36 @@ +import styled from 'styled-components'; + +export const StyledSelect = styled.div` + position: relative; + width: 100%; +`; +export const SelectButton = styled.button` + padding: 0.8rem; + font-size: 1.6rem; + border: 1px solid #ccc; + border-radius: 4px; + outline: none; + width: 100%; + background-color: #ffffff; + cursor: pointer; +`; +export const OptionsContainer = styled.div` + position: absolute; + top: 100%; + left: 0; + width: 100%; + font-size: 1.6rem; + border: 1px solid #ccc; + border-top: none; + border-radius: 0 0 4px 4px; + background-color: #ffffff; + z-index: 1; +`; +export const OptionItem = styled.div` + padding: 0.8rem; + cursor: pointer; + + &:hover { + background-color: #f0f0f0; + } +`; diff --git a/src/components/common/SelectBox/SelectBox.tsx b/src/components/common/SelectBox/SelectBox.tsx new file mode 100644 index 00000000..066a4ce8 --- /dev/null +++ b/src/components/common/SelectBox/SelectBox.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; + +import { OptionItem, OptionsContainer, SelectButton, StyledSelect } from './SelectBox.style'; + +interface SelectOption { + value: string; + label: string; +} + +interface SelectProps { + options: SelectOption[]; + onChange: (value: string) => void; + value: string; +} + +const SelectBox = ({ options, onChange, value }: SelectProps) => { + const [isOpen, setIsOpen] = useState(false); + + const handleSelect = (selectedValue: string) => { + onChange(selectedValue); + setIsOpen(false); + }; + + return ( + + setIsOpen(!isOpen)}> + {options.find((option) => option.value === value)?.label || '항목을 선택하세요'} + + {isOpen && ( + + {options.map((option) => ( + handleSelect(option.value)}> + {option.label} + + ))} + + )} + + ); +}; + +export default SelectBox; diff --git a/src/components/common/SelectBox/index.ts b/src/components/common/SelectBox/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/components/common/Skeleton/Skeleton.stories.tsx b/src/components/common/Skeleton/Skeleton.stories.tsx new file mode 100644 index 00000000..27d74e6d --- /dev/null +++ b/src/components/common/Skeleton/Skeleton.stories.tsx @@ -0,0 +1,61 @@ +import Box from '@common/Box'; + +import type { SkeletonProps } from './Skeleton'; +import Skeleton from './Skeleton'; + +const meta = { + title: 'Components/Skeleton', + component: Skeleton, + tags: ['autodocs'], + args: { + height: '100px', + width: '250px', + borderRadius: '6px', + }, + argTypes: { + height: { + control: { + type: 'text', + }, + description: '높이를 지정할 수 있습니다. 기본 값은 10px 입니다.', + }, + width: { + control: { + type: 'text', + }, + description: '높이를 지정할 수 있습니다. 기본 값은 100% 입니다.', + }, + borderRadius: { + control: { + type: 'text', + }, + description: '모서리의 둥근 정도를 설정할 수 있습니다. 기본 값은 6px 입니다.', + }, + }, +}; + +export default meta; + +export const Default = (args: SkeletonProps) => { + return ; +}; + +export const Example = () => { + return ( + <> + + + + + ); +}; + +export const Spacing = () => { + return ( + <> + + + + + ); +}; diff --git a/src/components/common/Skeleton/Skeleton.style.ts b/src/components/common/Skeleton/Skeleton.style.ts new file mode 100644 index 00000000..e12b6f1d --- /dev/null +++ b/src/components/common/Skeleton/Skeleton.style.ts @@ -0,0 +1,33 @@ +import styled, { keyframes } from 'styled-components'; + +import type { SkeletonProps } from '@common/Skeleton/Skeleton'; +import { spacing } from '@common/styles/spacing'; + +export const skeletonAnimation = keyframes` + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +`; + +export type StyledSkeletonType = Omit & { + $borderRadius: string; +}; + +export const StyledSkeleton = styled.div` + ${spacing}; + + width: ${({ width }) => width || '100%'}; + height: ${({ height }) => height || '1rem'}; + background: linear-gradient(-90deg, var(--lighter-color), #fafafa, var(--lighter-color), #fafafa); + background-size: 400%; + animation: ${skeletonAnimation} 5s infinite ease-out; + border-radius: ${({ $borderRadius }) => $borderRadius || '6px'}; + + ${({ css }) => css} +`; diff --git a/src/components/common/Skeleton/Skeleton.tsx b/src/components/common/Skeleton/Skeleton.tsx new file mode 100644 index 00000000..5859512c --- /dev/null +++ b/src/components/common/Skeleton/Skeleton.tsx @@ -0,0 +1,18 @@ +import type { CSSProp } from 'styled-components'; + +import type { SpacingProps } from '@common/styles/spacing'; + +import { StyledSkeleton } from './Skeleton.style'; + +export interface SkeletonProps extends SpacingProps { + width?: string; + height?: string; + borderRadius?: string; + css?: CSSProp; +} + +const Skeleton = ({ borderRadius, ...props }: SkeletonProps) => { + return ; +}; + +export default Skeleton; diff --git a/src/components/common/Skeleton/index.ts b/src/components/common/Skeleton/index.ts new file mode 100644 index 00000000..63e5e4d7 --- /dev/null +++ b/src/components/common/Skeleton/index.ts @@ -0,0 +1,3 @@ +import Skeleton from './Skeleton'; + +export default Skeleton; diff --git a/src/components/common/Text/Text.stories.tsx b/src/components/common/Text/Text.stories.tsx new file mode 100644 index 00000000..3a63f4c6 --- /dev/null +++ b/src/components/common/Text/Text.stories.tsx @@ -0,0 +1,159 @@ +import type { Meta } from '@storybook/react'; +import styled from 'styled-components'; + +import Text from './Text'; + +const meta = { + title: 'Components/Text', + component: Text, + tags: ['autodocs'], + args: { + tag: 'p', + variant: 'body', + align: 'left', + color: '#333', + lineClamp: 1, + children: 'You forget a thousand things every day. Make sure this is one of them.', + }, + argTypes: { + tag: { + description: + '태그명(ex. header, h2, span)을 입력해 텍스트 컴포넌트의 태그를 바꿀 수 있습니다.', + }, + children: { + control: { + type: 'text', + }, + description: '원하는 글자를 입력해 테스트를 할 수 있습니다.', + }, + variant: { + options: { + none: false, + h1: 'h1', + h2: 'h2', + h3: 'h3', + h4: 'h4', + h5: 'h5', + h6: 'h6', + title: 'title', + subtitle: 'subtitle', + label: 'label', + body: 'body', + caption: 'caption', + }, + control: { + type: 'select', + }, + description: '글자 크기 및 두께를 바꿀 수 있습니다. 기본값은 body입니다.', + }, + align: { + options: { + none: false, + center: 'center', + left: 'left', + right: 'right', + }, + control: { + type: 'select', + }, + description: '선택한 위치에 따라 글자가 정렬됩니다.', + }, + color: { + description: '선택한 색상에 따라 글씨색이 변합니다.', + }, + lineClamp: { + description: '블록 컨테이너 콘텐츠의 줄 수를 선택한 수만큼으로 제한할 수 있습니다.', + }, + fontSize: { + description: + '글자 크기를 직접 조절할 수 있습니다.
❗글자 크기를 직접 설정할 경우, variant 적용시에도 글자 크기는 변하지 않습니다.', + }, + weight: { + description: + '글자 두께를 직접 조절할 수 있습니다.
❗글자 두께를 직접 설정할 경우, variant 적용시에도 글자 두께는 변하지 않습니다.', + }, + lineHeight: { + control: { + type: 'text', + }, + description: '글자의 줄 간격을 조절할 수 있습니다.', + }, + }, +} satisfies Meta; + +export default meta; + +interface Props { + children: string; + variant: + | 'h1' + | 'h2' + | 'h3' + | 'h4' + | 'h5' + | 'h6' + | 'title' + | 'subtitle' + | 'label' + | 'body' + | 'caption'; +} + +export const Default = (args: Props) => { + return ( + <> + + + + ); +}; + +export const Sizes = () => { + return ( + <> + Heading 1 + Heading 2 + Heading 3 + Heading 4 + Heading 5 + Heading 6 + Title + Subtitle + Label + Body Text + Caption Text + You forget a thousand things every day. Make sure this is one of them. + + ); +}; + +export const MarginBottom = () => { + return ( + <> + + Heading 1 + + + Heading 1 + + Heading 1 + + ); +}; + +export const LineClamp = () => { + return ( + + + You forget a thousand things every day. Make sure this is one of them. + + + ); +}; + +const S = { + Container: styled.div` + width: 30rem; + line-height: 1.5; + `, +}; diff --git a/src/components/common/Text/Text.style.ts b/src/components/common/Text/Text.style.ts new file mode 100644 index 00000000..85fb5c3e --- /dev/null +++ b/src/components/common/Text/Text.style.ts @@ -0,0 +1,121 @@ +import { css, styled } from 'styled-components'; + +import type { TextProps } from '@common/Text/Text'; +import { spacing } from '@common/styles/spacing'; + +export type StyledTextType = Omit & { + $lineClamp?: number; + $lineHeight?: number | string; +}; + +export const StyledText = styled.p` + ${spacing}; + + ${({ align }) => { + switch (align) { + case 'center': + return css` + text-align: center; + `; + case 'left': + return css` + text-align: left; + `; + case 'right': + return css` + text-align: right; + `; + default: + return css` + text-align: inherit; + `; + } + }} + + ${({ variant }) => { + switch (variant) { + case 'h1': + return css` + font-size: 4.8rem; + font-weight: bold; + `; + case 'h2': + return css` + font-size: 4rem; + font-weight: bold; + `; + case 'h3': + return css` + font-size: 3.2rem; + font-weight: bold; + `; + case 'h4': + return css` + font-size: 2.4rem; + font-weight: bold; + `; + case 'h5': + return css` + font-size: 2rem; + font-weight: bold; + `; + case 'h6': + return css` + font-size: 1.6rem; + font-weight: bold; + `; + case 'title': + return css` + font-size: 2.2rem; + font-weight: bold; + `; + case 'subtitle': + return css` + font-size: 1.6rem; + `; + case 'label': + return css` + font-size: 1.4rem; + `; + case 'body': + return css` + font-size: 1.5rem; + `; + case 'caption': + return css` + font-size: 1.2rem; + color: #666; + `; + case 'pillbox': + return css` + font-size: 1.3rem; + color: #4b4b4b; + padding: 0.2rem 1.2rem 0.4rem; + background: var(--light-color); + border-radius: 6px; + `; + default: + return css` + font-size: 1.5rem; + `; + } + }} + + font-size: ${({ fontSize }) => fontSize && `${fontSize}rem`}; + font-weight: ${({ weight }) => (weight === 'regular' ? 500 : weight)}; + color: ${({ color }) => color}; + + ${({ $lineClamp }) => + $lineClamp && + ` + display: -webkit-box; + overflow: hidden; + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: ${$lineClamp}; + `} + + line-height: ${({ $lineHeight }) => $lineHeight}; + + ${({ css }) => css} +`; diff --git a/src/components/common/Text/Text.tsx b/src/components/common/Text/Text.tsx new file mode 100644 index 00000000..bcd62be7 --- /dev/null +++ b/src/components/common/Text/Text.tsx @@ -0,0 +1,48 @@ +import type { CSSProp } from 'styled-components'; + +import type { HTMLAttributes } from 'react'; + +import type { SpacingProps } from '@common/styles/spacing'; + +import { StyledText } from './Text.style'; + +const variantList = [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'title', + 'subtitle', + 'label', + 'body', + 'caption', + 'pillbox', +] as const; + +export type VariantType = (typeof variantList)[number]; + +export interface TextProps extends HTMLAttributes, SpacingProps { + tag?: string; + variant?: VariantType; + align?: 'center' | 'left' | 'right'; + color?: string; + lineClamp?: number; + fontSize?: number; + weight?: 'bolder' | 'bold' | 'regular' | 'normal' | 'lighter'; + lineHeight?: number | string; + css?: CSSProp; +} + +const Text = ({ children, tag, lineClamp, lineHeight, ...props }: TextProps) => { + const changeableTag = tag || 'p'; + + return ( + + {children} + + ); +}; + +export default Text; diff --git a/src/components/common/Text/index.ts b/src/components/common/Text/index.ts new file mode 100644 index 00000000..ddd97264 --- /dev/null +++ b/src/components/common/Text/index.ts @@ -0,0 +1,3 @@ +import Text from './Text'; + +export default Text; diff --git a/src/components/common/TextField/TextField.stories.tsx b/src/components/common/TextField/TextField.stories.tsx new file mode 100644 index 00000000..58ccdc2b --- /dev/null +++ b/src/components/common/TextField/TextField.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta } from '@storybook/react'; + +import { useState } from 'react'; + +import Text from '../Text'; +import type { TextFieldProps } from './TextField'; +import TextField from './TextField'; + +const meta = { + title: 'Components/TextField', + component: TextField, + tags: ['autodocs'], + args: { + value: '아래 Control/Input 필드에서 저를 지워보세요', + width: 150, + label: 'Label', + supportingText: '도움말은 여기에 입력됩니다.', + }, + argTypes: { + label: { + control: { + type: 'text', + }, + description: 'input 내부에 표기할 기본 라벨 입니다.', + }, + value: { + control: { + type: 'text', + }, + description: '기존 input의 value 입니다. 반드시 문자열로 처리됩니다.', + }, + onChange: { + description: '기존 input의 onChange 입니다.', + }, + supportingText: { + description: '도움 메세지를 입력할 수 있습니다.', + }, + width: { + description: '너비 길이를 설정할 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', + }, + cssForLabel: { + description: 'label의 CSS를 수동으로 지정할 수 있습니다.', + }, + cssForInput: { + description: 'input의 CSS를 수동으로 지정할 수 있습니다.', + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = (args: TextFieldProps) => { + return ; +}; + +export const Label = () => { + return ; +}; + +export const Value = () => { + const [value, setValue] = useState(''); + return ( + <> + value: {value} + { + setValue(e.target.value); + }} + /> + + ); +}; + +export const HelperText = () => { + const [value, setValue] = useState(''); + return ( + <> + value: {value} + { + setValue(e.target.value); + }} + supportingText={value.length < 2 && '닉네임은 2글자 이상 입력하셔야 합니다.'} + /> + + ); +}; + +export const Width = () => { + return ; +}; + +export const FullWidth = () => { + return ; +}; diff --git a/src/components/common/TextField/TextField.style.ts b/src/components/common/TextField/TextField.style.ts new file mode 100644 index 00000000..b28e3451 --- /dev/null +++ b/src/components/common/TextField/TextField.style.ts @@ -0,0 +1,54 @@ +import styled from 'styled-components'; + +import type { TextFieldProps } from '@common/TextField/TextField'; + +export const StyledGroup = styled.div` + position: relative; + margin: 2rem 0; +`; + +export const StyledInput = styled.input` + background: none; + font-size: 1.8rem; + padding: 1rem 1rem 1rem 0.5rem; + display: block; + + ${({ width }) => width && `width: ${width * 0.4}rem`}; + ${({ fullWidth }) => fullWidth && 'width: 100%;'} + + border: none; + border-radius: 0; + border-bottom: 1px solid #c6c6c6; + + &:focus { + outline: none; + } + + &:focus ~ label, + &:valid ~ label { + top: -1.4rem; + font-size: 1.2rem; + color: #2196f3; + } + + ${({ cssForInput }) => cssForInput}; +`; + +export const StyledHelperText = styled.div` + color: red; + font-size: 1.2rem; + margin-top: 0.5rem; +`; + +export const StyledLabel = styled.label` + color: #c6c6c6; + font-size: 1.6rem; + font-weight: normal; + position: absolute; + pointer-events: none; + left: 0.5rem; + top: 1rem; + transition: 300ms ease all; + + ${({ cssForLabel }) => cssForLabel}; +`; diff --git a/src/components/common/TextField/TextField.tsx b/src/components/common/TextField/TextField.tsx new file mode 100644 index 00000000..dd3896ab --- /dev/null +++ b/src/components/common/TextField/TextField.tsx @@ -0,0 +1,50 @@ +import type { CSSProp } from 'styled-components'; + +import type { ChangeEvent, HTMLAttributes } from 'react'; + +import { StyledGroup, StyledHelperText, StyledInput, StyledLabel } from './TextField.style'; + +export interface TextFieldProps extends HTMLAttributes { + // textFieldId: string; + label?: string; + width?: number; + value?: string; + onChange?: (e: ChangeEvent) => void; + supportingText?: string; + fullWidth?: boolean; + cssForLabel?: CSSProp; + cssForInput?: CSSProp; +} + +const TextField = ({ + // textFieldId, + label, + value, + onChange, + supportingText, + fullWidth, + ...props +}: TextFieldProps) => { + return ( + + + + {label} + + {supportingText && {supportingText}} + + ); +}; + +export default TextField; diff --git a/src/components/common/TextField/index.ts b/src/components/common/TextField/index.ts new file mode 100644 index 00000000..7e9c49aa --- /dev/null +++ b/src/components/common/TextField/index.ts @@ -0,0 +1,3 @@ +import TextField from './TextField'; + +export default TextField; diff --git a/src/components/common/Toast/Toast.stories.tsx b/src/components/common/Toast/Toast.stories.tsx new file mode 100644 index 00000000..66fc2ff7 --- /dev/null +++ b/src/components/common/Toast/Toast.stories.tsx @@ -0,0 +1,124 @@ +import type { Meta } from '@storybook/react'; +import { styled } from 'styled-components'; + +import { getToastColor } from '@common/Toast/Toast.style'; + +import { toastActions, toastListStore } from '../../../stores/layout/toastStore'; +import type { Color } from '../../../types'; +import { useExternalValue } from '../../../utils/external-state'; +import ButtonNext from '../ButtonNext'; +import Text from '../Text'; +import type { ToastProps } from './Toast'; +import Toast from './Toast'; + +const meta = { + title: 'Components/Toast', + component: Toast, + tags: ['autodocs'], + args: { + toastId: 0, + message: '사용자에게 보여줄 메시지를 입력하세요', + position: 'bottom-center', + color: 'success', + }, + argTypes: { + toastId: { + description: '토스트 고유의 id 입니다.', + }, + message: { + description: '원하는 글자를 입력해 테스트를 할 수 있습니다.', + }, + position: { + description: '선택한 위치에 따라 토스트가 나오는 방향을 선택할 수 있습니다.', + }, + color: { + description: '선택한 색상에 따라 토스트의 색상이 변합니다.', + }, + css: { + description: '원하는 css를 적용할 수 있습니다.', + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = (args: ToastProps) => { + const toastItems = useExternalValue(toastListStore); + const { showToast } = toastActions; + + const { message, position, color } = args; + + return ( + <> + showToast(message, color, position)}> + 나와라 토스트! + + <> + {toastItems.map((toastItem) => ( + + ))} + + + ); +}; + +export const Colors = () => { + return ( + <> + + Primary + + 이삭 토스트 + + Secondary + + 이삭 토스트 + + Success + + 이삭 토스트 + + Warning + + 이삭 토스트 + + Error + + 이삭 토스트 + + Info + + 이삭 토스트 + + Light + + 이삭 토스트 + + Dark + + 이삭 토스트 + + ); +}; + +const S = { + Toast: styled.div<{ color: Color }>` + width: max-content; + max-width: 40rem; + padding: 1.2rem 2.4rem; + font-size: 1.5rem; + text-align: center; + word-break: keep-all; + line-height: 1.5; + border-radius: 28px; + font-weight: 500; + color: #fff; + margin-bottom: 2rem; + + &:last-child { + margin-bottom: 0; + } + + ${({ color }) => getToastColor(color)} + `, +}; diff --git a/src/components/common/Toast/Toast.style.ts b/src/components/common/Toast/Toast.style.ts new file mode 100644 index 00000000..de2ab05e --- /dev/null +++ b/src/components/common/Toast/Toast.style.ts @@ -0,0 +1,85 @@ +import { css, styled } from 'styled-components'; + +import type { ToastProps } from '@common/Toast/Toast'; + +import { getPopupAnimation } from '@style'; + +import type { Color } from '@type'; + +export interface StyleProps extends Pick { + duration?: number; +} + +export const StyledToast = styled.div` + position: fixed; + width: max-content; + max-width: 40rem; + z-index: 99999; + padding: 1.2rem 2.4rem; + font-size: 1.5rem; + text-align: center; + word-break: keep-all; + line-height: 1.5; + border-radius: 28px; + font-weight: 500; + color: #fff; + + ${({ color }) => getToastColor(color)}; + ${({ position, duration }) => getPopupAnimation(position, duration)} + + ${({ css }) => css} +`; + +// TODO: Alert랑 통일 +export const getToastColor = (color?: Color) => { + switch (color) { + case 'primary': + return css` + background: #cfe2ff; + border: 1.3px solid #9ec5fe; + color: #052c65; + `; + case 'secondary': + return css` + border: 1.3px solid #dce5ff; + background: #e9edf8; + color: #585858; + `; + case 'success': + return css` + background: #d1e7dd; + border: 1.3px solid #a3cfbb; + color: #0a3622; + `; + case 'error': + return css` + background: #f8d7da; + border: 1.3px solid #f1aeb5; + color: #58151c; + `; + case 'warning': + return css` + border: 1.3px solid #ffe002; + background: #fffce1; + color: #664d03; + `; + case 'info': + return css` + border: 1.3px solid #aad5e2; + background: #f3f8ff; + color: #585858; + `; + case 'light': + return css` + border: 1.3px solid #d3d7db; + background: #e2e6ea; + color: #585858; + `; + case 'dark': + return css` + background: #ced4da; + border: 1.3px solid #adb5bd; + color: #495057; + `; + } +}; diff --git a/src/components/common/Toast/Toast.tsx b/src/components/common/Toast/Toast.tsx new file mode 100644 index 00000000..10d05e19 --- /dev/null +++ b/src/components/common/Toast/Toast.tsx @@ -0,0 +1,37 @@ +import type { CSSProp } from 'styled-components'; + +import type { HTMLAttributes } from 'react'; + +import { calculateToastDuration } from '@utils/calculateToastDuration'; + +import { toastActions } from '@stores/layout/toastStore'; + +import type { Color, ToastPosition } from '@type/style'; + +import { StyledToast } from './Toast.style'; + +export interface ToastProps extends HTMLAttributes { + toastId: number; + message: string; + position?: `${ToastPosition['column']}-${ToastPosition['row']}`; + color?: Color; + css?: CSSProp; +} + +const Toast = ({ toastId, message, ...props }: ToastProps) => { + const { deleteToast } = toastActions; + + const duration = calculateToastDuration(message); + + setTimeout(() => { + deleteToast(toastId); + }, duration * 1000); + + return ( + + {message} + + ); +}; + +export default Toast; diff --git a/src/components/common/Toast/index.ts b/src/components/common/Toast/index.ts new file mode 100644 index 00000000..25888862 --- /dev/null +++ b/src/components/common/Toast/index.ts @@ -0,0 +1,3 @@ +import Toast from './Toast'; + +export default Toast; diff --git a/src/components/common/styles/spacing.ts b/src/components/common/styles/spacing.ts new file mode 100644 index 00000000..e81a9e66 --- /dev/null +++ b/src/components/common/styles/spacing.ts @@ -0,0 +1,100 @@ +import { css } from 'styled-components'; + +import { addUnit } from '../utils/addUnit'; + +export interface SpacingProps { + /** 상하좌우 padding, + * - [number] 숫자만 적을 경우 rem 단위로 자동 변환 + * - [string] 단위까지 적어줘야 함 (ex. 0.8rem, 1rem 2rem) + */ + p?: number | string; + /** 좌우 padding, + * - [number] 숫자만 적을 경우 rem 단위로 자동 변환 + * - [string] 단위까지 적어줘야 함 (ex. 0.8rem, 1rem 2rem) + */ + px?: number | string; + /** 상하 padding, + * - [number] 숫자만 적을 경우 rem 단위로 자동 변환 + * - [string] 단위까지 적어줘야 함 (ex. 0.8rem, 1rem 2rem) + */ + py?: number | string; + /** 왼쪽에 padding, + * - [number] 숫자만 적을 경우 rem 단위로 자동 변환 + * - [string] 단위까지 적어줘야 함 (ex. 0.8rem, 1rem 2rem) + */ + pl?: number | string; + /** 오른쪽에 padding, + * - [number] 숫자만 적을 경우 rem 단위로 자동 변환 + * - [string] 단위까지 적어줘야 함 (ex. 0.8rem, 1rem 2rem) + */ + pr?: number | string; + /** 위에 padding, + * - [number] 숫자만 적을 경우 rem 단위로 자동 변환 + * - [string] 단위까지 적어줘야 함 (ex. 0.8rem, 1rem 2rem) + */ + pt?: number | string; + /** 아래에 padding, + * - [number] 숫자만 적을 경우 rem 단위로 자동 변환 + * - [string] 단위까지 적어줘야 함 (ex. 0.8rem, 1rem 2rem) + */ + pb?: number | string; + /** 상하좌우 margin, + * - [number] 숫자만 적을 경우 rem 단위로 자동 변환 + * - [string] 단위까지 적어줘야 함 (ex. 0.8rem, 1rem 2rem) + */ + m?: number | string; + /** 좌우 margin, + * - [number] 숫자만 적을 경우 rem 단위로 자동 변환 + * - [string] 단위까지 적어줘야 함 (ex. 0.8rem, 1rem 2rem) + */ + mx?: number | string; + /** 상하 margin, + * - [number] 숫자만 적을 경우 rem 단위로 자동 변환 + * - [string] 단위까지 적어줘야 함 (ex. 0.8rem, 1rem 2rem) + */ + my?: number | string; + /** 왼쪽에 margin, + * - [number] 숫자만 적을 경우 rem 단위로 자동 변환 + * - [string] 단위까지 적어줘야 함 (ex. 0.8rem, 1rem 2rem) + */ + ml?: number | string; + /** 오른쪽에 margin, + * - [number] 숫자만 적을 경우 rem 단위로 자동 변환 + * - [string] 단위까지 적어줘야 함 (ex. 0.8rem, 1rem 2rem) + */ + mr?: number | string; + /** 위에 margin, + * - [number] 숫자만 적을 경우 rem 단위로 자동 변환 + * - [string] 단위까지 적어줘야 함 (ex. 0.8rem, 1rem 2rem) + */ + mt?: number | string; + /** 아래에 margin, + * - [number] 숫자만 적을 경우 rem 단위로 자동 변환 + * - [string] 단위까지 적어줘야 함 (ex. 0.8rem, 1rem 2rem) + */ + mb?: number | string; +} + +export const spacing = css` + ${({ p }) => p !== undefined && `padding: ${addUnit(p, 0.4)}`}; + ${({ px }) => + px !== undefined && `padding-left: ${addUnit(px, 0.4)}; padding-right: ${addUnit(px, 0.4)}`}; + ${({ py }) => + py !== undefined && `padding-top: ${addUnit(py, 0.4)}; padding-bottom: ${addUnit(py, 0.4)}`}; + + ${({ pt }) => pt !== undefined && `padding-top: ${addUnit(pt, 0.4)}`}; + ${({ pr }) => pr !== undefined && `padding-right: ${addUnit(pr, 0.4)}`}; + ${({ pb }) => pb !== undefined && `padding-bottom: ${addUnit(pb, 0.4)}`}; + ${({ pl }) => pl !== undefined && `padding-left: ${addUnit(pl, 0.4)}`}; + + ${({ m }) => m !== undefined && `margin: ${addUnit(m, 0.4)}`}; + ${({ mx }) => + mx !== undefined && `margin-left: ${addUnit(mx, 0.4)}; margin-right: ${addUnit(mx, 0.4)}`}; + ${({ my }) => + my !== undefined && `margin-top: ${addUnit(my, 0.4)}; margin-bottom: ${addUnit(my, 0.4)}`}; + + ${({ mt }) => mt !== undefined && `margin-top: ${addUnit(mt, 0.4)}`}; + ${({ mr }) => mr !== undefined && `margin-right: ${addUnit(mr, 0.4)}`}; + ${({ mb }) => mb !== undefined && `margin-bottom: ${addUnit(mb, 0.4)}`}; + ${({ ml }) => ml !== undefined && `margin-left: ${addUnit(ml, 0.4)}`}; +`; diff --git a/src/components/common/utils/addUnit.ts b/src/components/common/utils/addUnit.ts new file mode 100644 index 00000000..3f45155f --- /dev/null +++ b/src/components/common/utils/addUnit.ts @@ -0,0 +1,9 @@ +export const addUnit = (prop: number | string, spacing?: number) => { + if (typeof prop === 'string') { + return prop; + } + + const defaultSpacing = spacing === undefined ? 1 : spacing; + + return `${(prop * defaultSpacing) / 1}rem`; +}; diff --git a/src/components/google-maps/map/CarFfeineMap.tsx b/src/components/google-maps/map/CarFfeineMap.tsx index b61fc612..3e493cc0 100644 --- a/src/components/google-maps/map/CarFfeineMap.tsx +++ b/src/components/google-maps/map/CarFfeineMap.tsx @@ -1,9 +1,33 @@ +import { lazy, Suspense } from 'react'; + import CarFfeineMapListener from './CarFfeineListener'; +const UserFilterListener = lazy(() => import('./UserFilterListener')); +const MarkersContainers = lazy(() => import('@marker/MarkerContainers')); +const ToastContainer = lazy(() => import('@ui/ToastContainer')); +const ClientStationFilters = lazy(() => import('@ui/ClientStationFilters')); +const MapController = lazy(() => import('@ui/MapController')); +const ModalContainer = lazy(() => import('@ui/ModalContainer')); +const Navigator = lazy(() => import('@ui/Navigator')); +const WarningModalContainer = lazy(() => import('@ui/WarningModalContainer')); + const CarFfeineMap = () => { return ( <> + + + + + + + + + + + + + ); }; diff --git a/src/components/google-maps/map/UserFilterListener.tsx b/src/components/google-maps/map/UserFilterListener.tsx new file mode 100644 index 00000000..7e538281 --- /dev/null +++ b/src/components/google-maps/map/UserFilterListener.tsx @@ -0,0 +1,25 @@ +import { useQueryClient } from '@tanstack/react-query'; + +import { serverStationFilterAction } from '@stores/station-filters/serverStationFiltersStore'; + +import { useCarFilters } from '@hooks/tanstack-query/station-filters/useCarFilters'; +import { useMemberFilters } from '@hooks/tanstack-query/station-filters/useMemberFilters'; + +import { QUERY_KEY_STATION_MARKERS } from '@constants/queryKeys'; + +const UserFilterListener = () => { + const queryClient = useQueryClient(); + const { data: memberFilters } = useMemberFilters(); + const { data: carFilters } = useCarFilters(); + const { setAllServerStationFilters } = serverStationFilterAction; + + if (memberFilters !== undefined) { + setAllServerStationFilters(memberFilters); + setAllServerStationFilters(carFilters); + queryClient.invalidateQueries([{ queryKey: [QUERY_KEY_STATION_MARKERS] }]); + } + + return <>; +}; + +export default UserFilterListener; diff --git a/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRegionMarkers.ts b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRegionMarkers.ts index 7f6207d7..01d73106 100644 --- a/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRegionMarkers.ts +++ b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRegionMarkers.ts @@ -6,7 +6,7 @@ import { SERVER_URL } from '@constants/server'; import type { Region } from '../types'; export const fetchRegionMarkers = async () => { - const stationMarkers = await fetch(`${SERVER_URL}/stations/markers/regions?regions=all`) + const stationMarkers = await fetch(`${SERVER_URL}/stations/regions?regions=all`) .then(async (response) => { const data = await response.json(); diff --git a/src/components/login-page/GoogleLogin.tsx b/src/components/login-page/GoogleLogin.tsx new file mode 100644 index 00000000..1fc7a851 --- /dev/null +++ b/src/components/login-page/GoogleLogin.tsx @@ -0,0 +1,66 @@ +import Loading from 'components/ui/Loading'; + +import { useEffect, useState } from 'react'; + +import { fetchUtils } from '@utils/fetch'; +import { getMemberToken } from '@utils/login'; +import { setSessionStorage } from '@utils/storage'; + +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import { SERVER_URL } from '@constants/server'; +import { SESSION_KEY_MEMBER_INFO, SESSION_KEY_MEMBER_TOKEN } from '@constants/storageKeys'; + +const GoogleLogin = () => { + const [loginError, setLoginError] = useState(null); + const homePageUrl = location.href.split('google')[0]; + + useEffect(() => { + const code = new URLSearchParams(location.search).get('code') ?? ''; + const encodedCode = encodeURIComponent(code); + + getMemberToken(encodedCode, 'google') + .then(async (token) => { + memberTokenStore.setState(token); + + const memberInfo = await fetchUtils.get(`${SERVER_URL}/members/me`); + + setSessionStorage(SESSION_KEY_MEMBER_TOKEN, token); + setSessionStorage(SESSION_KEY_MEMBER_INFO, JSON.stringify(memberInfo)); + + location.href = homePageUrl; + }) + .catch(() => { + setLoginError(new Error('로그인 중에 에러가 발생했습니다!')); + }); + }, []); + + if (loginError !== null) { + return ( + + 로그인 중에 에러가 발생했습니다! + { + location.href = homePageUrl; + }} + > + 홈으로 돌아가기 + + + ); + } + + return ; +}; + +export default GoogleLogin; diff --git a/src/components/ui/ChargingSpeedIcon.tsx b/src/components/ui/ChargingSpeedIcon.tsx new file mode 100644 index 00000000..e79e08c8 --- /dev/null +++ b/src/components/ui/ChargingSpeedIcon.tsx @@ -0,0 +1,26 @@ +import { BoltIcon } from '@heroicons/react/24/solid'; +import { css } from 'styled-components'; + +import FlexBox from '@common/FlexBox'; + +const ChargingSpeedIcon = () => { + return ( + + + + ); +}; + +const square = css` + padding: 0.4rem; + border-radius: 1rem; +`; + +export default ChargingSpeedIcon; diff --git a/src/components/ui/ClientStationFilters.tsx b/src/components/ui/ClientStationFilters.tsx new file mode 100644 index 00000000..f0518220 --- /dev/null +++ b/src/components/ui/ClientStationFilters.tsx @@ -0,0 +1,113 @@ +import { css, styled } from 'styled-components'; + +import { useExternalState, useExternalValue } from '@utils/external-state'; +import { getTypedObjectKeys } from '@utils/getTypedObjectKeys'; + +import { navigationBarPanelStore } from '@stores/layout/navigationBarPanelStore'; +import { toastActions } from '@stores/layout/toastStore'; +import type { ClientStationFilter } from '@stores/station-filters/clientStationFiltersStore'; +import { clientStationFiltersStore } from '@stores/station-filters/clientStationFiltersStore'; + +import useMediaQueries from '@hooks/useMediaQueries'; + +import FlexBox from '@common/FlexBox'; + +import { MOBILE_BREAKPOINT, NAVIGATOR_PANEL_WIDTH } from '@constants'; + +import StationSearchBar from './StationSearchWindow/StationSearchBar'; + +const ADDITIONAL_MARGIN = 8; + +const ClientStationFilters = () => { + const screen = useMediaQueries(); + const [filterOptions, setFilterOptions] = useExternalState(clientStationFiltersStore); + const { basePanel, lastPanel } = useExternalValue(navigationBarPanelStore); + + const navigationComponentWidth = + (basePanel === null ? 0 : NAVIGATOR_PANEL_WIDTH) + + (lastPanel === null ? 0 : NAVIGATOR_PANEL_WIDTH) + + ADDITIONAL_MARGIN; + + const toggleFilterOption = (filterKey: keyof ClientStationFilter) => { + setFilterOptions((prev) => { + toastActions.showToast( + prev[filterKey].isAvailable ? '필터가 해제되었습니다.' : '필터가 적용되었습니다.', + prev[filterKey].isAvailable ? 'secondary' : 'primary' + ); + + return { + ...prev, + [filterKey]: { + ...prev[filterKey], + isAvailable: !prev[filterKey].isAvailable, + }, + }; + }); + }; + + return ( + + {screen.get('isMobile') ? : !basePanel && } + + {getTypedObjectKeys(filterOptions).map((filterKey) => ( + toggleFilterOption(filterKey)} + $isChecked={filterOptions[filterKey].isAvailable} + > + {filterOptions[filterKey].label} + + ))} + + + ); +}; + +const Container = styled.div<{ + left: number; +}>` + position: fixed; + top: 14px; + left: ${({ left }) => left}rem; + z-index: 998; + padding: 10px; + display: flex; + align-items: start; + column-gap: 40px; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + left: 0; + gap: 10px; + flex-direction: column; + width: 100%; + } +`; + +const ClientFilterButton = styled.button<{ + $isChecked: boolean; +}>` + padding: 0.6rem 1.6rem; + background: ${({ $isChecked }) => ($isChecked ? '#ccdaff' : '#fff')}; + box-shadow: + 0 1px 2px rgba(60, 64, 67, 0.3), + 0 1px 3px 1px rgba(60, 64, 67, 0.15); + border-radius: 16px; +`; + +const filterContainerCss = css` + gap: 10px; + border-radius: 0; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + flex-wrap: nowrap; + width: 100%; + padding: 0 0.4rem 1rem; + overflow-x: auto; + + & > button { + flex: none; + } + } +`; + +export default ClientStationFilters; diff --git a/src/components/ui/Error.tsx b/src/components/ui/Error.tsx new file mode 100644 index 00000000..48d8fac4 --- /dev/null +++ b/src/components/ui/Error.tsx @@ -0,0 +1,54 @@ +import Box from '@common/Box'; +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; +import type { FlexBoxProps } from '@common/FlexBox/FlexBox'; +import Text from '@common/Text'; + +export interface ErrorProps extends Partial { + fontSize?: number | string; + title: string; + message: string; + subMessage?: string; + handleRetry?: () => void; +} + +const Error = ({ title, message, subMessage, handleRetry, fontSize, ...props }: ErrorProps) => { + return ( + + + + {title} + + + {message} + + {subMessage} + + + 다시 시도하기 + + + + + ); +}; +export default Error; diff --git a/src/components/ui/Loading/Loading.stories.tsx b/src/components/ui/Loading/Loading.stories.tsx new file mode 100644 index 00000000..267a9cd4 --- /dev/null +++ b/src/components/ui/Loading/Loading.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta } from '@storybook/react'; + +import Loading from './Loading'; + +const meta = { + component: Loading, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +export const Default = () => { + return ; +}; diff --git a/src/components/ui/Loading/Loading.style.ts b/src/components/ui/Loading/Loading.style.ts new file mode 100644 index 00000000..aba519b2 --- /dev/null +++ b/src/components/ui/Loading/Loading.style.ts @@ -0,0 +1,20 @@ +import styled from 'styled-components'; + +export const StyledMessage = styled.h2` + width: fit-content; + margin: 20% auto 0; + text-align: center; + font-size: 24px; + font-weight: 500; + + & > span { + display: block; + margin-top: 20px; + font-size: 15px; + font-weight: normal; + } +`; +export const StyledLoadingSvgContainer = styled.div` + max-width: 100px; + margin: 0 auto; +`; diff --git a/src/components/ui/Loading/Loading.tsx b/src/components/ui/Loading/Loading.tsx new file mode 100644 index 00000000..1eaf5221 --- /dev/null +++ b/src/components/ui/Loading/Loading.tsx @@ -0,0 +1,18 @@ +import { StyledLoadingSvgContainer, StyledMessage } from '@ui/Loading/Loading.style'; + +import LoadingSvg from '@assets/loading.svg'; + +const Loading = () => { + return ( + <> + + 열심히 로딩하고 있어요잠시만 기다려 주세요... + + + + + + ); +}; + +export default Loading; diff --git a/src/components/ui/Loading/index.ts b/src/components/ui/Loading/index.ts new file mode 100644 index 00000000..a57e3977 --- /dev/null +++ b/src/components/ui/Loading/index.ts @@ -0,0 +1,3 @@ +import Loading from './Loading'; + +export default Loading; diff --git a/src/components/ui/MapController.tsx b/src/components/ui/MapController.tsx new file mode 100644 index 00000000..50c3a332 --- /dev/null +++ b/src/components/ui/MapController.tsx @@ -0,0 +1,104 @@ +import { MinusSmallIcon, PlusSmallIcon } from '@heroicons/react/24/outline'; +import { css } from 'styled-components'; + +import { BiCurrentLocation } from 'react-icons/bi'; + +import { useStationMarkers } from '@marker/SmallMediumDeltaAreaMarkerContainer/hooks/useStationMarkers'; + +import { googleMapActions } from '@stores/google-maps/googleMapStore'; + +import Box from '@common/Box'; +import Button from '@common/Button'; +import Loader from '@common/Loader'; + +import { MOBILE_BREAKPOINT } from '@constants'; + +const MapController = () => { + const { isFetching } = useStationMarkers(); + + const handleCurrentPositionButton = () => { + if (!isFetching) { + googleMapActions.moveToCurrentPosition(); + } + }; + + const handleZoomUpButton = () => { + googleMapActions.zoomUp(); + }; + + const handleZoomDownButton = () => { + googleMapActions.zoomDown(); + }; + + return ( + + + + + + ); +}; + +const containerCss = css` + position: fixed; + bottom: 3.2rem; + right: 0.8rem; + z-index: 99; + + width: 4.2rem; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + bottom: 9.6rem; + right: 0.8rem; + } +`; + +const buttonCss = css` + display: flex; + width: 100%; + height: 4.2rem; + border: 1.8px solid #e3e8f7; + + & svg, + div { + margin: auto; + } +`; + +const currentPositionIconCss = css` + margin-bottom: 20px; +`; + +const plusIconCss = css` + border-bottom: 0; +`; + +export default MapController; diff --git a/src/components/ui/ModalContainer.tsx b/src/components/ui/ModalContainer.tsx new file mode 100644 index 00000000..354481f0 --- /dev/null +++ b/src/components/ui/ModalContainer.tsx @@ -0,0 +1,18 @@ +import { useExternalValue } from '@utils/external-state'; + +import { modalActions, modalContentStore, modalOpenStore } from '@stores/layout/modalStore'; + +import Modal from '@common/Modal'; + +const ModalContainer = () => { + const isModalOpen = useExternalValue(modalOpenStore); + const modalContent = useExternalValue(modalContentStore); + + return ( + modalActions.closeModal()}> + {modalContent} + + ); +}; + +export default ModalContainer; diff --git a/src/components/ui/Navigator/NavigationBar/BasePanel.tsx b/src/components/ui/Navigator/NavigationBar/BasePanel.tsx new file mode 100644 index 00000000..2d27064c --- /dev/null +++ b/src/components/ui/Navigator/NavigationBar/BasePanel.tsx @@ -0,0 +1,31 @@ +import { css } from 'styled-components'; + +import type { ReactElement } from 'react'; + +import FlexBox from '@common/FlexBox'; + +import { MOBILE_BREAKPOINT } from '@constants'; + +interface Props { + component: ReactElement | null; +} + +const BasePanel = ({ component }: Props) => { + return ( + + {component !== null && component} + + ); +}; + +const containerCss = css` + position: relative; + margin-left: 7rem; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + margin-left: 0; + position: absolute; + } +`; + +export default BasePanel; diff --git a/src/components/ui/Navigator/NavigationBar/CloseButton.tsx b/src/components/ui/Navigator/NavigationBar/CloseButton.tsx new file mode 100644 index 00000000..7e7525de --- /dev/null +++ b/src/components/ui/Navigator/NavigationBar/CloseButton.tsx @@ -0,0 +1,32 @@ +import { ChevronLeftIcon } from '@heroicons/react/24/solid'; + +import Button from '@common/Button'; + +import { displayNoneInMobile } from '@style/mediaQuery'; + +import { useNavigationBar } from './hooks/useNavigationBar'; + +interface Props { + canDisplay: boolean; +} + +const CloseButton = ({ canDisplay }: Props) => { + const { handleClosePanel } = useNavigationBar(); + + if (!canDisplay) { + return <>; + } + + return ( + + ); +}; + +export default CloseButton; diff --git a/src/components/ui/Navigator/NavigationBar/LastPanel.tsx b/src/components/ui/Navigator/NavigationBar/LastPanel.tsx new file mode 100644 index 00000000..f28ba250 --- /dev/null +++ b/src/components/ui/Navigator/NavigationBar/LastPanel.tsx @@ -0,0 +1,23 @@ +import { css } from 'styled-components'; + +import type { ReactElement } from 'react'; + +import FlexBox from '@common/FlexBox'; + +interface Props { + component: ReactElement | null; +} + +const LastPanel = ({ component }: Props) => { + return ( + + {component !== null && <>{component}} + + ); +}; + +const containerCss = css` + position: relative; +`; + +export default LastPanel; diff --git a/src/components/ui/Navigator/NavigationBar/Menu.tsx b/src/components/ui/Navigator/NavigationBar/Menu.tsx new file mode 100644 index 00000000..97a1bc7f --- /dev/null +++ b/src/components/ui/Navigator/NavigationBar/Menu.tsx @@ -0,0 +1,179 @@ +import { AdjustmentsHorizontalIcon, Bars3Icon, UserCircleIcon } from '@heroicons/react/24/outline'; +import { css } from 'styled-components'; + +import { HiArrowPath, HiOutlineChatBubbleOvalLeftEllipsis } from 'react-icons/hi2'; + +import { useExternalValue } from '@utils/external-state'; + +import { modalActions } from '@stores/layout/modalStore'; +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import Button from '@common/Button'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import PersonalMenu from '@ui/Navigator/NavigationBar/PersonalMenu'; +import ServerStationFilters from '@ui/ServerStationFilters'; +import StationListWindow from '@ui/StationListWindow'; +import StationSearchWindow from '@ui/StationSearchWindow'; +import LoginModal from '@ui/modal/LoginModal/LoginModal'; + +import { displayNoneInMobile, displayNoneInWeb } from '@style/mediaQuery'; + +import { EMPTY_MEMBER_TOKEN, MOBILE_BREAKPOINT } from '@constants'; + +import Logo from '@assets/logo-sm.svg'; + +import { useNavigationBar } from './hooks/useNavigationBar'; + +const Menu = () => { + const { openBasePanel } = useNavigationBar(); + + const memberToken = useExternalValue(memberTokenStore); + const isSignIn = memberToken !== EMPTY_MEMBER_TOKEN; + + const handleClickLoginIcon = () => { + modalActions.openModal(); + }; + + return ( + + + + + + + + + + {isSignIn ? ( + + ) : ( + + )} + + + + + + ); +}; + +const flexCss = css` + width: 7rem; + height: 100vh; + flex-direction: column; + align-items: center; + background-color: #fff; + gap: 3rem; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + width: 100vw; + height: 8.2rem; + padding-bottom: 0.8rem; + flex-direction: row; + align-items: center; + gap: 0; + justify-content: space-around; + + & > svg:first-child { + display: none; + } + } +`; + +const fixedPositionCss = css` + position: fixed; + left: 0; + z-index: 999; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + bottom: 0; + z-index: 99; + } +`; + +const paddingCss = css` + padding-top: 2.4rem; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + padding-top: 0; + } +`; + +const borderCss = css` + border-right: 0.1rem solid #ddd; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + border-top: 0.1rem solid #ddd; + border-right: none; + } +`; + +export default Menu; diff --git a/src/components/ui/Navigator/NavigationBar/NavigationBar.stories.tsx b/src/components/ui/Navigator/NavigationBar/NavigationBar.stories.tsx new file mode 100644 index 00000000..8d2e05c6 --- /dev/null +++ b/src/components/ui/Navigator/NavigationBar/NavigationBar.stories.tsx @@ -0,0 +1,81 @@ +import { ChevronLeftIcon } from '@heroicons/react/24/solid'; +import type { Meta } from '@storybook/react'; +import { styled } from 'styled-components'; + +import type { ReactElement } from 'react'; +import { useState } from 'react'; + +import Button from '@common/Button'; +import ButtonNext from '@common/ButtonNext'; + +import NavigationBar from './NavigationBar'; + +const meta = { + title: 'UI/NavigationBar', + component: NavigationBar, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +export const Default = () => { + const [basePanel, setBasePanel] = useState(null); + const [lastPanel, setLastPanel] = useState(null); + + const handleClosePanel = () => { + if (lastPanel !== null) { + setLastPanel(null); + } else { + setBasePanel(null); + } + }; + + return ( + <> + + + + + + +
+ setBasePanel()}> + openBlueBasePanel + + setBasePanel()}>openRedBasePanel + setBasePanel()}>openWhiteBasePanel + setLastPanel()}>openLastPanel +
+ + ); +}; + +const BaseContainer = styled.div` + width: 34rem; + height: 100vh; + border: 1px solid lightgrey; + border-radius: 0; +`; + +const BaseContainerBlue = styled.div` + width: 34rem; + height: 100vh; + border: 1px solid lightgrey; + background-color: blue; +`; + +const BaseContainerRed = styled.div` + width: 34rem; + height: 100vh; + border: 1px solid lightgrey; + background-color: red; +`; + +const LastContainer = styled.div` + width: 34rem; + height: 100vh; + border: 1px solid lightgrey; + border-left: none; +`; diff --git a/src/components/ui/Navigator/NavigationBar/NavigationBar.tsx b/src/components/ui/Navigator/NavigationBar/NavigationBar.tsx new file mode 100644 index 00000000..4dad966e --- /dev/null +++ b/src/components/ui/Navigator/NavigationBar/NavigationBar.tsx @@ -0,0 +1,34 @@ +import { css } from 'styled-components'; + +import type { PropsWithChildren } from 'react'; + +import FlexBox from '@common/FlexBox'; + +import BasePanel from './BasePanel'; +import CloseButton from './CloseButton'; +import LastPanel from './LastPanel'; +import Menu from './Menu'; + +export type BasePanelType = 'searchWindow' | 'stationList' | 'serverStationFilters' | null; + +const NavigationBar = ({ children }: PropsWithChildren) => { + return ( + + {children} + + ); +}; + +NavigationBar.Menu = Menu; +NavigationBar.BasePanel = BasePanel; +NavigationBar.LastPanel = LastPanel; +NavigationBar.CloseButton = CloseButton; + +const accordionContainerCss = css` + position: fixed; + top: 0; + left: 0; + z-index: 999; +`; + +export default NavigationBar; diff --git a/src/components/ui/Navigator/NavigationBar/PersonalMenu.tsx b/src/components/ui/Navigator/NavigationBar/PersonalMenu.tsx new file mode 100644 index 00000000..0cae7601 --- /dev/null +++ b/src/components/ui/Navigator/NavigationBar/PersonalMenu.tsx @@ -0,0 +1,56 @@ +import { ArrowRightOnRectangleIcon, PencilSquareIcon } from '@heroicons/react/24/solid'; + +import type { PropsWithChildren } from 'react'; + +import { useQueryClient } from '@tanstack/react-query'; + +import { logout } from '@utils/login'; + +import { modalActions } from '@stores/layout/modalStore'; +import { + selectedCapacitiesFilterStore, + selectedCompaniesFilterStore, + selectedConnectorTypesFilterStore, +} from '@stores/station-filters/serverStationFiltersStore'; + +import ProfileMenu from '@ui/Navigator/ProfileMenu'; +import CarModal from '@ui/modal/CarModal/CarModal'; + +import { QUERY_KEY_STATION_MARKERS } from '@constants/queryKeys'; + +const PersonalMenu = () => { + const queryClient = useQueryClient(); + const { openModal } = modalActions; + + const personalMenus: PropsWithChildren<{ onClick: () => void }>[] = [ + { + children: ( + <> + 차량등록 + + ), + onClick: () => { + openModal(); + }, + }, + { + children: ( + <> + 로그아웃 + + ), + onClick: () => { + logout(); + + selectedCapacitiesFilterStore.setState(new Set([])); + selectedConnectorTypesFilterStore.setState(new Set([])); + selectedCompaniesFilterStore.setState(new Set([])); + + queryClient.invalidateQueries({ queryKey: [QUERY_KEY_STATION_MARKERS] }); + }, + }, + ]; + + return ; +}; +export default PersonalMenu; diff --git a/src/components/ui/Navigator/NavigationBar/hooks/useNavigationBar.ts b/src/components/ui/Navigator/NavigationBar/hooks/useNavigationBar.ts new file mode 100644 index 00000000..6dff2ca0 --- /dev/null +++ b/src/components/ui/Navigator/NavigationBar/hooks/useNavigationBar.ts @@ -0,0 +1,53 @@ +import type { ReactElement } from 'react'; + +import { useExternalState } from '@utils/external-state'; + +import { navigationBarPanelStore } from '@stores/layout/navigationBarPanelStore'; + +export const useNavigationBar = () => { + const [navigationBarPanel, setNavigationBarPanel] = useExternalState(navigationBarPanelStore); + + const openBasePanel = (basePanel: ReactElement) => { + setNavigationBarPanel((prev) => ({ + ...prev, + basePanel, + })); + }; + + const openLastPanel = (lastPanel: ReactElement) => { + setNavigationBarPanel((prev) => ({ + ...prev, + lastPanel, + })); + }; + + const closeBasePanel = () => { + setNavigationBarPanel((prev) => ({ + ...prev, + basePanel: null, + })); + }; + + const closeLastPanel = () => { + setNavigationBarPanel((prev) => ({ + ...prev, + lastPanel: null, + })); + }; + + const handleClosePanel = () => { + if (navigationBarPanel.lastPanel !== null) { + closeLastPanel(); + return; + } + closeBasePanel(); + }; + + return { + openBasePanel, + openLastPanel, + closeBasePanel, + closeLastPanel, + handleClosePanel, + }; +}; diff --git a/src/components/ui/Navigator/NavigationBar/index.tsx b/src/components/ui/Navigator/NavigationBar/index.tsx new file mode 100644 index 00000000..513a8818 --- /dev/null +++ b/src/components/ui/Navigator/NavigationBar/index.tsx @@ -0,0 +1,3 @@ +import NavigationBar from './NavigationBar'; + +export default NavigationBar; diff --git a/src/components/ui/Navigator/Navigator.test.tsx b/src/components/ui/Navigator/Navigator.test.tsx new file mode 100644 index 00000000..c816bd24 --- /dev/null +++ b/src/components/ui/Navigator/Navigator.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import { useCars } from '@hooks/tanstack-query/car/useCars'; + +import ModalContainer from '@ui/ModalContainer'; +import NavigationBar from '@ui/Navigator/NavigationBar'; + +import { EMPTY_MEMBER_TOKEN } from '@constants'; + +jest.mock('@hooks/tanstack-query/car/useCars'); + +const queryClient = new QueryClient(); + +describe('Navigator 컴포넌트 테스트', () => { + it('멤버 토큰이 없을 때 프로필 버튼을 누르면 로그인 모달이 렌더링 된다.', () => { + render( + + + + + ); + + fireEvent.click(screen.getByLabelText('로그인 하기')); + + expect(screen.getByText('구글 로그인')).toBeInTheDocument(); + }); + + it('로그아웃 버튼을 클릭하면 저장해두었던 멤버 토큰이 지워진다.', () => { + memberTokenStore.setState('test-member-token'); + + render( + + + + + ); + + fireEvent.click(screen.getByLabelText('내 정보 메뉴 열기')); + fireEvent.click(screen.getByText('로그아웃')); + + expect(memberTokenStore.getState()).toBe(EMPTY_MEMBER_TOKEN); + }); + + it('차량 등록 버튼을 클릭하면 차량 선택 모달이 렌더링 된다.', async () => { + (useCars as jest.Mock).mockReturnValue({ + data: [ + { + carId: 1, + name: 'dummy car1', + vintage: 'dummy vintage1', + }, + { + carId: 2, + name: 'dummy car2', + vintage: 'dummy vintage2', + }, + { + carId: 3, + name: 'dummy car3', + vintage: 'dummy vintage3', + }, + ], + isLoading: false, + }); + memberTokenStore.setState('test-member-token'); + + render( + + + + + ); + + fireEvent.click(screen.getByLabelText('내 정보 메뉴 열기')); + fireEvent.click(screen.getByText('차량등록')); + + expect(screen.getByText('차량 선택')).toBeInTheDocument(); + }); + + it('멤버 토큰이 있을 때 프로필 버튼을 누르면 프로필 메뉴가 렌더링 된다.', () => { + memberTokenStore.setState('test-member-token'); + + render( + + + + + ); + + fireEvent.click(screen.getByLabelText('내 정보 메뉴 열기')); + + expect(screen.getByText('차량등록')).toBeInTheDocument(); + expect(screen.getByText('로그아웃')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/Navigator/Navigator.tsx b/src/components/ui/Navigator/Navigator.tsx new file mode 100644 index 00000000..105fb068 --- /dev/null +++ b/src/components/ui/Navigator/Navigator.tsx @@ -0,0 +1,21 @@ +import { useExternalValue } from '@utils/external-state'; + +import { navigationBarPanelStore } from '@stores/layout/navigationBarPanelStore'; + +import NavigationBar from './NavigationBar'; + +const Navigator = () => { + const { basePanel, lastPanel } = useExternalValue(navigationBarPanelStore); + const canDisplayCloseButton = basePanel !== null || lastPanel !== null; + + return ( + + + + + + + ); +}; + +export default Navigator; diff --git a/src/components/ui/Navigator/ProfileMenu/Menus.tsx b/src/components/ui/Navigator/ProfileMenu/Menus.tsx new file mode 100644 index 00000000..ac891377 --- /dev/null +++ b/src/components/ui/Navigator/ProfileMenu/Menus.tsx @@ -0,0 +1,94 @@ +import { css } from 'styled-components'; + +import { useEffect } from 'react'; +import type { PropsWithChildren } from 'react'; + +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; + +import { MOBILE_BREAKPOINT } from '@constants'; + +interface Props { + menus: PropsWithChildren<{ onClick: () => void }>[]; + closeMenu: () => void; +} + +const Menus = ({ menus, closeMenu }: Props) => { + useEffect(() => { + document.body.addEventListener('click', closeMenu); + + return () => document.body.removeEventListener('click', closeMenu); + }, []); + + return ( + event.stopPropagation()} + > + {menus.map(({ children, onClick }, index) => ( + + { + onClick(); + closeMenu(); + }} + > + {children} + + + ))} + + ); +}; + +const containerCss = css` + position: relative; + + border: 2.4px solid #333; + + background: #fff; + + &::before { + content: ''; + width: 2rem; + height: 2rem; + background: none; + + position: absolute; + top: 2rem; + left: -1rem; + + border: 1px solid #333; + border-width: 10px; + border-style: solid; + border-color: transparent transparent transparent #333; + + transform: rotate(45deg); + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + top: auto; + bottom: -10px; + left: auto; + transform: rotate(-45deg); + } + } +`; + +const buttonCss = css` + display: flex; + align-items: center; + gap: 0.6rem; + + padding: 0.8rem 1rem; + font-size: 1.5rem; +`; + +export default Menus; diff --git a/src/components/ui/Navigator/ProfileMenu/ProfileMenu.stories.tsx b/src/components/ui/Navigator/ProfileMenu/ProfileMenu.stories.tsx new file mode 100644 index 00000000..4701a9b0 --- /dev/null +++ b/src/components/ui/Navigator/ProfileMenu/ProfileMenu.stories.tsx @@ -0,0 +1,64 @@ +import { ArrowRightOnRectangleIcon, PencilSquareIcon } from '@heroicons/react/24/outline'; +import type { Meta } from '@storybook/react'; +import { styled } from 'styled-components'; + +import Menus from './Menus'; +import ProfileMenu from './ProfileMenu'; + +const meta = { + title: 'UI/ProfileMenu', + component: Menus, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +export const Default = () => { + return ( + + 차량등록 + + ), + onClick: () => alert('차량등록'), + }, + { + children: ( + <> + 로그아웃 + + ), + onClick: () => alert('로그아웃'), + }, + ]} + /> + ); +}; + +export const BigTrigger = () => { + return ( + + 차량등록 + + ), + onClick: () => alert('차량등록'), + }, + { + children: ( + <> + 로그아웃 + + ), + onClick: () => alert('로그아웃'), + }, + ]} + /> + ); +}; diff --git a/src/components/ui/Navigator/ProfileMenu/ProfileMenu.tsx b/src/components/ui/Navigator/ProfileMenu/ProfileMenu.tsx new file mode 100644 index 00000000..1238f1cd --- /dev/null +++ b/src/components/ui/Navigator/ProfileMenu/ProfileMenu.tsx @@ -0,0 +1,62 @@ +import { UserCircleIcon } from '@heroicons/react/24/outline'; +import { css } from 'styled-components'; + +import type { PropsWithChildren } from 'react'; + +import { useExternalState } from '@utils/external-state'; + +import { profileMenuOpenStore } from '@stores/profileMenuOpenStore'; + +import Button from '@common/Button'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import { MOBILE_BREAKPOINT } from '@constants'; + +import Menus from './Menus'; + +interface Props { + menus: PropsWithChildren<{ onClick: () => void }>[]; +} + +const ProfileMenu = ({ menus }: Props) => { + const [isOpen, setIsOpen] = useExternalState(profileMenuOpenStore); + + const closeProfileMenu = () => { + setIsOpen(false); + }; + + const toggleOpenProfileMenu = () => { + setIsOpen((prev) => !prev); + }; + + return ( + event.stopPropagation()}> + {isOpen && } + + + ); +}; + +const container = css` + position: relative; + display: inline-block; + + & > ul:first-child { + left: calc(100% + 20px); + position: absolute; + top: -18px; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + top: -96px; + left: -43px; + } + } +`; + +export default ProfileMenu; diff --git a/src/components/ui/Navigator/ProfileMenu/index.tsx b/src/components/ui/Navigator/ProfileMenu/index.tsx new file mode 100644 index 00000000..ade408b8 --- /dev/null +++ b/src/components/ui/Navigator/ProfileMenu/index.tsx @@ -0,0 +1,3 @@ +import ProfileMenu from './ProfileMenu'; + +export default ProfileMenu; diff --git a/src/components/ui/Navigator/index.tsx b/src/components/ui/Navigator/index.tsx new file mode 100644 index 00000000..ac08b8cf --- /dev/null +++ b/src/components/ui/Navigator/index.tsx @@ -0,0 +1,3 @@ +import Navigator from './Navigator'; + +export default Navigator; diff --git a/src/components/ui/ServerStationFilters/FilterOption.tsx b/src/components/ui/ServerStationFilters/FilterOption.tsx new file mode 100644 index 00000000..1b1fddc4 --- /dev/null +++ b/src/components/ui/ServerStationFilters/FilterOption.tsx @@ -0,0 +1,59 @@ +import { css } from 'styled-components'; + +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import { MOBILE_BREAKPOINT } from '@constants'; + +import type { Capacity, ConnectorTypeName } from '@type/chargers'; +import type { CapaCityBigDecimal, CompanyKey, ConnectorTypeKey } from '@type/serverStationFilter'; +import type { CompanyName } from '@type/stations'; + +interface FilterSectionProps { + title: string; + filterOptionNames: ConnectorTypeName[] | Capacity[] | CompanyName[]; + filterOptionValues: ConnectorTypeKey[] | CapaCityBigDecimal[] | CompanyKey[]; + toggleSelectFilter: (filter: ConnectorTypeKey | CapaCityBigDecimal | CompanyKey) => void; + getIsFilterSelected: (filter: ConnectorTypeKey | CapaCityBigDecimal | CompanyKey) => boolean; +} + +const FilterSection = ({ + title, + filterOptionNames, + filterOptionValues, + toggleSelectFilter, + getIsFilterSelected, +}: FilterSectionProps) => { + return ( + + + + {title} + + 중복선택 가능 + + + {filterOptionNames.map((filterOption, index) => ( + toggleSelectFilter(filterOptionValues[index])} + pill + > + {filterOption} + + ))} + + + ); +}; + +const containerCss = css` + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + width: calc(100vw - 6rem); + } +`; + +export default FilterSection; diff --git a/src/components/ui/ServerStationFilters/FilterOptionSkeleton.tsx b/src/components/ui/ServerStationFilters/FilterOptionSkeleton.tsx new file mode 100644 index 00000000..8d179e28 --- /dev/null +++ b/src/components/ui/ServerStationFilters/FilterOptionSkeleton.tsx @@ -0,0 +1,35 @@ +import { css } from 'styled-components'; + +import { generateRandomData } from '@utils/randomDataGenerator'; + +import FlexBox from '@common/FlexBox'; +import Skeleton from '@common/Skeleton'; + +const FilterOptionSkeleton = () => { + return ( + <> + + + + + + + {Array.from({ length: generateRandomData([10, 15, 20]) }, (_, index) => ( + + ))} + + + + ); +}; +export default FilterOptionSkeleton; diff --git a/src/components/ui/ServerStationFilters/ServerStationFilters.test.tsx b/src/components/ui/ServerStationFilters/ServerStationFilters.test.tsx new file mode 100644 index 00000000..78643e14 --- /dev/null +++ b/src/components/ui/ServerStationFilters/ServerStationFilters.test.tsx @@ -0,0 +1,138 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { + selectedCapacitiesFilterStore, + selectedCompaniesFilterStore, + selectedConnectorTypesFilterStore, + serverStationFilterAction, +} from '@stores/station-filters/serverStationFiltersStore'; + +import { useServerStationFilters } from '@hooks/tanstack-query/station-filters/useServerStationFilters'; +import useMediaQueries from '@hooks/useMediaQueries'; + +import type { StationFilters } from '@type'; +import type { CompanyKey, ConnectorTypeKey } from '@type/serverStationFilter'; + +import { CAPACITIES, COMPANIES, CONNECTOR_TYPES } from './../../../constants/chargers'; +import ServerStationFilters from './ServerStationFilters'; + +const queryClient = new QueryClient(); + +jest.mock('@hooks/tanstack-query/station-filters/useServerStationFilters'); +jest.mock('@hooks/useMediaQueries'); + +const MOCK_CAPACITIES = ['100.00', '200.00', '3.00']; +const MOCK_COMPANIES: CompanyKey[] = ['AM', 'BA', 'BG', 'BK']; +const MOCK_CONNECTOR_TYPES: ConnectorTypeKey[] = ['AC_3PHASE', 'AC_SLOW', 'DC_COMBO']; + +describe('ServerStationFilters 컴포넌트 테스트', () => { + beforeEach(() => { + serverStationFilterAction.deleteAllServerStationFilters(); + (useMediaQueries as jest.Mock).mockReturnValue({ + screen: { + get: () => true, + }, + }); + }); + + it('ServerStationFilters 컴포넌트가 열리면 useServerStationFilters 훅에서 받아온 정보를 화면에 렌더링 한다.', () => { + (useServerStationFilters as jest.Mock).mockReturnValue({ + data: { + capacities: MOCK_CAPACITIES, + companies: MOCK_COMPANIES, + connectorTypes: MOCK_CONNECTOR_TYPES, + } as StationFilters, + isLoading: false, + }); + + render( + + + + ); + + MOCK_COMPANIES.forEach((companyKey) => { + expect(screen.getByText(COMPANIES[companyKey])).toBeInTheDocument(); + }); + MOCK_CONNECTOR_TYPES.forEach((connectorTypeKey) => { + expect(screen.getByText(CONNECTOR_TYPES[connectorTypeKey])).toBeInTheDocument(); + }); + + expect(screen.getByText(CAPACITIES[3])).toBeInTheDocument(); + expect(screen.getByText(CAPACITIES[0])).toBeInTheDocument(); + expect(screen.getByText(CAPACITIES[4])).toBeInTheDocument(); + }); + + it('필터 옵션들을 선택하고 적용하기 버튼을 누르면 선택한 옵션들이 request param으로 추가되어 /stations에 요청이 발생한다.', () => { + (useServerStationFilters as jest.Mock).mockReturnValue({ + data: { + capacities: MOCK_CAPACITIES, + companies: MOCK_COMPANIES, + connectorTypes: MOCK_CONNECTOR_TYPES, + } as StationFilters, + isLoading: false, + }); + + render( + + + + ); + + fireEvent.click(screen.getByText(COMPANIES['AM'])); + fireEvent.click(screen.getByText(COMPANIES['BA'])); + + fireEvent.click(screen.getByText(CAPACITIES[0])); + + fireEvent.click(screen.getByText(CONNECTOR_TYPES['AC_3PHASE'])); + + const selectedCapacities = selectedCapacitiesFilterStore.getState(); + const selectedCompanies = selectedCompaniesFilterStore.getState(); + const selectedConnectorTypes = selectedConnectorTypesFilterStore.getState(); + + expect(selectedCompanies.has('AM')).toBeTruthy(); + expect(selectedCompanies.has('BA')).toBeTruthy(); + + expect(selectedCapacities.has('3.00')).toBeTruthy(); + + expect(selectedConnectorTypes.has('AC_3PHASE')).toBeTruthy(); + }); + + it('필터 초기화 버튼을 클릭하면 차량 필터를 제외한 모든 필터 옵션들이 해제된다.', () => { + (useServerStationFilters as jest.Mock).mockReturnValue({ + data: { + capacities: MOCK_CAPACITIES, + companies: MOCK_COMPANIES, + connectorTypes: MOCK_CONNECTOR_TYPES, + } as StationFilters, + isLoading: false, + }); + + render( + + + + ); + + fireEvent.click(screen.getByText(CAPACITIES[0])); + + fireEvent.click(screen.getByText(COMPANIES['AM'])); + fireEvent.click(screen.getByText(COMPANIES['BA'])); + + fireEvent.click(screen.getByText(CONNECTOR_TYPES['AC_3PHASE'])); + + // 유저가 클릭한 만큼 전역 상태에 추가되었는지 먼저 확인 + expect(selectedCapacitiesFilterStore.getState().size === 1).toBeTruthy(); + expect(selectedCompaniesFilterStore.getState().size === 2).toBeTruthy(); + expect(selectedConnectorTypesFilterStore.getState().size === 1).toBeTruthy(); + + fireEvent.click(screen.getByLabelText('모든 필터 해제')); + + // 모든 필터 해제 버튼을 클릭했을시 전역 상태의 모든 필터 옵션이 지워졌는지 확인 + expect(selectedCapacitiesFilterStore.getState().size === 0).toBeTruthy(); + expect(selectedCompaniesFilterStore.getState().size === 0).toBeTruthy(); + expect(selectedConnectorTypesFilterStore.getState().size === 0).toBeTruthy(); + }); +}); diff --git a/src/components/ui/ServerStationFilters/ServerStationFilters.tsx b/src/components/ui/ServerStationFilters/ServerStationFilters.tsx new file mode 100644 index 00000000..d278e47c --- /dev/null +++ b/src/components/ui/ServerStationFilters/ServerStationFilters.tsx @@ -0,0 +1,182 @@ +import { ArrowPathIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { css } from 'styled-components'; + +import { memberTokenStore } from '@stores/login/memberTokenStore'; +import { serverStationFilterAction } from '@stores/station-filters/serverStationFiltersStore'; + +import { useCarFilters } from '@hooks/tanstack-query/station-filters/useCarFilters'; +import { useServerStationFilters } from '@hooks/tanstack-query/station-filters/useServerStationFilters'; +import { useServerStationFilterStoreActions } from '@hooks/useServerStationFilterActions'; + +import Button from '@common/Button'; +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import { useNavigationBar } from '@ui/Navigator/NavigationBar/hooks/useNavigationBar'; +import ServerStationFiltersSkeleton from '@ui/ServerStationFilters/ServerStationFiltersSkeleton'; + +import { getHoverColor } from '@style'; + +import { EMPTY_MEMBER_TOKEN, MOBILE_BREAKPOINT, NAVIGATOR_PANEL_WIDTH } from '@constants'; + +import type { Capacity } from '@type'; + +import { COMPANIES, CONNECTOR_TYPES } from '../../../constants/chargers'; +import FilterSection from './FilterOption'; +import { useStationFilters } from './hooks/useStationFilters'; + +const ServerStationFilters = () => { + const { data: serverStationFilters, isLoading } = useServerStationFilters(); + const { data: carFilters } = useCarFilters(); + const { setAllServerStationFilters } = serverStationFilterAction; + const { closeBasePanel } = useNavigationBar(); + const { handleStationsRefetch, submitMemberFilters } = useStationFilters(); + const { + toggleCapacityFilter, + toggleConnectorTypeFilter, + toggleCompanyFilter, + getIsCapacitySelected, + getIsConnectorTypeSelected, + getIsCompanySelected, + resetAllFilters, + } = useServerStationFilterStoreActions(); + + if (isLoading) { + return ; + } + + const { connectorTypes, capacities, companies } = serverStationFilters; + + const handleApplySelectedFilters = async () => { + const isLoggedOutUser = memberTokenStore.getState() === EMPTY_MEMBER_TOKEN; + + if (isLoggedOutUser) { + handleStationsRefetch(); + return; + } + + setAllServerStationFilters(carFilters); + submitMemberFilters(); + }; + + return ( + + + + + + 필터 + + + + + CONNECTOR_TYPES[connectorType])} + filterOptionValues={connectorTypes} + toggleSelectFilter={toggleConnectorTypeFilter} + getIsFilterSelected={getIsConnectorTypeSelected} + /> + Number(capacity))] as Capacity[]} + filterOptionValues={[...capacities]} + toggleSelectFilter={toggleCapacityFilter} + getIsFilterSelected={getIsCapacitySelected} + /> + COMPANIES[companyKey])} + filterOptionValues={[...companies]} + toggleSelectFilter={toggleCompanyFilter} + getIsFilterSelected={getIsCompanySelected} + /> + + + ); +}; + +export const containerCss = css` + width: ${NAVIGATOR_PANEL_WIDTH}rem; + height: 100vh; + align-items: center; + flex-direction: column; + background-color: #fff; + + z-index: 99; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + width: 100vw; + border-radius: 0; + } +`; + +export const filterContainerCss = css` + width: ${NAVIGATOR_PANEL_WIDTH}; + height: 8rem; + justify-content: space-between; + align-items: center; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + width: calc(100vw - 2rem); + } +`; + +const paddingBottomCss = css` + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + padding-bottom: 12rem; + } +`; + +export const overFlowCss = css` + overflow-y: scroll; + overflow-x: hidden; + + &::-webkit-scrollbar { + display: none; + } +`; + +export const borderCss = css` + outline: 1.5px solid #e1e4eb; +`; + +export const buttonCss = css` + width: 100%; + height: 6rem; + + position: sticky; + bottom: 0; + + flex-shrink: 0; + + color: #fff; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + position: fixed; + } +`; + +export const filterHeaderCss = css` + position: sticky; + top: 0; + background-color: #fff; + flex-shrink: 0; + padding: 0 2rem; +`; + +export default ServerStationFilters; diff --git a/src/components/ui/ServerStationFilters/ServerStationFiltersSkeleton.tsx b/src/components/ui/ServerStationFilters/ServerStationFiltersSkeleton.tsx new file mode 100644 index 00000000..5a09e99d --- /dev/null +++ b/src/components/ui/ServerStationFilters/ServerStationFiltersSkeleton.tsx @@ -0,0 +1,42 @@ +import FlexBox from '@common/FlexBox'; +import Skeleton from '@common/Skeleton'; + +import FilterOptionSkeleton from '@ui/ServerStationFilters/FilterOptionSkeleton'; +import { + borderCss, + containerCss, + filterHeaderCss, + overFlowCss, +} from '@ui/ServerStationFilters/ServerStationFilters'; + +const ServerStationFiltersSkeleton = () => { + return ( + + + + + + + + + + + ); +}; + +export default ServerStationFiltersSkeleton; diff --git a/src/components/ui/ServerStationFilters/hooks/useStationFilters.ts b/src/components/ui/ServerStationFilters/hooks/useStationFilters.ts new file mode 100644 index 00000000..216a2b95 --- /dev/null +++ b/src/components/ui/ServerStationFilters/hooks/useStationFilters.ts @@ -0,0 +1,76 @@ +import { useQueryClient } from '@tanstack/react-query'; + +import { fetchUtils } from '@utils/fetch'; + +import { toastActions } from '@stores/layout/toastStore'; +import { memberInfoStore } from '@stores/login/memberInfoStore'; +import { serverStationFilterAction } from '@stores/station-filters/serverStationFiltersStore'; + +import useMediaQueries from '@hooks/useMediaQueries'; + +import { useNavigationBar } from '@ui/Navigator/NavigationBar/hooks/useNavigationBar'; + +import { QUERY_KEY_MEMBER_SELECTED_FILTERS, QUERY_KEY_STATION_MARKERS } from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +import type { StationFilters } from '@type'; + +export const useStationFilters = () => { + const queryClient = useQueryClient(); + const { closeBasePanel } = useNavigationBar(); + const { showToast } = toastActions; + const screen = useMediaQueries(); + + const fallbackToPreviousFilters = () => { + const { resetAllServerStationFilters } = serverStationFilterAction; + const stationFilters = queryClient.getQueryData([ + QUERY_KEY_MEMBER_SELECTED_FILTERS, + ]); + resetAllServerStationFilters(stationFilters); + + queryClient.invalidateQueries({ queryKey: [QUERY_KEY_STATION_MARKERS] }); + + showToast('필터 적용에 실패했습니다', 'error'); + }; + + const handleStationsRefetch = () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY_STATION_MARKERS] }); + if (screen.get('isMobile')) { + closeBasePanel(); + } + + showToast('필터가 적용되었습니다'); + }; + + const applyMemberFilters = async () => { + const memberId = memberInfoStore.getState()?.memberId; + + const { getMemberFilterRequestBody } = serverStationFilterAction; + const memberFilterRequestBody = getMemberFilterRequestBody(); + + return await fetchUtils.post( + `${SERVER_URL}/members/${memberId}/filters`, + memberFilterRequestBody + ); + }; + + const submitMemberFilters = async () => { + const { setAllServerStationFilters } = serverStationFilterAction; + + try { + const stationFilters = await applyMemberFilters(); + + setAllServerStationFilters(stationFilters); + handleStationsRefetch(); + } catch { + fallbackToPreviousFilters(); + } + }; + + return { + fallbackToPreviousFilters, + handleStationsRefetch, + applyMemberFilters, + submitMemberFilters, + }; +}; diff --git a/src/components/ui/ServerStationFilters/index.ts b/src/components/ui/ServerStationFilters/index.ts new file mode 100644 index 00000000..61fe140a --- /dev/null +++ b/src/components/ui/ServerStationFilters/index.ts @@ -0,0 +1,3 @@ +import ServerStationFilters from './ServerStationFilters'; + +export default ServerStationFilters; diff --git a/src/components/ui/ShowHideButton.tsx b/src/components/ui/ShowHideButton.tsx new file mode 100644 index 00000000..8b5b602c --- /dev/null +++ b/src/components/ui/ShowHideButton.tsx @@ -0,0 +1,37 @@ +import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline'; +import { css } from 'styled-components'; + +import Button from '@common/Button'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +interface Props { + name?: '더보기' | '닫기'; + onClick: () => void; +} +/** + * + * @param name [기본값] 더보기 + * @param direction [기본값] DOWN + * @returns 더보기 버튼 | 닫기 버튼 + */ +const ShowHideButton = ({ name = '더보기', onClick }: Props) => { + return ( + + ); +}; + +const buttonContainer = css` + width: 100%; + + & svg { + padding-top: 2px; + } +`; + +export default ShowHideButton; diff --git a/src/components/ui/Star/Star.stories.tsx b/src/components/ui/Star/Star.stories.tsx new file mode 100644 index 00000000..4abb0780 --- /dev/null +++ b/src/components/ui/Star/Star.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta } from '@storybook/react'; + +import { useState } from 'react'; + +import Text from '../../common/Text'; +import type { StarProps } from './Star'; +import Star from './Star'; + +const meta = { + title: 'UI/Star', + component: Star, + tags: ['autodocs'], + args: { + isSelected: false, + onClick: () => { + alert('제가 눌렸어요!!!'); + }, + size: 'md', + }, + argTypes: { + isSelected: { + description: '별에 불을 들어오게 하는 역할을 합니다.', + }, + onClick: { + description: '눌렀을 때 반응하도록 할 수 있습니다.', + }, + size: { + description: '크기를 지정할 수 있습니다.', + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = (args: StarProps) => { + return ; +}; + +export const Controllable = () => { + const [isSelected, setIsSelected] = useState(false); + return ( +
+ 현재 상태: {isSelected ? '반짝반짝 작은별' : '죽은별'} + setIsSelected(!isSelected)} /> +
+ ); +}; +export const Sizes = () => { + const [isSelected, setIsSelected] = useState(false); + return ( +
+ setIsSelected(!isSelected)} size="xs" /> + setIsSelected(!isSelected)} size="sm" /> + setIsSelected(!isSelected)} size="md" /> + setIsSelected(!isSelected)} size="lg" /> + setIsSelected(!isSelected)} size="xl" /> + setIsSelected(!isSelected)} size="xxl" /> +
+ ); +}; diff --git a/src/components/ui/Star/Star.tsx b/src/components/ui/Star/Star.tsx new file mode 100644 index 00000000..7c677a12 --- /dev/null +++ b/src/components/ui/Star/Star.tsx @@ -0,0 +1,63 @@ +import { StarIcon } from '@heroicons/react/24/solid'; +import styled, { css } from 'styled-components'; + +import type { Size } from '@type'; + +export interface StarProps { + isSelected: boolean; + onClick: () => void; + size?: Size; +} + +const getSize = (size: Size) => { + switch (size) { + case 'xs': + return css` + width: 1.2rem; + height: 1.2rem; + `; + case 'sm': + return css` + width: 1.8rem; + height: 1.8rem; + `; + case 'md': + return css` + width: 2.4rem; + height: 2.4rem; + `; + case 'lg': + return css` + width: 3rem; + height: 3rem; + `; + case 'xl': + return css` + width: 3.6rem; + height: 3.6rem; + `; + case 'xxl': + return css` + width: 4.2rem; + height: 4.2rem; + `; + default: + return css` + width: 2.4rem; + height: 2.4rem; + `; + } +}; + +const StyledStarIcon = styled(StarIcon)` + ${({ size }) => getSize(size)}; + + cursor: pointer; + color: ${({ isSelected }) => (isSelected ? '#FFD700' : '#CCC')}; +`; + +const Star = ({ isSelected, onClick, size }: StarProps) => { + return ; +}; + +export default Star; diff --git a/src/components/ui/Star/index.ts b/src/components/ui/Star/index.ts new file mode 100644 index 00000000..e2a0f6a2 --- /dev/null +++ b/src/components/ui/Star/index.ts @@ -0,0 +1,3 @@ +import Star from './Star'; + +export default Star; diff --git a/src/components/ui/StarRatings/StarRatings.stories.tsx b/src/components/ui/StarRatings/StarRatings.stories.tsx new file mode 100644 index 00000000..73f30405 --- /dev/null +++ b/src/components/ui/StarRatings/StarRatings.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta } from '@storybook/react'; + +import { useState } from 'react'; + +import Text from '../../common/Text'; +import type { StarRatingsProps } from './StarRatings'; +import StarRatings from './index'; + +const meta = { + title: 'UI/StarRatings', + component: StarRatings, + tags: ['autodocs'], + args: { + stars: 3, + setStars: () => { + alert('제가 눌렸어요!!!'); + }, + size: 'md', + }, + argTypes: { + stars: { + description: '0~5의 숫자를 줄 수 있습니다. ', + }, + setStars: { + description: '별의 갯수롤 조절할 수 있습니다.', + }, + size: { + description: '사이즈를 조절할 수 있습니다.', + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = (args: StarRatingsProps) => { + return ; +}; + +export const Controllable = () => { + const [stars, setStars] = useState(0); + return ( +
+ Rating: {stars} + +
+ ); +}; +export const Sizes = () => { + const [stars, setStars] = useState(0); + return ( +
+ Rating: {stars} + + + + + + +
+ ); +}; diff --git a/src/components/ui/StarRatings/StarRatings.tsx b/src/components/ui/StarRatings/StarRatings.tsx new file mode 100644 index 00000000..e069f5c0 --- /dev/null +++ b/src/components/ui/StarRatings/StarRatings.tsx @@ -0,0 +1,37 @@ +import styled from 'styled-components'; + +import Star from '@ui/Star'; + +import type { Size } from '@type'; + +export interface StarRatingsProps { + stars: number; + setStars: (newStar: number) => void; + size?: Size; +} + +const StarRatings = ({ stars, setStars, size }: StarRatingsProps) => { + const handleStarClick = (selectedStars: number) => { + setStars(selectedStars); + }; + + return ( + + {Array.from({ length: 5 }, (_, index) => ( + handleStarClick(index + 1)} + size={size} + /> + ))} + + ); +}; + +const StarContainer = styled.div` + display: flex; + align-items: center; +`; + +export default StarRatings; diff --git a/src/components/ui/StarRatings/index.tsx b/src/components/ui/StarRatings/index.tsx new file mode 100644 index 00000000..fd16abac --- /dev/null +++ b/src/components/ui/StarRatings/index.tsx @@ -0,0 +1,3 @@ +import StarRatings from './StarRatings'; + +export default StarRatings; diff --git a/src/components/ui/StationDetailsWindow/StationDetailsView.stories.tsx b/src/components/ui/StationDetailsWindow/StationDetailsView.stories.tsx new file mode 100644 index 00000000..9cd4ee66 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/StationDetailsView.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta } from '@storybook/react'; +import { css } from 'styled-components'; + +import Box from '../../common/Box'; +import FlexBox from '../../common/FlexBox'; +import type { StationDetailsViewProps } from './StationDetailsView'; +import StationDetailsView from './StationDetailsView'; +import StationDetailsViewSkeleton from './StationDetailsViewSkeleton'; +import CongestionBarContainerSkeleton from './congestion/CongestionBarContainerSkeleton'; + +const meta = { + title: 'UI/StationDetailsView', + component: StationDetailsView, + tags: ['autodocs'], + args: { + station: { + stationId: '99', + stationName: '박스터 충전소', + companyName: 'CARffeine', + contact: '02-1234-5678', + chargers: [ + { + type: 'DC_AC_3PHASE', + price: 200, + capacity: 3, + latestUpdateTime: '2023-07-18T15:11:40.000Z', + state: 'STANDBY', + method: '단독', + }, + { + type: 'DC_COMBO', + price: 300, + capacity: 200, + latestUpdateTime: '2023-07-30T03:21:40.000Z', + state: 'UNDER_INSPECTION', + method: '단독', + }, + { + type: 'DC_AC_3PHASE', + price: 350, + capacity: 50, + latestUpdateTime: '2023-07-01T03:21:40.000Z', + state: 'CHARGING_IN_PROGRESS', + method: '동시', + }, + { + type: 'AC_SLOW', + price: 350, + capacity: 3, + latestUpdateTime: '2023-07-01T03:21:40.000Z', + state: 'CHARGING_IN_PROGRESS', + method: '동시', + }, + { + type: 'DC_FAST', + price: 450, + capacity: 100, + latestUpdateTime: '2023-07-01T03:21:40.000Z', + state: 'STATUS_UNKNOWN', + method: '동시', + }, + ], + isParkingFree: true, + operatingTime: '평일 09:00~19:00 / 주말 미운영', + address: '서울 송파구 올림픽로35다길 42', + detailLocation: '지하 1층 구석탱이 어딘가', + latitude: 37.599295930415195, + longitude: 127.45404683387704, + isPrivate: true, + stationState: '2023-08-04일부터 충전소 공사합니다.', + privateReason: '박스터 차주만 충전 가능함', + reportCount: 1, + }, + }, + argTypes: { + station: { + description: '충전소 데이터를 수정할 수 있습니다.', + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = (args: StationDetailsViewProps) => { + return ; +}; + +export const Skeleton = (args: StationDetailsViewProps) => { + return ( + + + + + ); +}; + +export const StatisticsSkeleton = () => { + return ( + + + + ); +}; + +const containerCss = css` + width: 36rem; + height: 100vh; + background-color: white; + box-shadow: 1px 1px 2px gray; + border-left: 0.5px solid #e1e4eb; + border-right: 0.5px solid #e1e4eb; + padding: 2rem; +`; diff --git a/src/components/ui/StationDetailsWindow/StationDetailsView.tsx b/src/components/ui/StationDetailsWindow/StationDetailsView.tsx new file mode 100644 index 00000000..8cfd6066 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/StationDetailsView.tsx @@ -0,0 +1,78 @@ +import { XMarkIcon } from '@heroicons/react/24/outline'; +import { css } from 'styled-components'; + +import { navigationBarPanelStore } from '@stores/layout/navigationBarPanelStore'; + +import Box from '@common/Box'; +import Button from '@common/Button'; + +import ChargerList from '@ui/StationDetailsWindow/chargers/ChargerList'; +import StationReportButton from '@ui/StationDetailsWindow/reports/station/StationReportButton'; +import ReviewPreview from '@ui/StationDetailsWindow/reviews/previews/ReviewPreview'; +import StationInformation from '@ui/StationDetailsWindow/station/StationInformation'; + +import { MOBILE_BREAKPOINT, NAVIGATOR_PANEL_WIDTH } from '@constants'; + +import type { StationDetails } from '@type'; + +import CongestionStatistics from './congestion/CongestionStatistics'; + +export interface StationDetailsViewProps { + station: StationDetails; +} + +const StationDetailsView = ({ station }: StationDetailsViewProps) => { + const { stationId, chargers, reportCount } = station; + + const handleCloseDetail = () => { + navigationBarPanelStore.setState((panels) => ({ + ...panels, + lastPanel: null, + })); + }; + + return ( + + + + + + + + + + + + ); +}; + +export const stationDetailsViewContainerCss = css` + width: ${NAVIGATOR_PANEL_WIDTH}rem; + height: 100vh; + background-color: white; + box-shadow: 1px 1px 2px gray; + border-left: 0.5px solid #e1e4eb; + border-right: 0.5px solid #e1e4eb; + overflow: scroll; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + width: 100vw; + padding-bottom: 10rem; + } +`; + +const xIconCss = css` + @media screen and (min-width: ${MOBILE_BREAKPOINT}px) { + display: none; + } + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + position: absolute; + right: 1rem; + top: 1rem; + } +`; + +export default StationDetailsView; diff --git a/src/components/ui/StationDetailsWindow/StationDetailsViewSkeleton.tsx b/src/components/ui/StationDetailsWindow/StationDetailsViewSkeleton.tsx new file mode 100644 index 00000000..594ff34d --- /dev/null +++ b/src/components/ui/StationDetailsWindow/StationDetailsViewSkeleton.tsx @@ -0,0 +1,25 @@ +import Box from '@common/Box'; +import FlexBox from '@common/FlexBox'; +import Skeleton from '@common/Skeleton'; + +import { stationDetailsViewContainerCss } from '@ui/StationDetailsWindow/StationDetailsView'; +import ChargerCardSkeleton from '@ui/StationDetailsWindow/chargers/ChargerCardSkeleton'; +import StationInformationSkeleton from '@ui/StationDetailsWindow/station/StationInformationSkeleton'; + +const StationDetailsViewSkeleton = () => { + return ( + + + + + + + {Array.from({ length: 10 }, (_, index) => ( + + ))} + + + ); +}; + +export default StationDetailsViewSkeleton; diff --git a/src/components/ui/StationDetailsWindow/StationDetailsWindow.tsx b/src/components/ui/StationDetailsWindow/StationDetailsWindow.tsx new file mode 100644 index 00000000..d1408674 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/StationDetailsWindow.tsx @@ -0,0 +1,63 @@ +import { css } from 'styled-components'; + +import { lazy, Suspense } from 'react'; + +import { useStationDetails } from '@hooks/tanstack-query/station-details/useStationDetails'; + +import Box from '@common/Box'; + +import { useNavigationBar } from '@ui/Navigator/NavigationBar/hooks/useNavigationBar'; +import StationDetailsViewSkeleton from '@ui/StationDetailsWindow/StationDetailsViewSkeleton'; + +import { MOBILE_BREAKPOINT, NAVIGATOR_PANEL_WIDTH } from '@constants'; + +export interface Props { + stationId: string; +} + +const StationDetailsView = lazy(() => import('@ui/StationDetailsWindow/StationDetailsView')); + +const StationDetailSWindowSkeleton = () => ( + + + +); + +const StationDetailsWindow = ({ stationId }: Props) => { + const { + data: selectedStation, + isLoading: isSelectedStationLoading, + isError: isSelectedStationError, + isFetching, + } = useStationDetails(stationId); + const { handleClosePanel } = useNavigationBar(); + + if (stationId === null || (!isFetching && selectedStation === null)) { + handleClosePanel(); + } + + if (isSelectedStationError || isSelectedStationLoading) { + return ; + } + + return ( + + }> + + + + ); +}; + +const stationDetailsWindowCss = css` + width: ${NAVIGATOR_PANEL_WIDTH}rem; + height: 100vh; + z-index: 99; + overflow: scroll; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + width: 100vw; + } +`; + +export default StationDetailsWindow; diff --git a/src/components/ui/StationDetailsWindow/chargers/ChargerCard.stories.tsx b/src/components/ui/StationDetailsWindow/chargers/ChargerCard.stories.tsx new file mode 100644 index 00000000..e95bc188 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/chargers/ChargerCard.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta } from '@storybook/react'; + +import type { ChargerCardProps } from './ChargerCard'; +import ChargerCard from './ChargerCard'; + +const meta = { + title: 'UI/ChargerCard', + component: ChargerCard, + tags: ['autodocs'], + args: { + charger: { + type: 'DC_AC_3PHASE', + price: 200, + capacity: 3, + latestUpdateTime: '2023-07-18T15:11:40.000Z', + state: 'STANDBY', + method: '단독', + }, + }, + argTypes: { + charger: { + description: '충전기 데이터를 수정할 수 있습니다.', + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = (args: ChargerCardProps) => { + return ; +}; diff --git a/src/components/ui/StationDetailsWindow/chargers/ChargerCard.test.tsx b/src/components/ui/StationDetailsWindow/chargers/ChargerCard.test.tsx new file mode 100644 index 00000000..62600142 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/chargers/ChargerCard.test.tsx @@ -0,0 +1,49 @@ +import { calculateLatestUpdateTime } from '@utils/index'; + +describe('calculateLatestUpdateTime test', () => { + it('1분 미만의 시간 차이일 때 "방금 전"을 표시합니다', () => { + const currentDateTime = new Date(); + const latestUpdatedDateTime = new Date(currentDateTime); + latestUpdatedDateTime.setSeconds(currentDateTime.getSeconds() - 30); + + expect(calculateLatestUpdateTime(latestUpdatedDateTime.toISOString())).toBe('방금 전'); + }); + + it('1시간 미만의 시간 차이일 때 "n분 전"을 표시합니다', () => { + const currentDateTime = new Date(); + const latestUpdatedDateTime = new Date(currentDateTime); + latestUpdatedDateTime.setMinutes(currentDateTime.getMinutes() - 45); + + expect(calculateLatestUpdateTime(latestUpdatedDateTime.toISOString())).toBe('45분 전'); + }); + + it('1일 미만의 시간 차이일 때 "n시간 전"을 표시합니다', () => { + const currentDateTime = new Date(); + const latestUpdatedDateTime = new Date(currentDateTime); + latestUpdatedDateTime.setHours(currentDateTime.getHours() - 12); + + expect(calculateLatestUpdateTime(latestUpdatedDateTime.toISOString())).toBe('12시간 전'); + }); + + it('1일 이상의 시간 차이일 때 "n일 전"을 표시합니다', () => { + const currentDateTime = new Date(); + const latestUpdatedDateTime = new Date(currentDateTime); + latestUpdatedDateTime.setDate(currentDateTime.getDate() - 3); + + expect(calculateLatestUpdateTime(latestUpdatedDateTime.toISOString())).toBe('3일 전'); + }); + + // it('충전기 상태 메시지를 표시합니다', () => { + // const charger: ChargerDetails = { + // capacity: undefined, + // latestUpdateTime: '', + // method: undefined, + // price: 0, + // state: 'COMMUNICATION_ERROR', + // type: undefined, + // }; + // render(); + // const chargerCard = screen.getByText('마지막 통신'); + // expect(chargerCard).toBeInTheDocument(); + // }); +}); diff --git a/src/components/ui/StationDetailsWindow/chargers/ChargerCard.tsx b/src/components/ui/StationDetailsWindow/chargers/ChargerCard.tsx new file mode 100644 index 00000000..d3e25e69 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/chargers/ChargerCard.tsx @@ -0,0 +1,91 @@ +import styled, { css } from 'styled-components'; + +import { calculateLatestUpdateTime } from '@utils/index'; + +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import { + CHARGER_STATES, + CONNECTOR_TYPES, + QUICK_CHARGER_CAPACITY_THRESHOLD, +} from '@constants/chargers'; + +import type { ChargerDetails, ChargerStateType } from '@type/chargers'; + +export interface ChargerCardProps { + charger: ChargerDetails; +} + +const statusHeavyColor = (state: ChargerStateType) => { + switch (state) { + case 'STANDBY': + return '#052c65'; + case 'CHARGING_IN_PROGRESS': + return '#58151c'; + default: + return '#2b2f32'; + } +}; +const statusLightColor = (state: ChargerStateType) => { + switch (state) { + case 'STANDBY': + return '#cfe2ff'; + case 'CHARGING_IN_PROGRESS': + return '#f8d7da'; + default: + return '#e9ecef'; + } +}; + +const ChargerCard = ({ charger }: ChargerCardProps) => { + const { type, price, capacity, latestUpdateTime, state, method } = charger; + + return ( + + + + {CHARGER_STATES[state].state} + + + + {CONNECTOR_TYPES[type]} + + + {capacity >= QUICK_CHARGER_CAPACITY_THRESHOLD ? '급속' : '완속'}({capacity}kW) + {method && ( + +  / {method} + + )} + + {latestUpdateTime && ( + + {CHARGER_STATES[state].message} : {calculateLatestUpdateTime(latestUpdateTime)} + + )} + + ); +}; + +const borderCss = css` + border: 0.4px solid #66666666; + height: 142px; +`; + +const SquareBox = styled.div<{ heavyColor: string; lightColor: string }>` + padding: 0.4rem; + border-radius: 4px; + background: ${({ heavyColor }) => heavyColor}; + color: ${({ lightColor }) => lightColor}; +`; + +const regularFontWeight = css` + font-weight: 500; +`; + +const bottomTextCss = css` + margin-top: auto; +`; + +export default ChargerCard; diff --git a/src/components/ui/StationDetailsWindow/chargers/ChargerCardSkeleton.tsx b/src/components/ui/StationDetailsWindow/chargers/ChargerCardSkeleton.tsx new file mode 100644 index 00000000..ef9d6b10 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/chargers/ChargerCardSkeleton.tsx @@ -0,0 +1,20 @@ +import Box from '@common/Box'; +import FlexBox from '@common/FlexBox'; +import Skeleton from '@common/Skeleton'; + +const ChargerCardSkeleton = () => { + return ( + + + + + + + + + + + ); +}; + +export default ChargerCardSkeleton; diff --git a/src/components/ui/StationDetailsWindow/chargers/ChargerList.tsx b/src/components/ui/StationDetailsWindow/chargers/ChargerList.tsx new file mode 100644 index 00000000..115d93a5 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/chargers/ChargerList.tsx @@ -0,0 +1,81 @@ +import { css } from 'styled-components'; + +import { useState } from 'react'; + +import Alert from '@common/Alert'; +import Box from '@common/Box'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import ShowHideButton from '@ui/ShowHideButton'; +import ChargerCard from '@ui/StationDetailsWindow/chargers/ChargerCard'; + +import type { Charger } from '@type'; + +import ChargerReportButton from '../reports/charger/ChargerReportButton'; + +export interface ChargerListProps { + chargers: Charger[]; + stationId: string; + reportCount: number; +} + +const ChargerList = ({ chargers, stationId, reportCount }: ChargerListProps) => { + const CHARGER_SIZE = 6; + const INITIAL_PAGE = 1; + + const [page, setPage] = useState(INITIAL_PAGE); + const totalChargersSize = chargers.length; + const availableChargersSize = chargers.filter((charger) => charger.state === 'STANDBY').length; + const loadedChargers = chargers.slice(0, page * CHARGER_SIZE); + const loadedChargersSize = page * CHARGER_SIZE; + const isReported = reportCount > 0; + const shouldShowMoreButton = totalChargersSize - loadedChargersSize > 0; + + const handleShowMoreChargers = () => { + setPage((prev) => prev + 1); + }; + + const handleResetChargesPage = () => { + setPage(INITIAL_PAGE); + }; + + return ( + + + 충전기 + + + + {totalChargersSize}대 중 {availableChargersSize}대 사용가능 + + + + {isReported && ( + + )} + + {loadedChargers.map((charger, index) => ( + + ))} + + {shouldShowMoreButton ? ( + + ) : ( + page !== INITIAL_PAGE && + )} + + ); +}; + +const alertCss = css` + padding: 1rem 0 1.2rem; + text-align: center; + margin-top: 1rem; +`; + +export default ChargerList; diff --git a/src/components/ui/StationDetailsWindow/congestion/CongestionBarContainerSkeleton.tsx b/src/components/ui/StationDetailsWindow/congestion/CongestionBarContainerSkeleton.tsx new file mode 100644 index 00000000..3c09b0d2 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/congestion/CongestionBarContainerSkeleton.tsx @@ -0,0 +1,29 @@ +import { css } from 'styled-components'; + +import Box from '@common/Box'; +import FlexBox from '@common/FlexBox'; +import Skeleton from '@common/Skeleton'; +import Text from '@common/Text'; + +const CongestionBarContainerSkeleton = () => { + return ( + + + {Array.from({ length: 24 }, (_, index) => ( + + {String(index + 1).padStart(2, '0')} + + + ))} + + + ); +}; + +const graphCss = css` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export default CongestionBarContainerSkeleton; diff --git a/src/components/ui/StationDetailsWindow/congestion/CongestionStatistics.tsx b/src/components/ui/StationDetailsWindow/congestion/CongestionStatistics.tsx new file mode 100644 index 00000000..b5adebc2 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/congestion/CongestionStatistics.tsx @@ -0,0 +1,30 @@ +import { useState } from 'react'; + +import Box from '@common/Box'; + +import { ENGLISH_DAYS_OF_WEEK } from '@constants/congestion'; + +import type { EnglishDaysOfWeek } from '@type'; + +import Statistics from './Statistics'; +import Title from './Title'; + +interface CongestionStatisticsProps { + stationId: string; +} + +const CongestionStatistics = ({ stationId }: CongestionStatisticsProps) => { + const todayIndex = new Date().getDay() - 1; + const [dayOfWeek, setDayOfWeek] = useState( + ENGLISH_DAYS_OF_WEEK[todayIndex < 0 ? 6 : todayIndex] + ); + + return ( + + + <Statistics stationId={stationId} dayOfWeek={dayOfWeek} onChangeDayOfWeek={setDayOfWeek} /> + </Box> + ); +}; + +export default CongestionStatistics; diff --git a/src/components/ui/StationDetailsWindow/congestion/Help.stories.tsx b/src/components/ui/StationDetailsWindow/congestion/Help.stories.tsx new file mode 100644 index 00000000..cf9eab44 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/congestion/Help.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta } from '@storybook/react'; + +import Help from '../congestion/Help'; +import Box from './../../../common/Box'; + +const meta = { + title: 'UI/Help', + component: Help, +} satisfies Meta<typeof Help>; + +export default meta; + +export const Default = () => { + return ( + <Box width="fit-content" border> + <Help /> + </Box> + ); +}; diff --git a/src/components/ui/StationDetailsWindow/congestion/Help.tsx b/src/components/ui/StationDetailsWindow/congestion/Help.tsx new file mode 100644 index 00000000..39ae5254 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/congestion/Help.tsx @@ -0,0 +1,151 @@ +import { XMarkIcon } from '@heroicons/react/24/outline'; +import styled, { css } from 'styled-components'; + +import { modalActions } from '@stores/layout/modalStore'; + +import Box from '@common/Box'; +import Button from '@common/Button'; +import FlexBox from '@common/FlexBox'; +import List from '@common/List'; +import ListItem from '@common/ListItem'; +import Text from '@common/Text'; + +import Bar from '@ui/StatisticsGraph/Graph/Bar'; + +import { MOBILE_BREAKPOINT } from '@constants'; +import { NO_RATIO } from '@constants/congestion'; + +const Help = () => { + return ( + <Box width="fit-content" px={7} pt={7} pb={13} css={modalContainer}> + <Button aria-label="닫기" css={closeButton} onClick={() => modalActions.closeModal()}> + <XMarkIcon width={24} /> + </Button> + <Text variant="subtitle" mb={4}> + 좌측 숫자는  + <Text tag="span" weight="bold"> + 시간 + </Text> + , 막대 그래프는  + <Text tag="span" weight="bold"> + 혼잡도 + </Text> + (%)를 나타냅니다. + </Text> + <Text mb={2}> + 해당 시간대에 사용중인 충전기가 + <br /> 몇 퍼센트인지 예측하여 혼잡도를 표시합니다. + </Text> + <Text variant="caption" fontSize={1.3} mb={5}> + 예시) 2시에 보유 충전기의 20%(총 10대 중 2대)가 사용중일 확률이 높습니다. + </Text> + <FlexBox width={34} px={2} direction="column" gap={2} css={statisticsContainer}> + <table> + <TableRow> + <td> + <Bar ratio={0} hour="01" align="column" /> + </td> + <TableData>0%</TableData> + </TableRow> + <TableRow> + <td> + <Bar ratio={0.2} hour="02" align="column" /> + </td> + <TableData>20%</TableData> + </TableRow> + <TableRow> + <td> + <Bar ratio={0.4} hour="03" align="column" /> + </td> + <TableData>40%</TableData> + </TableRow> + <TableRow> + <td> + <Bar ratio={0.6} hour="04" align="column" /> + </td> + <TableData>60%</TableData> + </TableRow> + <TableRow> + <td> + <Bar ratio={0.8} hour="05" align="column" /> + </td> + <TableData>80%</TableData> + </TableRow> + <TableRow> + <td> + <Bar ratio={1} hour="06" align="column" /> + </td> + <TableData>100%</TableData> + </TableRow> + <TableRow> + <td> + <Bar ratio={NO_RATIO} hour="07" align="column" /> + </td> + <TableData>정보 없음</TableData> + </TableRow> + </table> + </FlexBox> + <List fontSize={1.4} p={0} pt={6}> + <ListItem px={0} py={0} pb={2}> + 0~19% + <Text tag="span" variant="label" weight="bold" ml={2}> + 아주 여유 + </Text> + </ListItem> + <ListItem px={0} py={0} pb={2}> + 20~39% + <Text tag="span" variant="label" weight="bold" ml={2}> + 여유 + </Text> + </ListItem> + <ListItem px={0} py={0} pb={2}> + 40~59% + <Text tag="span" variant="label" weight="bold" ml={2}> + 보통 + </Text> + </ListItem> + <ListItem px={0} py={0} pb={2}> + 60~79% + <Text tag="span" variant="label" weight="bold" ml={2}> + 혼잡 + </Text> + </ListItem> + <ListItem px={0} py={0}> + 80~100% + <Text tag="span" variant="label" weight="bold" ml={2}> + 매우 혼잡 + </Text> + </ListItem> + </List> + </Box> + ); +}; + +const modalContainer = css` + line-height: 1.4; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + width: 100%; + margin: 0 auto; + } +`; + +const closeButton = css` + display: block; + margin: -1rem -1rem 1rem auto; +`; + +const statisticsContainer = css` + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + width: 100%; + } +`; + +const TableRow = styled.tr``; + +const TableData = styled.td` + width: 7rem; + padding-left: 0.8rem; +`; + +export default Help; diff --git a/src/components/ui/StationDetailsWindow/congestion/Statistics.tsx b/src/components/ui/StationDetailsWindow/congestion/Statistics.tsx new file mode 100644 index 00000000..84ea8872 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/congestion/Statistics.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react'; + +import { useStationCongestionStatistics } from '@hooks/tanstack-query/station-details/useStationCongestionStatistics'; + +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; + +import Error from '@ui/Error'; +import StatisticsGraph from '@ui/StatisticsGraph'; + +import type { CHARGING_SPEED } from '@constants/chargers'; +import { NO_RATIO, SHORT_ENGLISH_DAYS_OF_WEEK } from '@constants/congestion'; + +import type { EnglishDaysOfWeek } from '@type'; + +interface Props { + stationId: string; + dayOfWeek: EnglishDaysOfWeek; + onChangeDayOfWeek: (dayOfWeek: EnglishDaysOfWeek) => void; +} + +const Statistics = ({ stationId, dayOfWeek, onChangeDayOfWeek }: Props) => { + const { + data: congestionStatistics, + isLoading, + isError, + refetch, + } = useStationCongestionStatistics(stationId, dayOfWeek); + + const hasOnlyStandardCharger = congestionStatistics?.congestion['quick'].every( + (congestion) => congestion.ratio === NO_RATIO + ); + const hasOnlyQuickCharger = congestionStatistics?.congestion['standard'].every( + (congestion) => congestion.ratio === NO_RATIO + ); + const hasOnlyOneChargerType = hasOnlyStandardCharger || hasOnlyQuickCharger; + + const [chargingSpeed, setChargingSpeed] = useState<keyof typeof CHARGING_SPEED>('standard'); + + useEffect(() => { + if (hasOnlyQuickCharger) { + setChargingSpeed('quick'); + } + }, [hasOnlyQuickCharger]); + + const handleRetry = () => { + refetch(); + }; + + if (isError) { + return ( + <Error + title="앗" + message="데이터를 불러오는데 실패했어요." + subMessage="잠시 후 다시 시도해주세요." + handleRetry={handleRetry} + minHeight={49.3 + 2.2} + /> + ); + } + + return ( + <FlexBox direction="column" gap={4} mb={3.5}> + <StatisticsGraph + statistics={congestionStatistics?.congestion[chargingSpeed]} + menus={[...SHORT_ENGLISH_DAYS_OF_WEEK]} + align="column" + dayOfWeek={dayOfWeek} + onChangeDayOfWeek={onChangeDayOfWeek} + isLoading={isLoading} + /> + {!isLoading && !hasOnlyOneChargerType && ( + <FlexBox nowrap> + <ButtonNext + variant={chargingSpeed === 'standard' ? 'contained' : 'outlined'} + size="xs" + onClick={() => setChargingSpeed('standard')} + fullWidth + > + 완속 충전기 그룹 + </ButtonNext> + <ButtonNext + variant={chargingSpeed === 'quick' ? 'contained' : 'outlined'} + size="xs" + onClick={() => setChargingSpeed('quick')} + fullWidth + > + 급속 충전기 그룹 + </ButtonNext> + </FlexBox> + )} + </FlexBox> + ); +}; + +export default Statistics; diff --git a/src/components/ui/StationDetailsWindow/congestion/Title.tsx b/src/components/ui/StationDetailsWindow/congestion/Title.tsx new file mode 100644 index 00000000..f200c780 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/congestion/Title.tsx @@ -0,0 +1,28 @@ +import { InformationCircleIcon } from '@heroicons/react/24/outline'; + +import { modalActions } from '@stores/layout/modalStore'; + +import Button from '@common/Button'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import Help from './Help'; + +const Title = () => { + const handleOpenStatisticsHelp = () => { + modalActions.openModal(<Help />); + }; + + return ( + <FlexBox justifyContent="between" alignItems="center" mb={3}> + <Text fontSize={1.8} weight="bold"> + 충전소 시간별 혼잡도 + </Text> + <Button aria-label="혼잡도 통계 설명 보기" onClick={handleOpenStatisticsHelp}> + <InformationCircleIcon width={24} stroke="#747474" /> + </Button> + </FlexBox> + ); +}; + +export default Title; diff --git a/src/components/ui/StationDetailsWindow/index.tsx b/src/components/ui/StationDetailsWindow/index.tsx new file mode 100644 index 00000000..ed9901f8 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/index.tsx @@ -0,0 +1,3 @@ +import StationDetailsWindow from './StationDetailsWindow'; + +export default StationDetailsWindow; diff --git a/src/components/ui/StationDetailsWindow/reports/ReportButton.tsx b/src/components/ui/StationDetailsWindow/reports/ReportButton.tsx new file mode 100644 index 00000000..977ec2fb --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reports/ReportButton.tsx @@ -0,0 +1,83 @@ +import type { CSSProp } from 'styled-components'; +import { styled } from 'styled-components'; +import { css } from 'styled-components'; + +import type { ReactNode } from 'react'; + +import { modalActions } from '@stores/layout/modalStore'; +import { toastActions } from '@stores/layout/toastStore'; +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import ButtonNext from '@common/ButtonNext'; +import Skeleton from '@common/Skeleton'; + +import { EMPTY_MEMBER_TOKEN } from '@constants'; + +interface Props { + modalContent: JSX.Element; + children: ReactNode; + isLoading?: boolean; + disabled?: boolean; + css?: CSSProp; +} + +function ReportButton({ modalContent, disabled, isLoading, children, css }: Props) { + const memberToken = memberTokenStore.getState(); + const { showToast } = toastActions; + const { openModal } = modalActions; + + const handleOpenStationReportForm = () => { + if (memberToken === EMPTY_MEMBER_TOKEN) { + showToast('로그인이 필요한 메뉴입니다.'); + } else { + openModal(modalContent); + } + }; + + if (isLoading) { + return <Skeleton height="2.8rem" width="8rem" />; + } + + return ( + <ButtonNext + type="button" + fullWidth + variant="outlined" + size="sm" + color="secondary" + my={3} + css={[buttonCss, css]} + onClick={handleOpenStationReportForm} + disabled={disabled} + > + <FlexContainer>{children}</FlexContainer> + </ButtonNext> + ); +} + +const FlexContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + column-gap: 8px; + font-size: 1.4rem; + color: #555; +`; + +const buttonCss = css` + border: 0; + outline: 1.6px solid #888; + margin-top: 1rem; + + &:hover:enabled { + font-weight: 500; + outline: 1.6px solid #555; + transform: translateY(-2px); + } + + &:hover { + background: inherit; + } +`; + +export default ReportButton; diff --git a/src/components/ui/StationDetailsWindow/reports/charger/ChargerReportButton.tsx b/src/components/ui/StationDetailsWindow/reports/charger/ChargerReportButton.tsx new file mode 100644 index 00000000..3eacae8c --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reports/charger/ChargerReportButton.tsx @@ -0,0 +1,51 @@ +import { css } from 'styled-components'; + +import { useStationChargerReport } from '@hooks/tanstack-query/station-details/reports/useStationChargerReport'; + +import ChargerReportConfirmation from '@ui/StationDetailsWindow/reports/charger/ChargerReportConfirmation'; + +import ReportButton from '../ReportButton'; + +interface ChargerReportButtonProps { + stationId: string; +} + +const ChargerReportButton = ({ stationId }: ChargerReportButtonProps) => { + const { data: isStationChargerReported, isLoading: isStationChargerReportedLoading } = + useStationChargerReport(stationId); + + return ( + <ReportButton + modalContent={<ChargerReportConfirmation stationId={stationId} />} + disabled={isStationChargerReported} + isLoading={isStationChargerReportedLoading} + css={reportButtonCss} + > + {isStationChargerReported ? '이미 신고한 충전소' : '고장 신고'} + </ReportButton> + ); +}; + +const reportButtonCss = css` + width: fit-content; + min-width: 8rem; + margin: 0; + background: #666; + outline: 0; + + &:hover { + background: #666; + } + + &:hover:enabled { + background: #555; + outline: 0; + transform: none; + } + + & > div { + color: #fff; + } +`; + +export default ChargerReportButton; diff --git a/src/components/ui/StationDetailsWindow/reports/charger/ChargerReportConfirmation.tsx b/src/components/ui/StationDetailsWindow/reports/charger/ChargerReportConfirmation.tsx new file mode 100644 index 00000000..57fa6f0a --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reports/charger/ChargerReportConfirmation.tsx @@ -0,0 +1,50 @@ +import { css } from 'styled-components'; + +import { modalActions } from '@stores/layout/modalStore'; + +import { useUpdateStationChargerReport } from '@hooks/tanstack-query/station-details/reports/useUpdateStationChargerReport'; + +import Box from '@common/Box'; +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +interface ChargerReportConfirmationProps { + stationId: string; +} + +const ChargerReportConfirmation = ({ stationId }: ChargerReportConfirmationProps) => { + const { updateStationChargerReport } = useUpdateStationChargerReport(stationId); + const reportCharger = () => { + updateStationChargerReport(stationId); + }; + + return ( + <Box p={4} css={chargerReportConfirmationCss}> + <Text variant="title" mb={3}> + 충전기가 고장나있다면 신고해주세요. + </Text> + <Text my={4}>표시된 정보가 실제 충전기 상태와 다를 수 있습니다.</Text> + <Text my={4}>충전소당 한 번만 신고할 수 있습니다.</Text> + <FlexBox justifyContent="between" nowrap> + <ButtonNext + variant="outlined" + size="sm" + fullWidth + onClick={() => modalActions.closeModal()} + > + 돌아가기 + </ButtonNext> + <ButtonNext variant="contained" size="sm" fullWidth onClick={() => reportCharger()}> + 제보하기 + </ButtonNext> + </FlexBox> + </Box> + ); +}; + +const chargerReportConfirmationCss = css` + width: 40rem; +`; + +export default ChargerReportConfirmation; diff --git a/src/components/ui/StationDetailsWindow/reports/station/StationReportButton.tsx b/src/components/ui/StationDetailsWindow/reports/station/StationReportButton.tsx new file mode 100644 index 00000000..360df66e --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reports/station/StationReportButton.tsx @@ -0,0 +1,30 @@ +import { MegaphoneIcon } from '@heroicons/react/24/outline'; +import { css } from 'styled-components'; + +import StationReportPreConfirmation from '@ui/StationDetailsWindow/reports/station/StationReportPreConfirmation'; + +import type { StationDetails } from '@type'; + +import ReportButton from '../ReportButton'; + +interface StationReportButtonProps { + station: StationDetails; +} + +const StationReportButton = ({ station }: StationReportButtonProps) => { + return ( + <ReportButton + css={calculatedButtonWidthCss} + modalContent={<StationReportPreConfirmation station={station} />} + > + <MegaphoneIcon width={20} stroke="#666" aria-hidden /> + 잘못된 충전소 정보 제보 + </ReportButton> + ); +}; + +const calculatedButtonWidthCss = css` + width: calc(100% - 1.6px * 2); +`; + +export default StationReportButton; diff --git a/src/components/ui/StationDetailsWindow/reports/station/StationReportConfirmation.tsx b/src/components/ui/StationDetailsWindow/reports/station/StationReportConfirmation.tsx new file mode 100644 index 00000000..393fbefb --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reports/station/StationReportConfirmation.tsx @@ -0,0 +1,196 @@ +import { css } from 'styled-components'; + +import type { ChangeEvent } from 'react'; +import { useState } from 'react'; + +import { modalActions } from '@stores/layout/modalStore'; + +import { useUpdateStationReport } from '@hooks/tanstack-query/station-details/reports/useUpdateStationReport'; + +import Box from '@common/Box'; +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; +import TextField from '@common/TextField'; + +import { findDifferentKeys } from '@ui/StationDetailsWindow/reports/station/domain'; +import StationInformation from '@ui/StationDetailsWindow/station/StationInformation'; + +import { + FORM_ADDRESS_LENGTH_LIMIT, + FORM_CONTACT_LENGTH_LIMIT, + FORM_DETAIL_LOCATION_LENGTH_LIMIT, + FORM_OPERATING_TIME_LENGTH_LIMIT, + FORM_PRIVATE_REASON_LENGTH_LIMIT, +} from '@constants'; + +import type { ChargerDetails } from '@type/chargers'; +import type { StationDetails, StationDetailsWithoutChargers } from '@type/stations'; + +interface StationReportConfirmationProps { + station: StationDetails; +} + +export interface Differences { + [key: string]: string | number | boolean | ChargerDetails[]; +} + +const validateForm = (form: StationDetailsWithoutChargers) => { + if (!form) { + return true; + } + + return ( + (!form.address || form.address.length <= FORM_ADDRESS_LENGTH_LIMIT) && + (!form.detailLocation || form.detailLocation.length <= FORM_DETAIL_LOCATION_LENGTH_LIMIT) && + (!form.operatingTime || form.operatingTime.length <= FORM_OPERATING_TIME_LENGTH_LIMIT) && + (!form.contact || form.contact.length <= FORM_CONTACT_LENGTH_LIMIT) && + (!form.privateReason || form.privateReason.length <= FORM_PRIVATE_REASON_LENGTH_LIMIT) + ); +}; + +const StationReportConfirmation = ({ station }: StationReportConfirmationProps) => { + const { chargers, ...stationWithoutChargers } = station; + const [form, setForm] = useState<StationDetailsWithoutChargers>({ ...stationWithoutChargers }); + const { updateStationReport, isLoading } = useUpdateStationReport(); + const isFormValid = validateForm(form); + + const handleChangeTextField = ({ target: { id, value } }: ChangeEvent<HTMLInputElement>) => { + setForm({ ...form, [id]: value }); + }; + + const handleClickButton = (key: 'isParkingFree' | 'isPrivate') => { + setForm({ ...form, [key]: !form[key] }); + }; + + const reportCharger = () => { + const differentKeys = findDifferentKeys(form, stationWithoutChargers); + const differencesArray: Differences[] = differentKeys.map((key) => ({ + category: key, + reportedDetail: form[key], + })); + if (differencesArray.length > 0) { + updateStationReport({ stationId: station.stationId, differences: differencesArray }); + } else { + alert('수정된 항목이 없습니다.'); + } + }; + + const handleCloseModalButton = () => modalActions.closeModal(); + + return ( + <Box p={4} css={stationReportConfirmationCss}> + <Text variant="title" mb={6}> + 개선할 충전소 정보가 있나요? + </Text> + <Text variant="subtitle" mb={2}> + 변경사항 미리보기 + </Text> + <Box border p={4}> + <StationInformation station={{ chargers: [], ...form }} /> + </Box> + <TextField + id="address" + label="도로명 주소" + fullWidth + value={form.address} + onChange={handleChangeTextField} + supportingText={ + form.address?.length > FORM_ADDRESS_LENGTH_LIMIT && + `${FORM_ADDRESS_LENGTH_LIMIT}자 이내로 작성해주셔야 합니다.` + } + /> + <TextField + id="detailLocation" + label="상세주소" + fullWidth + value={form.detailLocation} + onChange={handleChangeTextField} + supportingText={ + form.detailLocation?.length > FORM_DETAIL_LOCATION_LENGTH_LIMIT && + `${FORM_DETAIL_LOCATION_LENGTH_LIMIT}자 이내로 작성해주셔야 합니다.` + } + /> + <TextField + id="operatingTime" + label="운영시간" + fullWidth + value={form.operatingTime} + onChange={handleChangeTextField} + supportingText={ + form.operatingTime?.length > FORM_OPERATING_TIME_LENGTH_LIMIT && + `${FORM_OPERATING_TIME_LENGTH_LIMIT}자 이내로 작성해주셔야 합니다.` + } + /> + <TextField + id="contact" + label="연락처" + fullWidth + value={form.contact} + onChange={handleChangeTextField} + supportingText={ + form.contact?.length > FORM_CONTACT_LENGTH_LIMIT && + `${FORM_CONTACT_LENGTH_LIMIT}자 이내로 작성해주셔야 합니다.` + } + /> + <Box> + <ButtonNext noTheme onClick={() => handleClickButton('isParkingFree')}> + <FlexBox alignItems="center"> + <input + data-testid="isParkingFree-checkbox" + type="checkbox" + checked={form.isParkingFree} + /> + <Text>주차비 무료</Text> + </FlexBox> + </ButtonNext> + </Box> + <Box> + <ButtonNext noTheme onClick={() => handleClickButton('isPrivate')}> + <FlexBox alignItems="center"> + <input data-testid="isPrivate-checkbox" type="checkbox" checked={form.isPrivate} /> + <Text>사용 제한</Text> + </FlexBox> + </ButtonNext> + </Box> + <TextField + id="privateReason" + label="사용 제한 사유" + fullWidth + value={form.privateReason} + onChange={handleChangeTextField} + supportingText={ + form.privateReason?.length > FORM_PRIVATE_REASON_LENGTH_LIMIT && + `${FORM_PRIVATE_REASON_LENGTH_LIMIT}자 이내로 작성해주셔야 합니다.` + } + /> + + <FlexBox justifyContent="between" nowrap> + <ButtonNext variant="outlined" size="md" fullWidth onClick={handleCloseModalButton}> + 돌아가기 + </ButtonNext> + {isFormValid ? ( + <ButtonNext + disabled={isLoading} + variant="contained" + size="md" + fullWidth + onClick={reportCharger} + > + {isLoading ? '처리중...' : '제안하기'} + </ButtonNext> + ) : ( + <ButtonNext disabled variant="contained" size="md" fullWidth> + 다시확인해주세요 + </ButtonNext> + )} + </FlexBox> + </Box> + ); +}; + +const stationReportConfirmationCss = css` + width: 40rem; +`; + +export default StationReportConfirmation; diff --git a/src/components/ui/StationDetailsWindow/reports/station/StationReportPreConfirmation.render.test.tsx b/src/components/ui/StationDetailsWindow/reports/station/StationReportPreConfirmation.render.test.tsx new file mode 100644 index 00000000..d6a2a147 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reports/station/StationReportPreConfirmation.render.test.tsx @@ -0,0 +1,49 @@ +import { render, fireEvent, screen } from '@testing-library/react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import type { StationDetails } from '../../../../../types'; +import StationReportPreConfirmation from './StationReportPreConfirmation'; + +const queryClient = new QueryClient(); + +describe('StationReportPreConfirmation 테스트', () => { + const mockStation: StationDetails = { + address: '', + chargers: [], + companyName: '', + contact: '', + detailLocation: '', + isParkingFree: false, + isPrivate: false, + latitude: 0, + longitude: 0, + operatingTime: '', + privateReason: '', + reportCount: 0, + stationId: '', + stationName: '', + stationState: '', + }; + + it('오류 없이 렌더링이 잘 되는가?', () => { + render( + <QueryClientProvider client={queryClient}> + <StationReportPreConfirmation station={mockStation} /> + </QueryClientProvider> + ); + }); + + it('데이터의 수정 제안을 직접 할 때 모달이 잘 열리는가?', () => { + const { getByText } = render( + <QueryClientProvider client={queryClient}> + <StationReportPreConfirmation station={mockStation} /> + </QueryClientProvider> + ); + + fireEvent.click(getByText('데이터를 직접 수정/제안하고 싶어요')); + + const modalContent = screen.getByText('개선할 충전소 정보가 있나요?'); + expect(modalContent).toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/StationDetailsWindow/reports/station/StationReportPreConfirmation.tsx b/src/components/ui/StationDetailsWindow/reports/station/StationReportPreConfirmation.tsx new file mode 100644 index 00000000..0b7f1b80 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reports/station/StationReportPreConfirmation.tsx @@ -0,0 +1,81 @@ +import { css } from 'styled-components'; + +import { modalActions } from '@stores/layout/modalStore'; + +import { useUpdateStationReport } from '@hooks/tanstack-query/station-details/reports/useUpdateStationReport'; + +import Box from '@common/Box'; +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import StationReportConfirmation from '@ui/StationDetailsWindow/reports/station/StationReportConfirmation'; +import StationInformation from '@ui/StationDetailsWindow/station/StationInformation'; + +import type { StationDetails } from '@type'; + +interface StationReportPreConfirmationProps { + station: StationDetails; +} + +const StationReportPreConfirmation = ({ station }: StationReportPreConfirmationProps) => { + const { updateStationReport, isLoading } = useUpdateStationReport(); + + const reportCharger = () => { + updateStationReport({ stationId: station.stationId, differences: [] }); + }; + + const handleReportMoreButton = () => { + modalActions.openModal(<StationReportConfirmation station={station} />); + }; + + const handleCloseModalButton = () => modalActions.closeModal(); + + return ( + <Box p={4} css={stationReportPreConfirmationCss}> + <Text variant="title" mb={5}> + 개선할 충전소 정보가 있나요? + </Text> + <Box border p={4}> + <StationInformation station={station} /> + </Box> + <ButtonNext + my={6} + fullWidth + variant="contained" + size="md" + color="primary" + onClick={handleReportMoreButton} + > + 데이터를 직접 수정/제안하고 싶어요 + </ButtonNext> + <FlexBox nowrap> + <ButtonNext + fullWidth + variant="outlined" + size="sm" + color="secondary" + onClick={handleCloseModalButton} + > + 닫기 + </ButtonNext> + <ButtonNext + disabled={isLoading} + fullWidth + variant="outlined" + size="sm" + color="secondary" + onClick={reportCharger} + > + {isLoading ? '처리중...' : '그냥 제보하기'} + </ButtonNext> + </FlexBox> + </Box> + ); +}; + +const stationReportPreConfirmationCss = css` + width: 40rem; +`; + +export default StationReportPreConfirmation; diff --git a/src/components/ui/StationDetailsWindow/reports/station/domain.ts b/src/components/ui/StationDetailsWindow/reports/station/domain.ts new file mode 100644 index 00000000..bc03613d --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reports/station/domain.ts @@ -0,0 +1,11 @@ +import { getTypedObjectKeys } from '@utils/getTypedObjectKeys'; + +import type { StationDetailsWithoutChargers } from '@type/stations'; + +export const findDifferentKeys = ( + formStation: StationDetailsWithoutChargers, + originStation: StationDetailsWithoutChargers +) => + getTypedObjectKeys<StationDetailsWithoutChargers>(formStation).filter( + (key) => formStation[key] !== originStation[key] + ); diff --git a/src/components/ui/StationDetailsWindow/reports/station/stationReportConfirmation.render.test.tsx b/src/components/ui/StationDetailsWindow/reports/station/stationReportConfirmation.render.test.tsx new file mode 100644 index 00000000..e1e502d7 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reports/station/stationReportConfirmation.render.test.tsx @@ -0,0 +1,58 @@ +import { render, fireEvent } from '@testing-library/react'; + +import type { StationDetails } from '../../../../../types'; +import StationReportConfirmation from './StationReportConfirmation'; + +jest.mock('@hooks/tanstack-query/station-details/reports/useUpdateStationReport', () => ({ + useUpdateStationReport: () => ({ + updateStationReport: jest.fn(), + isLoading: false, + }), +})); + +describe('StationReportConfirmation 테스트', () => { + const mockStation: StationDetails = { + address: '', + chargers: [], + companyName: '', + contact: '', + detailLocation: '', + isParkingFree: true, + isPrivate: false, + latitude: 0, + longitude: 0, + operatingTime: '', + privateReason: '', + reportCount: 0, + stationId: '', + stationName: '', + stationState: '', + }; + + it('초기 렌더링 테스트', () => { + const { getByText, getByTestId } = render(<StationReportConfirmation station={mockStation} />); + + expect(getByText('도로명 주소')).toBeInTheDocument(); + expect(getByText('상세주소')).toBeInTheDocument(); + + expect(getByTestId('isParkingFree-checkbox')).toBeChecked(); + expect(getByTestId('isPrivate-checkbox')).not.toBeChecked(); + + expect(getByText('제안하기')).toBeInTheDocument(); + expect(getByText('돌아가기')).toBeInTheDocument(); + }); + + it('체크박스들이 눌렸을 때 반응하는지 확인한다', () => { + const { getByTestId } = render(<StationReportConfirmation station={mockStation} />); + + const isParkingFreeCheckbox = getByTestId('isParkingFree-checkbox'); + fireEvent.click(isParkingFreeCheckbox); + + expect(isParkingFreeCheckbox).not.toBeChecked(); + + const isPrivateCheckbox = getByTestId('isPrivate-checkbox'); + fireEvent.click(isPrivateCheckbox); + + expect(isPrivateCheckbox).toBeChecked(); + }); +}); diff --git a/src/components/ui/StationDetailsWindow/reports/station/stationReportConfirmation.test.ts b/src/components/ui/StationDetailsWindow/reports/station/stationReportConfirmation.test.ts new file mode 100644 index 00000000..df793807 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reports/station/stationReportConfirmation.test.ts @@ -0,0 +1,128 @@ +import type { Differences } from '@ui/StationDetailsWindow/reports/station/StationReportConfirmation'; +import { findDifferentKeys } from '@ui/StationDetailsWindow/reports/station/domain'; + +describe('findDifferentKeys()를 테스트한다.', () => { + test.each([ + // case 1 + [ + { + address: '', + companyName: '', + contact: '', + detailLocation: '', + isParkingFree: false, + isPrivate: false, + latitude: 0, + longitude: 0, + operatingTime: '', + privateReason: '', + reportCount: 0, + stationId: '0', + stationName: '', + stationState: '', + }, + { + address: '', + companyName: '', + contact: '', + detailLocation: '', + isParkingFree: false, + isPrivate: false, + latitude: 0, + longitude: 0, + operatingTime: '', + privateReason: '', + reportCount: 0, + stationId: '0', + stationName: '', + stationState: '', + }, + 0, + ], + // case 2 + [ + { + address: '', + companyName: '', + contact: '', + detailLocation: '', + isParkingFree: false, + isPrivate: false, + latitude: 0, + longitude: 0, + operatingTime: '', + privateReason: '', + reportCount: 0, + stationId: '0', + stationName: '', + stationState: '', + }, + { + address: '', + companyName: '', + contact: '', + detailLocation: '', + isParkingFree: false, + isPrivate: false, + latitude: 0, + longitude: 0, + operatingTime: '', + privateReason: '', + reportCount: 1, + stationId: '0', + stationName: '', + stationState: '', + }, + 1, + ], + // case 3 + [ + { + address: '', + companyName: '', + contact: '', + detailLocation: '', + isParkingFree: false, + isPrivate: false, + latitude: 0, + longitude: 0, + operatingTime: '', + privateReason: '', + reportCount: 0, + stationId: '0', + stationName: '', + stationState: '', + }, + { + address: '', + companyName: 'X Corp', + contact: '', + detailLocation: '우리집', + isParkingFree: true, + isPrivate: false, + latitude: 1.23, + longitude: 4.56, + operatingTime: '매일매일', + privateReason: '', + reportCount: 1, + stationId: '10', + stationName: 'X 충전소', + stationState: '운영중임?', + }, + 10, + ], + ])( + '여러 StationDetailsWithoutChargers 객체를 활용한 findDifferentKeys() 테스트', + (form, station, expectedDifferentCount) => { + const differentKeys = findDifferentKeys(form, station); + expect(differentKeys.length).toBe(expectedDifferentCount); + + const differencesArray: Differences[] = differentKeys.map((key) => ({ + category: key, + reportedDetail: form[key], + })); + + expect(differencesArray.length).toBe(expectedDifferentCount); + } + ); +}); diff --git a/src/components/ui/StationDetailsWindow/reviews/common/ContentField.tsx b/src/components/ui/StationDetailsWindow/reviews/common/ContentField.tsx new file mode 100644 index 00000000..78ce0c41 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/common/ContentField.tsx @@ -0,0 +1,30 @@ +import TextField from '@common/TextField'; + +import { MAX_REVIEW_CONTENT_LENGTH, MIN_REVIEW_CONTENT_LENGTH } from '@constants'; + +interface ContentFieldProps { + content: string; + setContent: (newContent: string) => void; +} + +const ContentField = ({ content, setContent }: ContentFieldProps) => { + return ( + <> + <TextField + label="글 작성하기" + value={content} + fullWidth + supportingText={ + (content.length < MIN_REVIEW_CONTENT_LENGTH || + content.length > MAX_REVIEW_CONTENT_LENGTH) && + `${MIN_REVIEW_CONTENT_LENGTH}자 이상 ${MAX_REVIEW_CONTENT_LENGTH}자 이하로 작성해주세요.` + } + onChange={(e) => { + setContent(e.target.value); + }} + /> + </> + ); +}; + +export default ContentField; diff --git a/src/components/ui/StationDetailsWindow/reviews/common/HeaderWithRating.tsx b/src/components/ui/StationDetailsWindow/reviews/common/HeaderWithRating.tsx new file mode 100644 index 00000000..9db976b2 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/common/HeaderWithRating.tsx @@ -0,0 +1,28 @@ +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import StarRatings from '@ui/StarRatings'; + +interface HeaderWithRatingProps { + title: string; + stars: number; + setStars: (newStars: number) => void; +} + +const HeaderWithRating = ({ title, setStars, stars }: HeaderWithRatingProps) => { + return ( + <> + <FlexBox justifyContent="between" alignItems="center"> + <Text variant="subtitle" px={1}> + {title} + </Text> + <FlexBox justifyContent="center" alignItems="center"> + <Text variant="subtitle">별점 </Text> + <StarRatings stars={stars} setStars={setStars} size="md" /> + </FlexBox> + </FlexBox> + </> + ); +}; + +export default HeaderWithRating; diff --git a/src/components/ui/StationDetailsWindow/reviews/previews/ReviewPreview.tsx b/src/components/ui/StationDetailsWindow/reviews/previews/ReviewPreview.tsx new file mode 100644 index 00000000..1ea85e26 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/previews/ReviewPreview.tsx @@ -0,0 +1,108 @@ +import { css } from 'styled-components'; + +import { modalActions } from '@stores/layout/modalStore'; + +import { useReviewRatings } from '@hooks/tanstack-query/station-details/reviews/useReviewRatings'; +import { useReviews } from '@hooks/tanstack-query/station-details/reviews/useReviews'; + +import Box from '@common/Box'; +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import ReviewPreviewSkeleton from '@ui/StationDetailsWindow/reviews/previews/ReviewPreviewSkeleton'; +import UserRatings from '@ui/StationDetailsWindow/reviews/previews/UserRatings'; +import ReviewCard from '@ui/StationDetailsWindow/reviews/reviews/ReviewCard'; +import ReviewList from '@ui/StationDetailsWindow/reviews/reviews/ReviewList'; + +export interface ReviewPreviewProps { + stationId: string; +} + +const ReviewPreview = ({ stationId }: ReviewPreviewProps) => { + const { + data: totalRatings, + isLoading: isReviewRatingsLoading, + isError: isReviewRatingsError, + error: reviewRatingsError, + } = useReviewRatings(stationId); + + const { + data: reviews, + isLoading: isReviewsLoading, + isError: isReviewsError, + error: reviewsError, + } = useReviews(stationId); + + const handleClickMoreReviewButton = () => { + modalActions.openModal(<ReviewList stationId={stationId} />); + }; + + if (isReviewRatingsLoading || isReviewsLoading) { + return <ReviewPreviewSkeleton />; + } + + if (isReviewRatingsError || isReviewsError) { + return ( + <> + <Text variant="title">ReviewPreview Error!</Text> + <Text variant="subtitle">reviewRatingsError</Text> + <Text>{JSON.stringify(reviewRatingsError)}</Text> + <Text variant="subtitle">reviewsError</Text> + <Text>{JSON.stringify(reviewsError)}</Text> + </> + ); + } + + const aliveReviews = reviews.filter((review) => !review.isDeleted); + + return ( + <> + <Box my={5}> + <UserRatings stationId={stationId} /> + {aliveReviews.length === 0 ? ( + <Box p={5}>등록된 리뷰가 없습니다.</Box> + ) : ( + <> + {aliveReviews.slice(0, 3).map((review, i) => { + return ( + <ReviewCard + key={i} + stationId="" + review={{ + content: review.content, + isDeleted: review.isDeleted, + isUpdated: review.isUpdated, + latestUpdateDate: review.latestUpdateDate, + ratings: review.ratings, + replySize: review.replySize, + reviewId: review.reviewId, + memberId: review.memberId, + }} + previewMode={true} + /> + ); + })} + </> + )} + + <FlexBox justifyContent="end"> + <ButtonNext + variant="text" + size="sm" + css={moreButtonCss} + onClick={() => handleClickMoreReviewButton()} + > + {aliveReviews.length === 0 ? '후기 작성하기' : '후기 더보기'} + </ButtonNext> + </FlexBox> + </Box> + </> + ); +}; + +const moreButtonCss = css` + font-size: 1.5rem; +`; + +export default ReviewPreview; diff --git a/src/components/ui/StationDetailsWindow/reviews/previews/ReviewPreviewList.tsx b/src/components/ui/StationDetailsWindow/reviews/previews/ReviewPreviewList.tsx new file mode 100644 index 00000000..58877826 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/previews/ReviewPreviewList.tsx @@ -0,0 +1,67 @@ +import { useReviews } from '@hooks/tanstack-query/station-details/reviews/useReviews'; + +import Box from '@common/Box'; +import Text from '@common/Text'; + +import ReviewPreviewSkeleton from '@ui/StationDetailsWindow/reviews/previews/ReviewPreviewSkeleton'; +import ReviewCard from '@ui/StationDetailsWindow/reviews/reviews/ReviewCard'; + +interface ReviewPreviewListProps { + stationId: string; +} + +const ReviewPreviewList = ({ stationId }: ReviewPreviewListProps) => { + const { + data: reviews, + isLoading: isReviewsLoading, + isError: isReviewsError, + error: reviewsError, + } = useReviews(stationId); + + if (isReviewsLoading) { + return <ReviewPreviewSkeleton />; + } + + if (isReviewsError) { + return ( + <> + <Text variant="title">ReviewPreview Error!</Text> + <Text variant="subtitle">reviewsError</Text> + <Text>{JSON.stringify(reviewsError)}</Text> + </> + ); + } + + const aliveReviews = reviews.filter((review) => !review.isDeleted); + + return ( + <> + {aliveReviews.length === 0 ? ( + <Box p={5}>등록된 리뷰가 없습니다.</Box> + ) : ( + <> + {aliveReviews.slice(0, 3).map((review, i) => { + return ( + <ReviewCard + key={i} + stationId="" + review={{ + content: review.content, + isDeleted: review.isDeleted, + isUpdated: review.isUpdated, + latestUpdateDate: review.latestUpdateDate, + ratings: review.ratings, + replySize: review.replySize, + reviewId: review.reviewId, + memberId: review.memberId, + }} + /> + ); + })} + </> + )} + </> + ); +}; + +export default ReviewPreviewList; diff --git a/src/components/ui/StationDetailsWindow/reviews/previews/ReviewPreviewSkeleton.tsx b/src/components/ui/StationDetailsWindow/reviews/previews/ReviewPreviewSkeleton.tsx new file mode 100644 index 00000000..9ea9402a --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/previews/ReviewPreviewSkeleton.tsx @@ -0,0 +1,21 @@ +import Box from '@common/Box'; +import FlexBox from '@common/FlexBox'; +import Skeleton from '@common/Skeleton'; + +import ReviewCardsLoading from '@ui/StationDetailsWindow/reviews/reviews/ReviewCardsLoading'; + +const ReviewPreviewSkeleton = () => { + return ( + <Box> + <Box my={5}> + <FlexBox justifyContent="between"> + <Skeleton width="10rem" height="2.2rem" /> + <Skeleton width="8rem" height="2.2rem" /> + </FlexBox> + </Box> + <ReviewCardsLoading count={3} /> + </Box> + ); +}; + +export default ReviewPreviewSkeleton; diff --git a/src/components/ui/StationDetailsWindow/reviews/previews/UserRatings.stories.tsx b/src/components/ui/StationDetailsWindow/reviews/previews/UserRatings.stories.tsx new file mode 100644 index 00000000..65346599 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/previews/UserRatings.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta } from '@storybook/react'; + +import Box from '../../../../common/Box'; +import type { UserRatingsProps } from './UserRatings'; +import UserRatings from './UserRatings'; + +const meta = { + title: 'UI/UserRatings', + component: UserRatings, + tags: ['autodocs'], + args: { + stationId: '', + }, + argTypes: {}, +} satisfies Meta<typeof UserRatings>; + +export default meta; + +export const Default = (args: UserRatingsProps) => { + return ( + <Box width={80} border p={4}> + <UserRatings {...args} /> + </Box> + ); +}; diff --git a/src/components/ui/StationDetailsWindow/reviews/previews/UserRatings.tsx b/src/components/ui/StationDetailsWindow/reviews/previews/UserRatings.tsx new file mode 100644 index 00000000..b53f893c --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/previews/UserRatings.tsx @@ -0,0 +1,62 @@ +import { StarIcon } from '@heroicons/react/24/solid'; +import { css } from 'styled-components'; + +import { useReviewRatings } from '@hooks/tanstack-query/station-details/reviews/useReviewRatings'; + +import Box from '@common/Box'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import UserRatingsSkeleton from '@ui/StationDetailsWindow/reviews/previews/UserRatingsSkeleton'; + +export interface UserRatingsProps { + stationId: string; +} + +const UserRatings = ({ stationId }: UserRatingsProps) => { + const { + data: totalRatings, + isLoading: isReviewRatingsLoading, + isError: isReviewRatingsError, + error: reviewRatingsError, + } = useReviewRatings(stationId); + + if (isReviewRatingsLoading) { + return <UserRatingsSkeleton />; + } + + if (isReviewRatingsError) { + return ( + <> + <Text variant="title">ReviewPreview Error!</Text> + <Text variant="subtitle">reviewRatingsError</Text> + <Text>{JSON.stringify(reviewRatingsError)}</Text> + </> + ); + } + + return ( + <Box mt={12} mb={5}> + <FlexBox justifyContent="between" alignItems="center"> + <Text fontSize={1.8} weight="bold"> + 충전소 후기 + </Text> + <Text variant="subtitle" css={ratingCss}> + <StarIcon width={14} display="inline-block" fill="#fc4c4e" /> + <Text tag="span" color="#3e3e3e" weight="bold"> + {parseFloat(totalRatings.totalRatings.toFixed(1))} + </Text> + ({totalRatings.totalCount}명) + </Text> + </FlexBox> + </Box> + ); +}; + +const ratingCss = css` + column-gap: 0.2rem; + display: flex; + align-items: center; +`; + +export default UserRatings; diff --git a/src/components/ui/StationDetailsWindow/reviews/previews/UserRatingsSkeleton.tsx b/src/components/ui/StationDetailsWindow/reviews/previews/UserRatingsSkeleton.tsx new file mode 100644 index 00000000..7db92533 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/previews/UserRatingsSkeleton.tsx @@ -0,0 +1,16 @@ +import Box from '@common/Box'; +import FlexBox from '@common/FlexBox'; +import Skeleton from '@common/Skeleton'; + +const UserRatingsSkeleton = () => { + return ( + <Box mb={25}> + <FlexBox justifyContent="between" alignItems="center"> + <Skeleton width="7rem" height="2.2rem" /> + <Skeleton width="5rem" height="2rem" /> + </FlexBox> + </Box> + ); +}; + +export default UserRatingsSkeleton; diff --git a/src/components/ui/StationDetailsWindow/reviews/replies/ReplyCard.tsx b/src/components/ui/StationDetailsWindow/reviews/replies/ReplyCard.tsx new file mode 100644 index 00000000..b8a8ee11 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/replies/ReplyCard.tsx @@ -0,0 +1,103 @@ +import { TrashIcon } from '@heroicons/react/20/solid'; +import { PencilSquareIcon } from '@heroicons/react/24/solid'; + +import { useState } from 'react'; + +import { calculateLatestUpdateTime } from '@utils/index'; + +import { memberInfoStore } from '@stores/login/memberInfoStore'; + +import { useRemoveReply } from '@hooks/tanstack-query/station-details/reviews/useRemoveReply'; + +import Box from '@common/Box'; +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import ReplyModify from '@ui/StationDetailsWindow/reviews/replies/ReplyModify'; + +import type { Reply } from '@type'; + +interface ReplyCardProps { + stationId: string; + reply: Reply; + reviewId: number; + previewMode: boolean; +} + +const ReplyCard = ({ stationId, reply, reviewId, previewMode }: ReplyCardProps) => { + const [isModifyMode, setIsModifyMode] = useState(false); + const { removeReply, isRemoveReplyLoading } = useRemoveReply(stationId, reviewId); + const memberId = memberInfoStore.getState()?.memberId; + const isReplyOwner = memberId === reply.memberId; + const isEditable = isReplyOwner && !reply.isDeleted && !previewMode; + + const handleClickRemoveReplyButton = () => { + if (confirm('정말로 삭제하시겠습니까?')) { + removeReply({ replyId: reply.replyId, reviewId }); + } + }; + + if (isModifyMode) { + return ( + <ReplyModify + stationId={stationId} + reply={reply} + reviewId={reviewId} + setIsModifyMode={setIsModifyMode} + /> + ); + } + + return ( + <> + <Box key={reply.replyId} p={3} pl={8}> + <Box pl={4} py={3} css={{ borderLeft: '1px solid #66666666' }}> + <FlexBox justifyContent="between"> + <Box> + <Text variant="label" mb={2}> + {reply.isDeleted ? '(알수없음)' : `${reply.memberId}님`} + </Text> + <Text variant="caption"> + {!reply.isDeleted && calculateLatestUpdateTime(reply.latestUpdateDate)} + {reply.isDeleted ? ' (삭제됨)' : reply.isUpdated ? ' (수정됨)' : ''} + </Text> + </Box> + {!isEditable ? ( + <></> + ) : ( + <div> + <ButtonNext + size="xs" + variant="text" + color="secondary" + onClick={() => setIsModifyMode(true)} + > + <PencilSquareIcon width={15} display="inline-block" /> + </ButtonNext> + <ButtonNext + disabled={isRemoveReplyLoading} + size="xs" + variant="text" + color="secondary" + onClick={() => handleClickRemoveReplyButton()} + > + {isRemoveReplyLoading ? ( + '삭제중' + ) : ( + <TrashIcon width={15} display="inline-block" /> + )} + </ButtonNext> + </div> + )} + </FlexBox> + <Box mt={3}> + <Text variant="body">{reply.isDeleted ? '(삭제된 답글입니다.)' : reply.content}</Text> + </Box> + </Box> + </Box> + </> + ); +}; + +export default ReplyCard; diff --git a/src/components/ui/StationDetailsWindow/reviews/replies/ReplyCardSkeleton.tsx b/src/components/ui/StationDetailsWindow/reviews/replies/ReplyCardSkeleton.tsx new file mode 100644 index 00000000..c55720a5 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/replies/ReplyCardSkeleton.tsx @@ -0,0 +1,18 @@ +import Box from '@common/Box'; +import Skeleton from '@common/Skeleton'; + +const ReplyCardSkeleton = () => { + return ( + <> + <Box p={3} pl={8}> + <Box pl={4} py={3} css={{ borderLeft: '1px solid #66666666' }}> + <Skeleton width="10rem" height="1.2rem" mb={2} /> + <Skeleton width="5rem" height="1.2rem" mb={3} /> + <Skeleton width="100%" height="1.2rem" mb={2} /> + </Box> + </Box> + </> + ); +}; + +export default ReplyCardSkeleton; diff --git a/src/components/ui/StationDetailsWindow/reviews/replies/ReplyCreate.tsx b/src/components/ui/StationDetailsWindow/reviews/replies/ReplyCreate.tsx new file mode 100644 index 00000000..fc01bba0 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/replies/ReplyCreate.tsx @@ -0,0 +1,83 @@ +import { css } from 'styled-components'; + +import { useEffect, useState } from 'react'; + +import { memberInfoStore } from '@stores/login/memberInfoStore'; +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import { useCreateReply } from '@hooks/tanstack-query/station-details/reviews/useCreateReply'; + +import Box from '@common/Box'; +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import ContentField from '@ui/StationDetailsWindow/reviews/common/ContentField'; + +import { DEFAULT_TOKEN, MAX_REVIEW_CONTENT_LENGTH, MIN_REVIEW_CONTENT_LENGTH } from '@constants'; + +interface ReplyCreateProps { + stationId: string; + reviewId: number; +} + +const ReplyCreate = ({ stationId, reviewId }: ReplyCreateProps) => { + const { isCreateReplyLoading, createReply } = useCreateReply(stationId); + const [content, setContent] = useState(''); + const [isReplyCreateOpen, setIsReplyCreateOpen] = useState(true); + const memberId = memberInfoStore.getState()?.memberId; + + useEffect(() => { + if (isCreateReplyLoading && isReplyCreateOpen) { + setContent(''); + setIsReplyCreateOpen(false); + } + }, [isCreateReplyLoading]); + + const handleClickCreate = () => { + createReply({ content, reviewId: reviewId }); + setContent(''); + }; + + if (!isReplyCreateOpen || memberId === DEFAULT_TOKEN) { + return <></>; + } + + return ( + <> + {isCreateReplyLoading ? ( + <> + <ButtonNext variant="contained" disabled fullWidth> + 등록중 ... + </ButtonNext> + </> + ) : ( + <> + <Box mt={2} ml={8} mr={4} p={2} pl={4} css={replyCreateCss}> + <ContentField content={content} setContent={setContent} /> + <FlexBox justifyContent="end"> + <ButtonNext + size="xs" + variant="contained" + disabled={ + isCreateReplyLoading || + content.length < MIN_REVIEW_CONTENT_LENGTH || + content.length > MAX_REVIEW_CONTENT_LENGTH + } + onClick={() => handleClickCreate()} + > + 등록 + </ButtonNext> + </FlexBox> + </Box> + </> + )} + </> + ); +}; + +const replyCreateCss = css` + border-left: 1px solid #66666666; +`; + +export default ReplyCreate; diff --git a/src/components/ui/StationDetailsWindow/reviews/replies/ReplyList.tsx b/src/components/ui/StationDetailsWindow/reviews/replies/ReplyList.tsx new file mode 100644 index 00000000..d17efcf7 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/replies/ReplyList.tsx @@ -0,0 +1,72 @@ +import React from 'react'; + +import { useInfiniteReplies } from '@hooks/tanstack-query/station-details/reviews/useInfiniteReplies'; + +import Box from '@common/Box'; +import ButtonNext from '@common/ButtonNext'; +import Text from '@common/Text'; + +import ReplyCard from '@ui/StationDetailsWindow/reviews/replies/ReplyCard'; +import ReplyListLoading from '@ui/StationDetailsWindow/reviews/replies/ReplyListLoading'; + +interface ReplyListProps { + stationId: string; + reviewId: number; + previewMode: boolean; +} + +const ReplyList = ({ stationId, reviewId, previewMode }: ReplyListProps) => { + const { status, data, error, isFetchingNextPage, fetchNextPage, hasNextPage } = + useInfiniteReplies(reviewId); + + return ( + <> + {status === 'loading' ? ( + <ReplyListLoading count={1} /> + ) : status === 'error' ? ( + <Text variant="caption" align="center"> + Error: {JSON.stringify(error)} + </Text> + ) : ( + <> + {data.pages.map((page) => ( + <div key={page.nextPage}> + {page.replies.length === 0 && ( + <Text m={10} align="center"> + 등록 된 답글이 없습니다. + </Text> + )} + {page.replies.map((reply, index) => ( + <ReplyCard + key={index} + stationId={stationId} + reply={reply} + reviewId={reviewId} + previewMode={previewMode} + /> + ))} + </div> + ))} + {isFetchingNextPage && <ReplyListLoading count={1} />} + {hasNextPage && ( + <Box pl={8} pr={4} my={3}> + <ButtonNext + size="xs" + py={2} + color="secondary" + variant="contained" + onClick={() => fetchNextPage()} + disabled={!hasNextPage || isFetchingNextPage} + fullWidth + > + {isFetchingNextPage ? '로딩중...' : '답글 더 보기'} + </ButtonNext> + </Box> + )} + </> + )} + </> + ); +}; + +export default ReplyList; diff --git a/src/components/ui/StationDetailsWindow/reviews/replies/ReplyListLoading.tsx b/src/components/ui/StationDetailsWindow/reviews/replies/ReplyListLoading.tsx new file mode 100644 index 00000000..fb4da5d3 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/replies/ReplyListLoading.tsx @@ -0,0 +1,16 @@ +import ReplyCardSkeleton from '@ui/StationDetailsWindow/reviews/replies/ReplyCardSkeleton'; + +export interface ReplyListLoadingProps { + count: number; +} + +const ReplyListLoading = ({ count }: ReplyListLoadingProps) => { + return ( + <> + {Array.from({ length: count }, (_, index) => ( + <ReplyCardSkeleton key={index} /> + ))} + </> + ); +}; +export default ReplyListLoading; diff --git a/src/components/ui/StationDetailsWindow/reviews/replies/ReplyModify.tsx b/src/components/ui/StationDetailsWindow/reviews/replies/ReplyModify.tsx new file mode 100644 index 00000000..20826d3a --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/replies/ReplyModify.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react'; + +import { useModifyReply } from '@hooks/tanstack-query/station-details/reviews/useModifyReply'; + +import Box from '@common/Box'; +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import ContentField from '@ui/StationDetailsWindow/reviews/common/ContentField'; + +import { MAX_REVIEW_CONTENT_LENGTH, MIN_REVIEW_CONTENT_LENGTH } from '@constants'; + +import type { Reply } from '@type'; + +interface ReplyModifyProps { + stationId: string; + reply: Reply; + reviewId: number; + setIsModifyMode: (newMode: boolean) => void; +} + +const ReplyModify = ({ stationId, reply, reviewId, setIsModifyMode }: ReplyModifyProps) => { + const [content, setContent] = useState(reply.content); + const { modifyReply, isModifyReplyLoading } = useModifyReply(stationId, reviewId); + + const handleClickModifyReply = () => { + modifyReply({ replyId: reply.replyId, content, reviewId }); + setIsModifyMode(false); + }; + + return ( + <> + <Box mt={2} ml={8} mr={4} p={2} border> + <ContentField content={content} setContent={setContent} /> + <FlexBox justifyContent="end"> + <ButtonNext size="xs" variant="outlined" onClick={() => setIsModifyMode(false)}> + 닫기 + </ButtonNext> + <ButtonNext + size="xs" + variant="contained" + disabled={ + isModifyReplyLoading || + content.length < MIN_REVIEW_CONTENT_LENGTH || + content.length > MAX_REVIEW_CONTENT_LENGTH + } + onClick={() => handleClickModifyReply()} + > + 등록 + </ButtonNext> + </FlexBox> + </Box> + </> + ); +}; + +export default ReplyModify; diff --git a/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCard.stories.tsx b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCard.stories.tsx new file mode 100644 index 00000000..a84a5098 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCard.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta } from '@storybook/react'; + +import Box from '../../../../common/Box'; +import type { ReviewCardProps } from './ReviewCard'; +import ReviewCard from './ReviewCard'; +import ReviewCardSkeleton from './ReviewCardSkeleton'; + +const meta = { + title: 'UI/ReviewCard', + component: ReviewCard, + tags: ['autodocs'], + args: { + review: { + content: + '후면 주차가 어려운 충전소에요. 후면 주차가 어려운 충전소에요. 후면 주차가 어려운 충전소에요. ', + isDeleted: false, + isUpdated: false, + latestUpdateDate: '2023-07-30T15:11:40+00:00', + ratings: 4, + replySize: 3, + reviewId: 0, + memberId: 23884823, + }, + previewMode: false, + }, + argTypes: { + review: { + description: 'Review 객체를 전달하면 카드를 만듭니다.', + }, + previewMode: { + description: '수정 및 삭제 컨트롤러를 제거할 수 있습니다.', + }, + }, +} satisfies Meta<typeof ReviewCard>; + +export default meta; + +export const Default = (args: ReviewCardProps) => { + return ( + <Box width={80}> + <ReviewCard {...args} /> + <ReviewCard {...args} /> + <ReviewCard {...args} /> + </Box> + ); +}; + +export const Skeleton = (args: ReviewCardProps) => { + return ( + <Box width={80}> + <ReviewCard {...args} /> + <ReviewCardSkeleton /> + </Box> + ); +}; diff --git a/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCard.tsx b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCard.tsx new file mode 100644 index 00000000..b4ef5152 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCard.tsx @@ -0,0 +1,135 @@ +import { TrashIcon } from '@heroicons/react/20/solid'; +import { PencilSquareIcon, StarIcon } from '@heroicons/react/24/solid'; + +import { useEffect, useState } from 'react'; + +import { calculateLatestUpdateTime } from '@utils/index'; + +import { memberInfoStore } from '@stores/login/memberInfoStore'; + +import { useRemoveReview } from '@hooks/tanstack-query/station-details/reviews/useRemoveReview'; + +import Box from '@common/Box'; +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import ReplyCreate from '@ui/StationDetailsWindow/reviews/replies/ReplyCreate'; +import ReplyList from '@ui/StationDetailsWindow/reviews/replies/ReplyList'; +import ReviewModify from '@ui/StationDetailsWindow/reviews/reviews/ReviewModify'; + +import type { Review } from '@type'; + +export interface ReviewCardProps { + stationId: string; + review: Review; + previewMode?: boolean; +} + +const ReviewCard = ({ stationId, review, previewMode }: ReviewCardProps) => { + const { isRemoveReviewLoading, removeReview } = useRemoveReview(stationId, review.reviewId); + const [isRepliesOpen, setIsRepliesOpen] = useState(false); + const [isModifyMode, setIsModifyMode] = useState(false); + const memberId = memberInfoStore.getState()?.memberId; + + const isReviewOwner = memberId === review.memberId; + const isEditable = isReviewOwner && !review.isDeleted && !previewMode; + + const handleClickRemoveReviewButton = () => { + if (confirm('정말로 삭제하시겠습니까?')) { + removeReview({ reviewId: review.reviewId }); + } + }; + + useEffect(() => { + setIsRepliesOpen(false); + }, [review]); + + return ( + <> + {isModifyMode ? ( + <ReviewModify stationId={stationId} review={review} setIsModifyMode={setIsModifyMode} /> + ) : ( + <Box my={6}> + <Box mb={3}> + <Box px={2}> + <FlexBox justifyContent="between"> + <Box> + <Text variant="label" mb={2} weight="regular"> + {review.isDeleted ? '(알수없음)' : `${review.memberId}님`} + {!review.isDeleted && ( + <> + (<StarIcon width={10} display="inline-block" fill="#fc4c4e" /> + <Text tag="span" variant="label"> + {review.ratings} + </Text> + ) + </> + )} + </Text> + + <Text variant="caption" mb={3}> + {!review.isDeleted && calculateLatestUpdateTime(review.latestUpdateDate)} + {review.isDeleted ? ' (삭제됨)' : review.isUpdated ? ' (수정됨)' : ''} + </Text> + </Box> + <FlexBox> + {!isEditable ? ( + <></> + ) : ( + <> + <ButtonNext + size="xs" + variant="text" + color="secondary" + onClick={() => setIsModifyMode(true)} + > + <PencilSquareIcon width={15} display="inline-block" /> + </ButtonNext> + <ButtonNext + disabled={isRemoveReviewLoading} + size="xs" + variant="text" + color="secondary" + onClick={() => handleClickRemoveReviewButton()} + > + {isRemoveReviewLoading ? ( + '삭제중' + ) : ( + <TrashIcon width={15} display="inline-block" /> + )} + </ButtonNext> + </> + )} + </FlexBox> + </FlexBox> + <Text variant="body" mb={3}> + {review.isDeleted ? '(삭제된 리뷰입니다.)' : review.content} + </Text> + </Box> + {!previewMode && ( + <ButtonNext size="xs" variant="text" onClick={() => setIsRepliesOpen(!isRepliesOpen)}> + {isRepliesOpen + ? `닫기` + : `답글 ${review.replySize > 0 ? review.replySize : '달기'}`} + </ButtonNext> + )} + </Box> + + {isRepliesOpen && ( + <> + <ReplyList + reviewId={review.reviewId} + stationId={stationId} + previewMode={previewMode} + /> + <ReplyCreate stationId={stationId} reviewId={review.reviewId} /> + </> + )} + </Box> + )} + </> + ); +}; + +export default ReviewCard; diff --git a/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCardSkeleton.tsx b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCardSkeleton.tsx new file mode 100644 index 00000000..097dbc42 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCardSkeleton.tsx @@ -0,0 +1,17 @@ +import Box from '@common/Box'; +import Skeleton from '@common/Skeleton'; + +const ReviewCardSkeleton = () => { + return ( + <> + <Box p={2} mb={4}> + <Skeleton width="10rem" height="1.2rem" mb={1} /> + <Skeleton width="5rem" height="1.2rem" mb={2} /> + <Skeleton width="20rem" height="1.2rem" mb={4} /> + <Skeleton width="5rem" height="1.2rem" /> + </Box> + </> + ); +}; + +export default ReviewCardSkeleton; diff --git a/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCardsLoading.tsx b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCardsLoading.tsx new file mode 100644 index 00000000..21731476 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCardsLoading.tsx @@ -0,0 +1,16 @@ +import ReviewCardSkeleton from '@ui/StationDetailsWindow/reviews/reviews/ReviewCardSkeleton'; + +export interface ReviewCardsLoadingProps { + count: number; +} + +const ReviewCardsLoading = ({ count }: ReviewCardsLoadingProps) => { + return ( + <> + {Array.from({ length: count }, (_, index) => ( + <ReviewCardSkeleton key={index} /> + ))} + </> + ); +}; +export default ReviewCardsLoading; diff --git a/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCreate.tsx b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCreate.tsx new file mode 100644 index 00000000..81878327 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCreate.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react'; + +import { modalActions } from '@stores/layout/modalStore'; +import { memberInfoStore } from '@stores/login/memberInfoStore'; +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import { useCreateReview } from '@hooks/tanstack-query/station-details/reviews/useCreateReview'; + +import Box from '@common/Box'; +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; + +import ContentField from '@ui/StationDetailsWindow/reviews/common/ContentField'; +import HeaderWithRating from '@ui/StationDetailsWindow/reviews/common/HeaderWithRating'; + +import { DEFAULT_TOKEN, MAX_REVIEW_CONTENT_LENGTH, MIN_REVIEW_CONTENT_LENGTH } from '@constants'; + +interface ReviewCreateProps { + stationId: string; +} + +const ReviewCreate = ({ stationId }: ReviewCreateProps) => { + const { createReview, isCreateReviewLoading } = useCreateReview(stationId); + const [isReviewCreateOpen, setIsReviewCreateOpen] = useState(false); + const [stars, setStars] = useState(5); + const [content, setContent] = useState(''); + const memberId = memberInfoStore.getState().memberId; + + useEffect(() => { + if (!isCreateReviewLoading && isReviewCreateOpen) { + setContent(''); + setIsReviewCreateOpen(false); + } + }, [isCreateReviewLoading]); + + const handleClickReviewCreateCloseButton = () => { + modalActions.closeModal(); + }; + + const handleClickReviewCreateOpenButton = () => { + setIsReviewCreateOpen(!isReviewCreateOpen); + }; + + const handleClickReviewCreateButton = () => { + if ( + content.length >= MIN_REVIEW_CONTENT_LENGTH && + content.length <= MAX_REVIEW_CONTENT_LENGTH + ) { + createReview({ stationId, ratings: stars, content }); + } + }; + + return ( + <Box p={4} border> + {isReviewCreateOpen && ( + <> + <HeaderWithRating stars={stars} setStars={setStars} title="후기 등록하기" /> + <ContentField content={content} setContent={setContent} /> + </> + )} + <FlexBox nowrap> + {isReviewCreateOpen ? ( + <> + <ButtonNext + variant="outlined" + color="error" + fullWidth + onClick={handleClickReviewCreateOpenButton} + > + 후기 그만작성하기 + </ButtonNext> + <ButtonNext + disabled={ + isCreateReviewLoading || + content.length < MIN_REVIEW_CONTENT_LENGTH || + content.length > MAX_REVIEW_CONTENT_LENGTH + } + variant="contained" + fullWidth + onClick={handleClickReviewCreateButton} + > + {isCreateReviewLoading ? '처리중...' : '등록'} + </ButtonNext> + </> + ) : ( + <> + <ButtonNext variant="outlined" fullWidth onClick={handleClickReviewCreateCloseButton}> + 닫기 + </ButtonNext> + <ButtonNext + variant="contained" + disabled={memberId === DEFAULT_TOKEN} + fullWidth + onClick={handleClickReviewCreateOpenButton} + > + 후기 작성하기 + </ButtonNext> + </> + )} + </FlexBox> + </Box> + ); +}; + +export default ReviewCreate; diff --git a/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewList.tsx b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewList.tsx new file mode 100644 index 00000000..2ad0ee2f --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewList.tsx @@ -0,0 +1,96 @@ +import { css } from 'styled-components'; + +import { useEffect, useRef } from 'react'; + +import { useInfiniteReviews } from '@hooks/tanstack-query/station-details/reviews/useInfiniteReviews'; + +import Box from '@common/Box'; +import Text from '@common/Text'; + +import ReviewCard from '@ui/StationDetailsWindow/reviews/reviews/ReviewCard'; +import ReviewCardsLoading from '@ui/StationDetailsWindow/reviews/reviews/ReviewCardsLoading'; +import ReviewCreate from '@ui/StationDetailsWindow/reviews/reviews/ReviewCreate'; + +export interface ReviewListProps { + stationId: string; +} + +export default function ReviewList({ stationId }: ReviewListProps) { + const { status, data, error, isFetchingNextPage, fetchNextPage, hasNextPage } = + useInfiniteReviews(stationId); + + const loadMoreElementRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }); + }); + + if (loadMoreElementRef.current) { + observer.observe(loadMoreElementRef.current); + } + + return () => { + if (loadMoreElementRef.current) { + observer.unobserve(loadMoreElementRef.current); + } + }; + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + return ( + <> + <Box p={4} css={reviewListCss}> + <Text variant="title" mt={2} mb={5} px={4}> + 충전소 후기 보기 + </Text> + {status === 'loading' ? ( + <ReviewCardsLoading count={1} /> + ) : status === 'error' ? ( + <Text variant="caption" align="center"> + Error: {JSON.stringify(error)} + </Text> + ) : ( + <> + {data.pages.map((page) => ( + <div key={page.nextPage}> + {page.reviews.length === 0 && ( + <Text m={10} align="center"> + 등록 된 후기가 없습니다. + </Text> + )} + {page.reviews.map((review) => ( + <ReviewCard + key={review.reviewId} + stationId={stationId} + review={review} + previewMode={false} + /> + ))} + </div> + ))} + {isFetchingNextPage && <ReviewCardsLoading count={10} />} + <div ref={loadMoreElementRef} /> + </> + )} + </Box> + + <Box css={modalButtonCss}> + <ReviewCreate stationId={stationId} /> + </Box> + </> + ); +} + +const modalButtonCss = css` + position: sticky; + bottom: 0; + background: white; +`; + +const reviewListCss = css` + width: 40rem; +`; diff --git a/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewModify.tsx b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewModify.tsx new file mode 100644 index 00000000..6b9bd414 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewModify.tsx @@ -0,0 +1,37 @@ +import { useState } from 'react'; + +import Box from '@common/Box'; + +import ContentField from '@ui/StationDetailsWindow/reviews/common/ContentField'; +import HeaderWithRating from '@ui/StationDetailsWindow/reviews/common/HeaderWithRating'; +import ReviewModifyButton from '@ui/StationDetailsWindow/reviews/reviews/ReviewModifyButton'; + +import type { Review } from '@type'; + +export interface ReviewModifyProps { + stationId: string; + review: Review; + setIsModifyMode: (isModifyMode: boolean) => void; +} + +const ReviewModify = ({ stationId, review, setIsModifyMode }: ReviewModifyProps) => { + const [stars, setStars] = useState(review.ratings); + const [content, setContent] = useState(review.content); + + return ( + <Box border p={2}> + <HeaderWithRating stars={stars} setStars={setStars} title="후기 수정하기" /> + <ContentField content={content} setContent={setContent} /> + + <ReviewModifyButton + stationId={stationId} + review={review} + setIsModifyMode={setIsModifyMode} + stars={stars} + content={content} + /> + </Box> + ); +}; + +export default ReviewModify; diff --git a/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewModifyButton.tsx b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewModifyButton.tsx new file mode 100644 index 00000000..ec0e2e18 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewModifyButton.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; + +import { useModifyReview } from '@hooks/tanstack-query/station-details/reviews/useModifyReview'; + +import Box from '@common/Box'; +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; + +import ContentField from '@ui/StationDetailsWindow/reviews/common/ContentField'; +import HeaderWithRating from '@ui/StationDetailsWindow/reviews/common/HeaderWithRating'; + +import { MAX_REVIEW_CONTENT_LENGTH, MIN_REVIEW_CONTENT_LENGTH } from '@constants'; + +import type { Review } from '@type'; + +export interface ReviewModifyButtonProps { + stationId: string; + review: Review; + setIsModifyMode: (isModifyMode: boolean) => void; + stars: number; + content: string; +} + +const ReviewModifyButton = ({ + stationId, + review, + setIsModifyMode, + stars, + content, +}: ReviewModifyButtonProps) => { + const { modifyReview, isModifyReviewLoading } = useModifyReview(stationId, review.reviewId); + const handleClickCloseModifyMode = () => { + setIsModifyMode(false); + }; + + const handleClickModifyReview = () => { + modifyReview({ reviewId: review.reviewId, ratings: stars, content: content }); + setIsModifyMode(false); + }; + + return ( + <FlexBox nowrap justifyContent="end"> + <ButtonNext size="xs" variant="outlined" onClick={() => handleClickCloseModifyMode()}> + 취소 + </ButtonNext> + <ButtonNext + size="xs" + disabled={ + isModifyReviewLoading || + content.length < MIN_REVIEW_CONTENT_LENGTH || + content.length > MAX_REVIEW_CONTENT_LENGTH + } + variant="contained" + onClick={() => handleClickModifyReview()} + > + {isModifyReviewLoading ? '처리중...' : '등록'} + </ButtonNext> + </FlexBox> + ); +}; + +export default ReviewModifyButton; diff --git a/src/components/ui/StationDetailsWindow/station/StationInformation.tsx b/src/components/ui/StationDetailsWindow/station/StationInformation.tsx new file mode 100644 index 00000000..d676ef27 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/station/StationInformation.tsx @@ -0,0 +1,93 @@ +import styled, { css } from 'styled-components'; + +import Box from '@common/Box'; +import Text from '@common/Text'; + +import type { StationDetails } from '@type'; + +export interface StationInformationProps { + station: StationDetails; +} + +const StationInformation = ({ station }: StationInformationProps) => { + const { + stationName, + companyName, + contact, + isParkingFree, + operatingTime, + address, + detailLocation, + isPrivate, + privateReason, + } = station; + + return ( + <Box> + <Box css={lineHeight}> + <Text variant="label" mb={1.5}> + {companyName} + </Text> + <Text variant="title" mb={1}> + {stationName} + </Text> + <Text variant="body" mb={1.5}> + {!address || address?.length === 0 ? '주소 미확인' : address} + </Text> + <Text variant="caption" mb={1}> + {!detailLocation || detailLocation?.length === 0 ? '상세주소 미확인' : detailLocation} + </Text> + </Box> + <Divider /> + <Box> + <Box my={3}> + <Text variant="body" weight="bold" mb={2}> + 운영시간 + </Text> + <Text variant="label" lineHeight={1.3} color="#585858"> + {operatingTime?.length > 0 ? operatingTime : '운영시간 미확인'} + </Text> + </Box> + + <Box my={3}> + <Text variant="body" weight="bold" mb={2}> + 연락처 + </Text> + <Text variant="label" color="#585858"> + {contact?.length > 0 ? contact : '연락처 없음'} + </Text> + </Box> + + <Box my={3}> + <Text variant="body" weight="bold" mb={2}> + 주차비 + </Text> + <Text variant="label" color="#585858"> + {isParkingFree ? '무료' : '유료'} + </Text> + </Box> + + <Box my={3}> + <Text variant="body" weight="bold" mb={2}> + 사용 제한 여부 + </Text> + <Text variant="label" color="#585858" lineHeight={1.4}> + {isPrivate + ? `사용 제한됨 (사유: ${privateReason?.length > 0 ? privateReason : '미확인'})` + : '누구나 사용가능'} + </Text> + </Box> + </Box> + </Box> + ); +}; + +const Divider = styled.hr` + margin: 1.5rem 0 1.5rem 0; +`; + +const lineHeight = css` + line-height: 1.2; +`; + +export default StationInformation; diff --git a/src/components/ui/StationDetailsWindow/station/StationInformationSkeleton.tsx b/src/components/ui/StationDetailsWindow/station/StationInformationSkeleton.tsx new file mode 100644 index 00000000..2833ac31 --- /dev/null +++ b/src/components/ui/StationDetailsWindow/station/StationInformationSkeleton.tsx @@ -0,0 +1,28 @@ +import Box from '@common/Box'; +import Skeleton from '@common/Skeleton'; + +const StationInformationSkeleton = () => { + return ( + <Box p={3}> + <Box px={1}> + <Skeleton width="10rem" height="1.4rem" /> + <Skeleton width="15rem" height="2.2rem" my={1} /> + <Skeleton width="20rem" height="1.6rem" mb={1} /> + <Skeleton width="10rem" height="1.2rem" /> + </Box> + <hr /> + <Box px={1}> + <Skeleton width="7rem" height="1.6rem" mt={2} mb={1} /> + <Skeleton width="20rem" height="1.5rem" mb={2} /> + <Skeleton width="7rem" height="1.6rem" mt={2} mb={1} /> + <Skeleton width="20rem" height="1.5rem" mb={2} /> + <Skeleton width="7rem" height="1.6rem" mt={2} mb={1} /> + <Skeleton width="20rem" height="1.5rem" mb={2} /> + <Skeleton width="7rem" height="1.6rem" mt={2} mb={1} /> + <Skeleton width="20rem" height="1.5rem" mb={2} /> + </Box> + </Box> + ); +}; + +export default StationInformationSkeleton; diff --git a/src/components/ui/StationDetailsWindow/validation.ts b/src/components/ui/StationDetailsWindow/validation.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/components/ui/StationInfoWindow/StationInfo.stories.tsx b/src/components/ui/StationInfoWindow/StationInfo.stories.tsx new file mode 100644 index 00000000..8242615c --- /dev/null +++ b/src/components/ui/StationInfoWindow/StationInfo.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta } from '@storybook/react'; +import styled from 'styled-components'; + +import Text from '@common/Text'; + +import type { Charger, StationDetails } from '@type'; + +import type { StationInfoProps } from './StationInfo'; +import StationInfo from './StationInfo'; + +const meta = { + title: 'UI/StationInfo', + component: StationInfo, + args: { + stationDetails: { + address: '서울특별시 강남구 테헤란로87길 22', + chargers: [ + { + capacity: 50, + latestUpdateTime: '2021-08-01T00:00:00.000Z', + method: '단독', + price: 0, + state: 'STANDBY', + type: 'DC_FAST', + }, + ] as Charger[], + companyName: '에스트래픽', + contact: '1566-1704', + detailLocation: '지하 4층 08번 기둥', + isParkingFree: false, + isPrivate: true, + latitude: 0, + longitude: 0, + operatingTime: '24시간 이용가능', + privateReason: '', + reportCount: 0, + stationId: 'ab12345', + stationName: '한국도심공항', + stationState: '내일부터 공사합니다.', + } as StationDetails, + handleCloseStationWindow: () => { + alert('마커 위의 충전소 정보창이 닫혔습니다.'); + }, + handleOpenStationDetail: () => { + alert('충전소 상세 정보창이 열렸습니다.'); + }, + }, + argTypes: { + handleCloseStationWindow: { + description: '마커 위의 충전소 정보창을 닫을 수 있습니다.', + }, + handleOpenStationDetail: { + description: '충전소 상세 정보창을 열 수 있습니다. 모바일에서만 보이는 버튼입니다.', + }, + }, +} satisfies Meta<typeof StationInfo>; + +export default meta; + +export const Default = (args: StationInfoProps) => { + return ( + <> + <Container> + <StationInfo {...args} /> + </Container> + <Text mt={5}>위 컨테이너는 실제 구글 api 디자인을 가져온 것입니다.</Text> + </> + ); +}; + +const Container = styled.div` + width: 32rem; + overflow: hidden; + background-color: white; + border-radius: 8px; + box-shadow: 0 2px 7px 1px rgba(0, 0, 0, 0.3); + line-height: normal; +`; diff --git a/src/components/ui/StationInfoWindow/StationInfo.tsx b/src/components/ui/StationInfoWindow/StationInfo.tsx new file mode 100644 index 00000000..23c6b0ee --- /dev/null +++ b/src/components/ui/StationInfoWindow/StationInfo.tsx @@ -0,0 +1,136 @@ +import { XMarkIcon } from '@heroicons/react/24/outline'; + +import type { MouseEvent } from 'react'; +import { HiChevronRight } from 'react-icons/hi2'; + +import { MARKER_COLORS } from '@marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeineMarker.style'; + +import Box from '@common/Box'; +import Button from '@common/Button'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import { QUICK_CHARGER_CAPACITY_THRESHOLD } from '@constants/chargers'; + +import type { Charger, StationDetails } from '@type'; + +export interface StationInfoProps { + stationDetails: StationDetails; + handleOpenStationDetail: () => void; + handleCloseStationWindow: (event: MouseEvent<HTMLButtonElement>) => void; +} + +const getChargerCountsAndAvailability = (chargers: Charger[]) => { + const isAvailable = chargers.some(({ state }) => state === 'STANDBY'); + + const standardChargers = chargers.filter( + ({ capacity }) => capacity < QUICK_CHARGER_CAPACITY_THRESHOLD + ); + const quickChargers = chargers.filter( + ({ capacity }) => capacity >= QUICK_CHARGER_CAPACITY_THRESHOLD + ); + + const availableStandardChargerCount = standardChargers.filter( + ({ state }) => state === 'STANDBY' + ).length; + const availableQuickChargerCount = quickChargers.filter( + ({ state }) => state === 'STANDBY' + ).length; + + const standardChargerCount = standardChargers.length; + const quickChargerCount = quickChargers.length; + + return { + isAvailable, + availableStandardChargerCount, + availableQuickChargerCount, + standardChargerCount, + quickChargerCount, + }; +}; + +const StationInfo = ({ + stationDetails, + handleOpenStationDetail, + handleCloseStationWindow, +}: StationInfoProps) => { + const { address, chargers, companyName, isParkingFree, isPrivate, stationId, stationName } = + stationDetails; + + const { + isAvailable, + availableStandardChargerCount, + availableQuickChargerCount, + standardChargerCount, + quickChargerCount, + } = getChargerCountsAndAvailability(chargers); + + const availabilityColor = MARKER_COLORS[isAvailable ? 'available' : 'noAvailable']; + + return ( + <Box tag="article" key={stationId} mt={2} mx={5} mb={3} css={{ fontSize: '10%' }}> + <FlexBox justifyContent="between" alignItems="center"> + <FlexBox alignItems="center"> + <Box + width={3} + height={3} + mt={0.5} + border + borderWidth={1.5} + borderColor={availabilityColor.border} + borderRadius="50%" + bgColor={availabilityColor.background} + /> + + <Text variant="caption" color={availabilityColor.background}> + {isAvailable ? '이용 가능' : '이용 불가'} + </Text> + <Text tag="span">―</Text> + {standardChargerCount !== 0 && ( + <Text fontSize={1.2}> + 완속 {availableStandardChargerCount}/{standardChargerCount} + </Text> + )} + {quickChargerCount !== 0 && ( + <Text fontSize={1.2}> + 급속 {availableQuickChargerCount}/{quickChargerCount} + </Text> + )} + </FlexBox> + + <Button mr={-1.5} onClick={handleCloseStationWindow}> + <XMarkIcon width={28} /> + </Button> + </FlexBox> + + <Text tag="h4" align="left" title={stationName} lineClamp={1} fontSize={1.3}> + {companyName} + </Text> + <Text tag="h3" align="left" variant="h5" title={stationName} lineClamp={1}> + {stationName} + </Text> + <Text variant="label" align="left" lineClamp={1} mb={2} color="#585858"> + {address === 'null' || !address ? '주소 미확인' : address} + </Text> + <FlexBox columnGap={3}> + <Text variant="pillbox" align="left"> + {isPrivate ? '이용 제한' : '외부인 개방'} + </Text> + <Text variant="pillbox" align="left"> + {isParkingFree ? '무료 주차' : '유료 주차'} + </Text> + </FlexBox> + + <Button onClick={handleOpenStationDetail} mt={3} hover> + <FlexBox alignItems="center"> + <Text variant="label" mb={0.75}> + 상세 정보 보기 + </Text> + <HiChevronRight /> + </FlexBox> + </Button> + </Box> + ); +}; + +export default StationInfo; diff --git a/src/components/ui/StationInfoWindow/StationInfoWindow.tsx b/src/components/ui/StationInfoWindow/StationInfoWindow.tsx new file mode 100644 index 00000000..0ace9196 --- /dev/null +++ b/src/components/ui/StationInfoWindow/StationInfoWindow.tsx @@ -0,0 +1,56 @@ +import type { MouseEvent } from 'react'; + +import { useExternalValue } from '@utils/external-state'; + +import { getStationInfoWindowStore } from '@stores/google-maps/stationInfoWindowStore'; + +import FlexBox from '@common/FlexBox'; +import Loader from '@common/Loader'; + +import { useNavigationBar } from '@ui/Navigator/NavigationBar/hooks/useNavigationBar'; + +import StationDetailsWindow from '../StationDetailsWindow'; +import StationInfo from './StationInfo'; +import { useFetchStationDetails } from './useFetchStationDetails'; + +export interface StationInfoWindowProps { + selectedStationId: string; +} + +const StationInfoWindow = ({ selectedStationId }: StationInfoWindowProps) => { + const infoWindowInstance = useExternalValue(getStationInfoWindowStore()); + const { openLastPanel } = useNavigationBar(); + + const { stationDetails, isLoading } = useFetchStationDetails(selectedStationId); + + const handleOpenStationDetail = () => { + openLastPanel(<StationDetailsWindow stationId={selectedStationId} />); + }; + + const handleCloseStationWindow = (event: MouseEvent<HTMLButtonElement>) => { + event.stopPropagation(); + infoWindowInstance.infoWindowInstance.close(); + }; + + /** + * TODO: 추후에 스테이션 상세 정보를 불러오는데 실패했을 때의 처리를 추가해야 합니다. + */ + + if (isLoading || stationDetails === null) { + return ( + <FlexBox justifyContent="center" alignItems="center" height="17.52rem"> + <Loader size="xxl" /> + </FlexBox> + ); + } + + return ( + <StationInfo + stationDetails={stationDetails} + handleOpenStationDetail={handleOpenStationDetail} + handleCloseStationWindow={handleCloseStationWindow} + /> + ); +}; + +export default StationInfoWindow; diff --git a/src/components/ui/StationInfoWindow/SummaryButtons.tsx b/src/components/ui/StationInfoWindow/SummaryButtons.tsx new file mode 100644 index 00000000..bc12f954 --- /dev/null +++ b/src/components/ui/StationInfoWindow/SummaryButtons.tsx @@ -0,0 +1,66 @@ +import { css } from 'styled-components'; + +import type { MouseEvent } from 'react'; + +import useMediaQueries from '@hooks/useMediaQueries'; + +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; + +import { MOBILE_BREAKPOINT } from '@constants'; + +export interface SummaryButtonsProps { + handleCloseStationWindow: (event: MouseEvent<HTMLButtonElement>) => void; + handleOpenStationDetail: () => void; +} + +const SummaryButtons = ({ + handleCloseStationWindow, + handleOpenStationDetail, +}: SummaryButtonsProps) => { + const screen = useMediaQueries(); + + return ( + <FlexBox + nowrap + direction="row" + justifyContent="between" + mt={2.5} + mb={screen.get('isMobile') ? 1 : 0} + > + <ButtonNext + variant="outlined" + size="xs" + color="secondary" + css={closeButtonCss} + onClick={handleCloseStationWindow} + > + 닫기 + </ButtonNext> + {screen.get('isMobile') && ( + <ButtonNext + variant="contained" + size="xs" + color="dark" + css={{ width: '68%' }} + onClick={handleOpenStationDetail} + > + 상세보기 + </ButtonNext> + )} + </FlexBox> + ); +}; + +const closeButtonCss = css` + width: 20%; + margin-left: auto; + border-color: #4d5053bf; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + width: 28%; + margin-left: 0; + } +`; + +export default SummaryButtons; diff --git a/src/components/ui/StationInfoWindow/index.tsx b/src/components/ui/StationInfoWindow/index.tsx new file mode 100644 index 00000000..4278d73c --- /dev/null +++ b/src/components/ui/StationInfoWindow/index.tsx @@ -0,0 +1,3 @@ +import StationInfoWindow from './StationInfoWindow'; + +export default StationInfoWindow; diff --git a/src/components/ui/StationInfoWindow/useFetchStationDetails.ts b/src/components/ui/StationInfoWindow/useFetchStationDetails.ts new file mode 100644 index 00000000..041f5872 --- /dev/null +++ b/src/components/ui/StationInfoWindow/useFetchStationDetails.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +import { fetchStationDetails } from '@hooks/tanstack-query/station-details/useStationDetails'; + +import type { StationDetails } from '@type'; + +export const useFetchStationDetails = (stationId: string) => { + const [stationDetails, setStationDetails] = useState<StationDetails>(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + setIsLoading(true); + + fetchStationDetails(stationId).then((stationDetailsResponse) => { + setStationDetails(stationDetailsResponse); + setIsLoading(false); + }); + }, [stationId]); + + /** + * TODO: 여기도 캐싱기능 추가 할 예정 + */ + + return { + stationDetails, + isLoading, + }; +}; diff --git a/src/components/ui/StationListWindow/StationList.tsx b/src/components/ui/StationListWindow/StationList.tsx new file mode 100644 index 00000000..1815537b --- /dev/null +++ b/src/components/ui/StationListWindow/StationList.tsx @@ -0,0 +1,103 @@ +import { css } from 'styled-components'; + +import { useEffect, useRef } from 'react'; + +import { useStationMarkers } from '@marker/SmallMediumDeltaAreaMarkerContainer/hooks/useStationMarkers'; + +import List from '@common/List'; +import Text from '@common/Text'; + +import { MOBILE_BREAKPOINT } from '@constants'; + +import StationSummaryCardList from './StationSummaryCardList'; +import EmptyStationsNotice from './fallbacks/EmptyStationsNotice'; +import StationListSkeletons from './fallbacks/StationListSkeletons'; +import { useInfiniteStationSummaries } from './hooks/useInfiniteStationSummaries'; +import { cachedStationSummariesActions } from './tools/cachedStationSummaries'; + +const StationList = () => { + const { data: filteredMarkers } = useStationMarkers(); + + const { data, isLoading, isError, isFetchingNextPage, fetchNextPage, hasNextPage, error } = + useInfiniteStationSummaries(filteredMarkers ?? []); + + const loadMoreElementRef = useRef(null); + const cachedStationSummaries = cachedStationSummariesActions.get(); + const isStationSummaryListEmpty = + data?.pages[0].stations.length + cachedStationSummaries.length === 0; + const isEndOfList = data?.pages.length !== 0 && !hasNextPage; + const canFetchNextPage = !isLoading && !isFetchingNextPage && hasNextPage; + + useEffect(() => { + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && hasNextPage) { + fetchNextPage(); + } + }); + }); + + if (loadMoreElementRef.current) { + observer.observe(loadMoreElementRef.current); + } + + return () => { + if (loadMoreElementRef.current) { + observer.unobserve(loadMoreElementRef.current); + } + }; + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + const renderStationSummaryCards = () => { + if (isLoading) { + return <StationListSkeletons />; + } + + if (isError) { + // TODO: Error handling 추후에 보완 + return ( + <Text variant="caption" align="center"> + Error: {JSON.stringify(error)} + </Text> + ); + } + + return ( + <> + <StationSummaryCardList cachedStationSummaries={cachedStationSummaries} data={data} /> + {isFetchingNextPage && <StationListSkeletons />} + {canFetchNextPage && <div ref={loadMoreElementRef} />} + + {isStationSummaryListEmpty ? ( + <EmptyStationsNotice /> + ) : ( + isEndOfList && ( + <Text align="center" my={10}> + 주변의 모든 충전소를 불러왔습니다. + </Text> + ) + )} + </> + ); + }; + + return <List css={stationListCss}>{renderStationSummaryCards()}</List>; +}; + +const stationListCss = css` + width: 34rem; + height: calc(100vh - 14.133rem); + border-top: 1.2rem solid var(--lighter-color); + border-bottom: 3.6rem solid var(--lighter-color); + border-top-left-radius: 20px; + border-top-right-radius: 20px; + background: var(--lighter-color); + overflow: auto; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + width: 100%; + height: 100vh; + } +`; + +export default StationList; diff --git a/src/components/ui/StationListWindow/StationListWindow.tsx b/src/components/ui/StationListWindow/StationListWindow.tsx new file mode 100644 index 00000000..9e174701 --- /dev/null +++ b/src/components/ui/StationListWindow/StationListWindow.tsx @@ -0,0 +1,51 @@ +import { XMarkIcon } from '@heroicons/react/24/outline'; +import { css } from 'styled-components'; + +import { lazy, Suspense } from 'react'; + +import Button from '@common/Button'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import { useNavigationBar } from '@ui/Navigator/NavigationBar/hooks/useNavigationBar'; +import { containerCss } from '@ui/ServerStationFilters/ServerStationFilters'; + +import { MOBILE_BREAKPOINT } from '@constants'; + +const StationList = lazy(() => import('./StationList')); + +const StationListWindow = () => { + const { closeBasePanel } = useNavigationBar(); + + return ( + <FlexBox css={[containerCss]} nowrap> + <Button css={xIconCss} onClick={closeBasePanel}> + <XMarkIcon width={32} /> + </Button> + <FlexBox css={headerCss}> + <Text variant="h5">주변 충전소</Text> + </FlexBox> + <Suspense> + <StationList /> + </Suspense> + </FlexBox> + ); +}; + +const headerCss = css` + margin: 4rem 0 2rem; + + width: calc(100vw - 6rem); + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + margin-top: 2.8rem; + } +`; + +const xIconCss = css` + position: absolute; + top: 2rem; + right: 3rem; +`; + +export default StationListWindow; diff --git a/src/components/ui/StationListWindow/StationSummaryCard.tsx b/src/components/ui/StationListWindow/StationSummaryCard.tsx new file mode 100644 index 00000000..17fde110 --- /dev/null +++ b/src/components/ui/StationListWindow/StationSummaryCard.tsx @@ -0,0 +1,94 @@ +import { css } from 'styled-components'; + +import { useStationInfoWindow } from '@hooks/google-maps/useStationInfoWindow'; +import useMediaQueries from '@hooks/useMediaQueries'; + +import Button from '@common/Button'; +import FlexBox from '@common/FlexBox'; +import ListItem from '@common/ListItem'; +import Text from '@common/Text'; + +import ChargingSpeedIcon from '@ui/ChargingSpeedIcon'; +import { useNavigationBar } from '@ui/Navigator/NavigationBar/hooks/useNavigationBar'; +import StationDetailsWindow from '@ui/StationDetailsWindow'; + +import type { StationSummary } from '@type'; + +interface Props { + station: StationSummary; + tag?: string; + $noPadding?: boolean; +} + +const StationSummaryCard = ({ station, tag, $noPadding }: Props) => { + const { openLastPanel, closeBasePanel } = useNavigationBar(); + const { openStationInfoWindow } = useStationInfoWindow(); + const screen = useMediaQueries(); + + const { + stationId, + stationName, + address, + companyName, + isPrivate, + isParkingFree, + operatingTime, + quickChargerCount, + } = station; + + return ( + <ListItem tag={tag} key={stationId} css={$noPadding && noPadding}> + <Button + width="100%" + shadow + css={foundStationButton} + onClick={() => { + openStationInfoWindow(stationId); + if (screen.get('isMobile')) { + closeBasePanel(); + } else { + openLastPanel(<StationDetailsWindow stationId={stationId} />); + } + }} + > + <FlexBox alignItems="start" justifyContent="between" nowrap columnGap={2.8}> + <article> + <Text tag="h4" align="left" title={stationName} lineClamp={1} fontSize={1.3}> + {companyName} + </Text> + <Text tag="h3" align="left" variant="h5" title={stationName} lineClamp={1}> + {stationName} + </Text> + <Text variant="label" align="left" lineClamp={1} mb={1} color="#585858"> + {address === 'null' || !address ? '주소 미확인' : address} + </Text> + <Text variant="caption" align="left" lineClamp={1} mb={3} color="#585858"> + {operatingTime} + </Text> + <FlexBox columnGap={3}> + <Text variant="pillbox" align="left"> + {isPrivate ? '이용 제한' : '외부인 개방'} + </Text> + <Text variant="pillbox" align="left"> + {isParkingFree ? '무료 주차' : '유료 주차'} + </Text> + </FlexBox> + </article> + {quickChargerCount !== 0 && <ChargingSpeedIcon />} + </FlexBox> + </Button> + </ListItem> + ); +}; + +const noPadding = css` + padding: 0; +`; + +const foundStationButton = css` + padding: 1.6rem 1.4rem 1.8rem; + box-shadow: 0 0.3rem 0.8rem 0 var(--gray-200-color); + border-radius: 10px; +`; + +export default StationSummaryCard; diff --git a/src/components/ui/StationListWindow/StationSummaryCardList.tsx b/src/components/ui/StationListWindow/StationSummaryCardList.tsx new file mode 100644 index 00000000..ccea9e5e --- /dev/null +++ b/src/components/ui/StationListWindow/StationSummaryCardList.tsx @@ -0,0 +1,32 @@ +import { Fragment } from 'react'; + +import type { InfiniteData } from '@tanstack/react-query'; + +import type { StationSummary } from '@type'; + +import StationSummaryCard from './StationSummaryCard'; +import type { StationSummaryResponse } from './hooks/useInfiniteStationSummaries'; + +export interface StationSummaryCardListProps { + cachedStationSummaries: StationSummary[]; + data: InfiniteData<StationSummaryResponse>; +} + +const StationSummaryCardList = ({ cachedStationSummaries, data }: StationSummaryCardListProps) => { + return ( + <> + {cachedStationSummaries.map((stationSummary) => ( + <StationSummaryCard key={stationSummary.stationId} station={stationSummary} /> + ))} + {data.pages.map((page) => ( + <Fragment key={page.nextPage}> + {page.stations.map((stationSummary) => ( + <StationSummaryCard key={stationSummary.stationId} station={stationSummary} /> + ))} + </Fragment> + ))} + </> + ); +}; + +export default StationSummaryCardList; diff --git a/src/components/ui/StationListWindow/fallbacks/EmptyStationsNotice.tsx b/src/components/ui/StationListWindow/fallbacks/EmptyStationsNotice.tsx new file mode 100644 index 00000000..418e4cce --- /dev/null +++ b/src/components/ui/StationListWindow/fallbacks/EmptyStationsNotice.tsx @@ -0,0 +1,21 @@ +import Box from '@common/Box'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +const EmptyStationsNotice = () => { + return ( + <FlexBox width="100%" height="100%" justifyContent="center" alignItems="center"> + <Box> + <Text align="center" css={{ fontSize: '20rem', fontWeight: 'bold' }} mb={7}> + 텅 + </Text> + <Text align="center" mb={2}> + 조회 가능한 충전소가 없습니다. + </Text> + <Text align="center">화면을 조금 더 확대하거나 장소를 이동해보세요.</Text> + </Box> + </FlexBox> + ); +}; + +export default EmptyStationsNotice; diff --git a/src/components/ui/StationListWindow/fallbacks/StationListSkeletons.tsx b/src/components/ui/StationListWindow/fallbacks/StationListSkeletons.tsx new file mode 100644 index 00000000..cfd43c31 --- /dev/null +++ b/src/components/ui/StationListWindow/fallbacks/StationListSkeletons.tsx @@ -0,0 +1,13 @@ +import StationSummaryCardSkeleton from '@ui/StationListWindow/fallbacks/StationSummaryCardSkeleton'; + +const StationListSkeletons = () => { + return ( + <> + {Array.from({ length: 10 }, (_, index) => ( + <StationSummaryCardSkeleton key={index} /> + ))} + </> + ); +}; + +export default StationListSkeletons; diff --git a/src/components/ui/StationListWindow/fallbacks/StationSummaryCardSkeleton.stories.tsx b/src/components/ui/StationListWindow/fallbacks/StationSummaryCardSkeleton.stories.tsx new file mode 100644 index 00000000..3148d627 --- /dev/null +++ b/src/components/ui/StationListWindow/fallbacks/StationSummaryCardSkeleton.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta } from '@storybook/react'; +import { css } from 'styled-components'; + +import List from '@common/List'; + +import StationSummaryCardSkeleton from './StationSummaryCardSkeleton'; + +const meta = { + title: 'UI/StationSummaryCardSkeleton', + component: StationSummaryCardSkeleton, +} satisfies Meta<typeof StationSummaryCardSkeleton>; + +export default meta; + +export const Default = () => { + return ( + <List css={listCss}> + <StationSummaryCardSkeleton /> + </List> + ); +}; + +const listCss = css` + left: 7rem; + bottom: 0; + width: 34rem; + border-top: 1.8rem solid var(--lighter-color); + border-bottom: 4rem solid var(--lighter-color); + border-top-left-radius: 30px; + border-top-right-radius: 30px; + background: var(--lighter-color); + overflow: auto; +`; diff --git a/src/components/ui/StationListWindow/fallbacks/StationSummaryCardSkeleton.tsx b/src/components/ui/StationListWindow/fallbacks/StationSummaryCardSkeleton.tsx new file mode 100644 index 00000000..4f2ff542 --- /dev/null +++ b/src/components/ui/StationListWindow/fallbacks/StationSummaryCardSkeleton.tsx @@ -0,0 +1,37 @@ +import { css } from 'styled-components'; + +import Box from '@common/Box'; +import Button from '@common/Button'; +import FlexBox from '@common/FlexBox'; +import ListItem from '@common/ListItem'; +import Skeleton from '@common/Skeleton'; + +const StationSummaryCardSkeleton = () => { + return ( + <ListItem> + <Button width="100%" shadow css={foundStationButton}> + <FlexBox alignItems="start" justifyContent="between" nowrap columnGap={2.8}> + <Box> + <Skeleton width="7rem" height="1.2rem" mb={2} /> + <Skeleton width="15rem" height="2.2rem" mb={3} /> + <Skeleton width="10rem" height="1.6rem" mb={2} /> + <Skeleton width="17rem" height="1.5rem" mb={3} /> + <FlexBox columnGap={3}> + <Skeleton width="8rem" height="2.2rem" /> + <Skeleton width="8rem" height="2.2rem" /> + </FlexBox> + </Box> + <Skeleton width="3.2rem" height="3.2rem" borderRadius="1rem" /> + </FlexBox> + </Button> + </ListItem> + ); +}; + +const foundStationButton = css` + padding: 1.6rem 1.4rem 1.8rem; + box-shadow: 0 0.3rem 0.8rem 0 var(--gray-200-color); + border-radius: 10px; +`; + +export default StationSummaryCardSkeleton; diff --git a/src/components/ui/StationListWindow/hooks/useInfiniteStationSummaries.ts b/src/components/ui/StationListWindow/hooks/useInfiniteStationSummaries.ts new file mode 100644 index 00000000..97c112ad --- /dev/null +++ b/src/components/ui/StationListWindow/hooks/useInfiniteStationSummaries.ts @@ -0,0 +1,64 @@ +import { useEffect } from 'react'; + +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; + +import { fetchStationSummaries } from '@hooks/fetch/fetchStationSummaries'; + +import { cachedStationSummariesActions } from '@ui/StationListWindow/tools/cachedStationSummaries'; + +import { QUERY_KEY_STATION_SUMMARIES } from '@constants/queryKeys'; + +import type { StationMarker, StationSummary } from '@type'; + +export interface StationSummaryResponse { + stations: StationSummary[]; + nextPage: number; +} + +const makeStationIdsChunks = (markers: StationMarker[]) => { + return markers + .filter((marker) => !cachedStationSummariesActions.has(marker.stationId)) + .reduce((acc: string[][], marker, index) => { + const REQUEST_CHUNK_SIZE = 10; + const chunkIndex = Math.floor(index / REQUEST_CHUNK_SIZE); + + if (!acc[chunkIndex]) { + acc[chunkIndex] = []; + } + + acc[chunkIndex].push(marker.stationId); + + return acc; + }, []); +}; + +export const useInfiniteStationSummaries = (markers: StationMarker[]) => { + const queryClient = useQueryClient(); + const stationIdChunks = makeStationIdsChunks(markers); + + useEffect(() => { + queryClient.removeQueries([QUERY_KEY_STATION_SUMMARIES]); + }, [markers]); + + return useInfiniteQuery<StationSummaryResponse>( + [QUERY_KEY_STATION_SUMMARIES], + async ({ pageParam = 0 }) => { + const stationIds = stationIdChunks[pageParam] ?? []; + + if (stationIds.length > 0) { + const stationSummaries = await fetchStationSummaries(stationIds); + cachedStationSummariesActions.add(stationSummaries); + + return { stations: stationSummaries, nextPage: pageParam + 1 }; + } else { + return { stations: [], nextPage: -1 }; + } + }, + { + getNextPageParam: (lastPage) => { + return lastPage.nextPage > 0 ? lastPage.nextPage : undefined; + }, + refetchOnWindowFocus: false, + } + ); +}; diff --git a/src/components/ui/StationListWindow/index.ts b/src/components/ui/StationListWindow/index.ts new file mode 100644 index 00000000..e6cc45cf --- /dev/null +++ b/src/components/ui/StationListWindow/index.ts @@ -0,0 +1,3 @@ +import StationListWindow from './StationListWindow'; + +export default StationListWindow; diff --git a/src/components/ui/StationListWindow/tools/cachedStationSummaries.ts b/src/components/ui/StationListWindow/tools/cachedStationSummaries.ts new file mode 100644 index 00000000..c8ceaa57 --- /dev/null +++ b/src/components/ui/StationListWindow/tools/cachedStationSummaries.ts @@ -0,0 +1,57 @@ +import { getDisplayPosition } from '@utils/google-maps'; + +import { getGoogleMapStore } from '@stores/google-maps/googleMapStore'; + +import { DELTA_MULTIPLE } from '@constants/googleMaps'; + +import type { StationSummary } from '@type'; + +const cachedStationSummaries = new Map<string, StationSummary>(); + +export const cachedStationSummariesActions = { + add: (stationSummaries: StationSummary[]) => { + stationSummaries.forEach((stationSummary) => { + cachedStationSummaries.set(stationSummary.stationId, stationSummary); + }); + }, + get: () => { + const googleMap = getGoogleMapStore().getState(); + const { latitude, latitudeDelta, longitude, longitudeDelta } = getDisplayPosition(googleMap); + + const northEastBoundary = { + latitude: latitude + latitudeDelta * DELTA_MULTIPLE, + longitude: longitude + longitudeDelta * DELTA_MULTIPLE, + }; + + const southWestBoundary = { + latitude: latitude - latitudeDelta * DELTA_MULTIPLE, + longitude: longitude - longitudeDelta * DELTA_MULTIPLE, + }; + + const isStationLatitudeWithinBounds = (station: StationSummary) => { + return ( + station.latitude > southWestBoundary.latitude && + station.latitude < northEastBoundary.latitude + ); + }; + + const isStationLongitudeWithinBounds = (station: StationSummary) => { + return ( + station.longitude > southWestBoundary.longitude && + station.longitude < northEastBoundary.longitude + ); + }; + + const validStationSummaries = Array.from(cachedStationSummaries.values()).filter( + (station) => isStationLatitudeWithinBounds(station) && isStationLongitudeWithinBounds(station) + ); + + return validStationSummaries; + }, + clear: (stationIds: string[]) => { + stationIds.forEach((stationId) => cachedStationSummaries.delete(stationId)); + }, + has: (stationId: string) => { + return cachedStationSummaries.has(stationId); + }, +}; diff --git a/src/components/ui/StationSearchWindow/NoResult.tsx b/src/components/ui/StationSearchWindow/NoResult.tsx new file mode 100644 index 00000000..072e9110 --- /dev/null +++ b/src/components/ui/StationSearchWindow/NoResult.tsx @@ -0,0 +1,19 @@ +import ListItem from '@common/ListItem'; +import Text from '@common/Text'; + +const NoResult = () => { + return ( + <ListItem> + <Text mt={3} fontSize={1.8} weight="bold"> + 검색 결과가 없습니다. + </Text> + <Text variant="subtitle" mt={1}> + 검색어를 다시 한 번 확인해 주세요. + </Text> + <Text>·  오타는 없나요?</Text> + <Text mb={5}>·  띄어쓰기가 잘못되진 않았나요?</Text> + </ListItem> + ); +}; + +export default NoResult; diff --git a/src/components/ui/StationSearchWindow/SearchResult.stories.tsx b/src/components/ui/StationSearchWindow/SearchResult.stories.tsx new file mode 100644 index 00000000..906666e0 --- /dev/null +++ b/src/components/ui/StationSearchWindow/SearchResult.stories.tsx @@ -0,0 +1,127 @@ +import type { Meta } from '@storybook/react'; +import { styled } from 'styled-components'; + +import type { SearchResultProps } from './SearchResult'; +import SearchResult from './SearchResult'; + +const meta = { + title: 'UI/SearchResult', + tags: ['autodocs'], + component: SearchResult, + args: { + cities: [ + { + cityName: '서울특별시 강동구 천호동', + latitude: 1, + longitude: 1, + }, + { + cityName: '서울특별시 강동구 명일동', + latitude: 1, + longitude: 1, + }, + { + cityName: '서울특별시 강동구 명일동 413-12번지 카페인 빌딩', + latitude: 1, + longitude: 1, + }, + ], + stations: [ + { + stationId: '0', + stationName: '충전소 이름이라네', + speed: 'quick', + address: '서울시 강남구 테헤란로 411', + latitude: 1, + longitude: 1, + }, + { + stationId: '1', + stationName: '허허', + speed: 'quick', + address: '서울시 강남구 테헤란로 411', + latitude: 1, + longitude: 1, + }, + { + stationId: '2', + stationName: '완전 엄청나게 이름이 긴 충전소를 테스트', + speed: 'standard', + address: '서울시 강남구 테헤란로 411 천호빌딩 지하 14층', + latitude: 1, + longitude: 1, + }, + ], + isLoading: false, + isError: false, + showStationDetails: () => { + (''); + }, + }, + argTypes: { + stations: { + description: + '검색된 충전소들입니다.<br /> 검색된 충전소의 개수가 0일 때는 검색 결과가 없습니다.', + }, + isLoading: { + description: 'true: 검색 결과를 가져오고 있습니다.<br /> false: 검색 결과를 가져왔습니다.', + }, + isError: { + description: 'true: 에러가 발생했습니다.<br /> false: 에러가 발생하지 않았습니다.', + }, + showStationDetails: { + description: '검색된 충전소를 클릭하면 해당 충전소로 이동하고, 상세정보가 나타납니다.', + }, + }, +} satisfies Meta<typeof SearchResult>; + +export default meta; + +// TODO: 스토리북 빌드 실패로 임시로 조치해뒀으니 수정 바랍니다. + +export const Default = ({ ...args }: SearchResultProps) => { + return ( + <Container> + <SearchResult {...args} /> + </Container> + ); +}; + +export const NoResult = () => { + return ( + <SubContainer> + <SearchResult + cities={[]} + stations={[]} + closeResult={() => null} + isError={false} + isLoading={false} + showStationDetails={() => null} + /> + </SubContainer> + ); +}; + +export const Error = () => { + return ( + <Container> + <SearchResult + cities={[]} + stations={[]} + closeResult={() => null} + isError={true} + isLoading={false} + showStationDetails={() => null} + /> + </Container> + ); +}; + +const Container = styled.div` + width: 34rem; + height: 16rem; +`; + +const SubContainer = styled(Container)` + height: 24rem; +`; diff --git a/src/components/ui/StationSearchWindow/SearchResult.style.ts b/src/components/ui/StationSearchWindow/SearchResult.style.ts new file mode 100644 index 00000000..86b49692 --- /dev/null +++ b/src/components/ui/StationSearchWindow/SearchResult.style.ts @@ -0,0 +1,31 @@ +import { css } from 'styled-components'; + +import { MOBILE_BREAKPOINT } from '@constants'; + +export const searchResultListCss = css` + position: absolute; + z-index: 9999; + width: 29.6rem; + max-height: 46rem; + overflow: auto; + border: 1.5px solid #d9d9da; + border-radius: 10px; + background: #fcfcfc; + box-shadow: 0 3px 10px 0 #d9d9da; + font-size: 1.5rem; + line-height: 2; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + width: calc(100vw - 2rem); + + max-height: 22.6rem; + } +`; + +export const foundStationListCss = css` + display: flex; + + &:hover { + background: #f5f5f5; + } +`; diff --git a/src/components/ui/StationSearchWindow/SearchResult.tsx b/src/components/ui/StationSearchWindow/SearchResult.tsx new file mode 100644 index 00000000..675b6620 --- /dev/null +++ b/src/components/ui/StationSearchWindow/SearchResult.tsx @@ -0,0 +1,80 @@ +import { useEffect } from 'react'; + +import List from '@common/List'; + +import Error from '@ui/Error'; + +import type { SearchedCity, SearchedStation, StationPosition } from '@type/stations'; + +import NoResult from './NoResult'; +import { searchResultListCss } from './SearchResult.style'; +import SearchedCityCard from './SearchedCityCard'; +import SearchedStationCard from './SearchedStationCard'; + +export interface SearchResultProps { + cities: SearchedCity[]; + stations: SearchedStation[]; + isLoading: boolean; + isError: boolean; + showStationDetails: (param: StationPosition) => void; + closeResult: () => void; +} + +const SearchResult = ({ + cities = [], + stations = [], + isLoading, + isError, + showStationDetails, + closeResult, +}: SearchResultProps) => { + const handleShowStationDetails = ({ stationId, latitude, longitude }: StationPosition) => { + showStationDetails({ stationId, latitude, longitude }); + }; + + useEffect(() => { + document.body.addEventListener('click', closeResult); + + return () => { + document.body.removeEventListener('click', closeResult); + }; + }, []); + + const isExistResults = stations.length !== 0 || cities.length !== 0; + const renderResults = [ + ...cities.map((city) => <SearchedCityCard city={city} key={city.cityName} />), + ...stations.map((station) => ( + <SearchedStationCard + station={station} + handleShowStationDetails={handleShowStationDetails} + key={station.stationId} + /> + )), + ]; + + if (isLoading) { + return <></>; + } + + if (isError) + return ( + <List aria-live="assertive" mt={1} css={searchResultListCss}> + <Error + title="문제가 발생했어요!" + message="예상하지 못한 오류로" + subMessage="검색 결과를 가져오지 못했습니다." + fontSize="20%" + pt={6} + pb={3} + /> + </List> + ); + + return ( + <List aria-live="assertive" mt={1} css={searchResultListCss}> + {isExistResults ? renderResults : <NoResult />} + </List> + ); +}; + +export default SearchResult; diff --git a/src/components/ui/StationSearchWindow/SearchedCityCard.tsx b/src/components/ui/StationSearchWindow/SearchedCityCard.tsx new file mode 100644 index 00000000..c3d735f4 --- /dev/null +++ b/src/components/ui/StationSearchWindow/SearchedCityCard.tsx @@ -0,0 +1,54 @@ +import { MapPinIcon } from '@heroicons/react/24/solid'; +import { css } from 'styled-components'; + +import { googleMapActions } from '@stores/google-maps/googleMapStore'; + +import Button from '@common/Button'; +import FlexBox from '@common/FlexBox'; +import ListItem from '@common/ListItem'; +import Text from '@common/Text'; + +import type { SearchedCity } from '@type'; + +import { foundStationListCss } from './SearchResult.style'; + +export interface SearchedCityCardProps { + city: SearchedCity; +} + +const SearchedCityCard = ({ city }: SearchedCityCardProps) => { + const { cityName, latitude, longitude } = city; + + return ( + <ListItem divider NoLastDivider css={foundStationListCss}> + <Button + width="100%" + noRadius="all" + background="transparent" + onMouseDown={() => googleMapActions.moveTo({ lat: latitude, lng: longitude }, 14)} + > + <FlexBox alignItems="center" columnGap={2} nowrap css={iconCss}> + <MapPinIcon width="1.6rem" fill="#888" /> + <Text + title={cityName} + align="left" + lineClamp={1} + fontSize={1.3} + mb={0.25} + color="#585858" + > + {cityName} + </Text> + </FlexBox> + </Button> + </ListItem> + ); +}; + +const iconCss = css` + & > svg { + flex-shrink: 0; + } +`; + +export default SearchedCityCard; diff --git a/src/components/ui/StationSearchWindow/SearchedStationCard.tsx b/src/components/ui/StationSearchWindow/SearchedStationCard.tsx new file mode 100644 index 00000000..b181ac39 --- /dev/null +++ b/src/components/ui/StationSearchWindow/SearchedStationCard.tsx @@ -0,0 +1,55 @@ +import { css } from 'styled-components'; + +import { RiChargingPile2Fill } from 'react-icons/ri'; + +import FlexBox from '@common/FlexBox'; +import ListItem from '@common/ListItem'; +import Text from '@common/Text'; + +import { foundStationListCss } from '@ui/StationSearchWindow/SearchResult.style'; + +import type { SearchedStation, StationPosition } from '@type'; + +export interface SearchedStationCardProps { + station: SearchedStation; + handleShowStationDetails: (props: StationPosition) => void; +} + +const SearchedStationCard = ({ station, handleShowStationDetails }: SearchedStationCardProps) => { + const { stationId, stationName, address, latitude, longitude } = station; + + return ( + <ListItem divider NoLastDivider pb={3} css={foundStationListCss}> + <FlexBox + tag="button" + type="button" + width="100%" + noRadius="all" + background="transparent" + columnGap={2} + onMouseDown={() => handleShowStationDetails({ stationId, latitude, longitude })} + nowrap + css={iconCss} + > + <RiChargingPile2Fill size={16} fill="#585858" /> + <div> + <Text weight="regular" align="left" title={stationName} lineClamp={1}> + {stationName} + </Text> + <Text fontSize={1.3} align="left" lineClamp={1} color="#585858"> + {address === 'null' || !address ? '주소 미확인' : address} + </Text> + </div> + </FlexBox> + </ListItem> + ); +}; + +const iconCss = css` + & > svg { + margin-top: 0.4rem; + flex-shrink: 0; + } +`; + +export default SearchedStationCard; diff --git a/src/components/ui/StationSearchWindow/StationSearchBar.stories.tsx b/src/components/ui/StationSearchWindow/StationSearchBar.stories.tsx new file mode 100644 index 00000000..a07d0d68 --- /dev/null +++ b/src/components/ui/StationSearchWindow/StationSearchBar.stories.tsx @@ -0,0 +1,175 @@ +import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; +import type { Meta } from '@storybook/react'; +import { styled } from 'styled-components'; + +import type { ChangeEvent, FocusEvent, FormEvent, MouseEvent } from 'react'; +import { useState } from 'react'; + +import { useQueryClient } from '@tanstack/react-query'; + +import { fetchSearchedStations, useSearchStations } from '@hooks/tanstack-query/useSearchStations'; +import { useDebounce } from '@hooks/useDebounce'; + +import Loader from '@common/Loader'; + +import { useNavigationBar } from '@ui/Navigator/NavigationBar/hooks/useNavigationBar'; +import StationDetailsWindow from '@ui/StationDetailsWindow'; + +import { MOBILE_BREAKPOINT } from '@constants'; +import { QUERY_KEY_SEARCHED_STATION, QUERY_KEY_STATION_MARKERS } from '@constants/queryKeys'; + +import { pillStyle } from '../../../style'; +import type { StationPosition } from '../../../types'; +import Button from '../../common/Button'; +import SearchResult from './SearchResult'; + +const meta = { + title: 'UI/StationSearchBar', + decorators: [ + (Story) => ( + <S.Container> + <Story /> + </S.Container> + ), + ], +} satisfies Meta; + +export default meta; + +// TODO: addon으로 googleMap 관련 함수 제외하기 +export const Default = () => { + const [isFocused, setIsFocused] = useState(false); + + const [searchWord, setSearchWord] = useState(''); + const [debouncedSearchWord, setDebouncedSearchWord] = useState(searchWord); + const queryClient = useQueryClient(); + const { openLastPanel } = useNavigationBar(); + + useDebounce( + () => { + setDebouncedSearchWord(searchWord); + }, + [searchWord], + 400 + ); + + const { + data: searchResult, + isLoading, + isError, + isFetching, + } = useSearchStations(debouncedSearchWord); + + const handleOpenResult = (event: MouseEvent<HTMLInputElement> | FocusEvent<HTMLInputElement>) => { + event.stopPropagation(); + setIsFocused(true); + }; + + const handleCloseResult = () => { + setIsFocused(false); + }; + + const handleSubmitSearchWord = async (event: FormEvent<HTMLFormElement>) => { + event.preventDefault(); + handleCloseResult(); + + const { stations } = await fetchSearchedStations(searchWord); + + if (stations !== undefined && stations.length > 0) { + const [{ stationId, latitude, longitude }] = stations; + showStationDetails({ stationId, latitude, longitude }); + } + + queryClient.invalidateQueries({ queryKey: [QUERY_KEY_SEARCHED_STATION] }); + }; + + const showStationDetails = ({ stationId, latitude, longitude }: StationPosition) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY_STATION_MARKERS] }); + openLastPanel(<StationDetailsWindow stationId={stationId} />); + }; + + const handleChangeSearchWord = ({ target: { value } }: ChangeEvent<HTMLInputElement>) => { + const searchWord = encodeURIComponent(value); + + setIsFocused(true); + setSearchWord(searchWord); + }; + + return ( + <S.Container> + <S.Form role="search" onSubmit={handleSubmitSearchWord}> + <label htmlFor="station-search-bar" aria-hidden> + <S.Search + id="station-search-bar" + type="search" + role="searchbox" + placeholder="충전소명 또는 지역명을 입력해 주세요" + autoComplete="off" + onChange={handleChangeSearchWord} + onFocus={handleOpenResult} + onClick={handleOpenResult} + /> + <Button type="submit" aria-label="검색하기"> + {isFetching ? ( + <Loader size="md" /> + ) : ( + <MagnifyingGlassIcon width="2.4rem" stroke="#767676" /> + )} + </Button> + </label> + </S.Form> + {isFocused && searchResult && ( + <SearchResult + cities={searchResult.cities} + stations={searchResult.stations} + isLoading={isLoading} + isError={isError} + showStationDetails={showStationDetails} + closeResult={handleCloseResult} + /> + )} + </S.Container> + ); +}; + +const S = { + Container: styled.div` + width: 30rem; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + width: 100%; + } + `, + + Form: styled.form` + position: relative; + min-width: 30rem; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + min-width: 100%; + } + `, + + Search: styled.input` + ${pillStyle}; + + background: #fcfcfc; + border: 1px solid #d0d2d8; + + width: 100%; + padding: 1.9rem 4.6rem 2rem 1.8rem; + font-size: 1.3rem; + + & + button { + position: absolute; + right: 2rem; + top: 50%; + transform: translateY(-50%); + } + + &:focus { + box-shadow: 0 1px 8px 0 rgba(0, 0, 0, 0.2); + outline: 0; + } + `, +}; diff --git a/src/components/ui/StationSearchWindow/StationSearchBar.style.ts b/src/components/ui/StationSearchWindow/StationSearchBar.style.ts new file mode 100644 index 00000000..5662b850 --- /dev/null +++ b/src/components/ui/StationSearchWindow/StationSearchBar.style.ts @@ -0,0 +1,45 @@ +import styled from 'styled-components'; + +import { pillStyle } from '@style'; + +import { MOBILE_BREAKPOINT } from '@constants'; + +export const StyledContainer = styled.div` + width: 30rem; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + width: 100%; + } +`; + +export const StyledForm = styled.form` + position: relative; + min-width: 30rem; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + min-width: 100%; + } +`; + +export const StyledSearch = styled.input` + ${pillStyle}; + + background: #fcfcfc; + border: 1px solid #d0d2d8; + + width: 100%; + padding: 1.9rem 4.6rem 2rem 1.8rem; + font-size: 1.3rem; + + & + button { + position: absolute; + right: 2rem; + top: 50%; + transform: translateY(-50%); + } + + &:focus { + box-shadow: 0 1px 8px 0 rgba(0, 0, 0, 0.2); + outline: 0; + } +`; diff --git a/src/components/ui/StationSearchWindow/StationSearchBar.tsx b/src/components/ui/StationSearchWindow/StationSearchBar.tsx new file mode 100644 index 00000000..5540ea4b --- /dev/null +++ b/src/components/ui/StationSearchWindow/StationSearchBar.tsx @@ -0,0 +1,79 @@ +import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; + +import { useState } from 'react'; + +import { useSearchStations } from '@hooks/tanstack-query/useSearchStations'; +import { useDebounce } from '@hooks/useDebounce'; + +import FlexBox from '@common/FlexBox'; +import Loader from '@common/Loader'; + +import SearchResult from './SearchResult'; +import { StyledContainer, StyledForm, StyledSearch } from './StationSearchBar.style'; +import { useStationSearchWindow } from './hooks/useStationSearchWindow'; + +const StationSearchBar = () => { + const { + handleSubmitSearchWord, + handleChangeSearchWord, + handleOpenResult, + handleCloseResult, + showStationDetails, + isFocused, + searchWord, + } = useStationSearchWindow(); + const [debouncedSearchWord, setDebouncedSearchWord] = useState(searchWord); + + useDebounce( + () => { + setDebouncedSearchWord(searchWord); + }, + [searchWord], + 400 + ); + + const { + data: searchResult, + isLoading, + isError, + isFetching, + } = useSearchStations(debouncedSearchWord); + + return ( + <StyledContainer> + <StyledForm role="search" onSubmit={handleSubmitSearchWord}> + <label htmlFor="station-search-bar" aria-hidden> + <StyledSearch + id="station-search-bar" + type="search" + role="searchbox" + placeholder="충전소명 또는 지역명을 입력해 주세요" + autoComplete="off" + onChange={handleChangeSearchWord} + onFocus={handleOpenResult} + onClick={handleOpenResult} + /> + <FlexBox tag="button" aria-label="검색하기" height={2.4} alignItems="center"> + {isFetching ? ( + <Loader size="md" /> + ) : ( + <MagnifyingGlassIcon width="2.4rem" stroke="#767676" /> + )} + </FlexBox> + </label> + </StyledForm> + {isFocused && searchResult && ( + <SearchResult + cities={searchResult.cities} + stations={searchResult.stations} + isLoading={isLoading} + isError={isError} + showStationDetails={showStationDetails} + closeResult={handleCloseResult} + /> + )} + </StyledContainer> + ); +}; + +export default StationSearchBar; diff --git a/src/components/ui/StationSearchWindow/StationSearchWindow.stories.tsx b/src/components/ui/StationSearchWindow/StationSearchWindow.stories.tsx new file mode 100644 index 00000000..32836a66 --- /dev/null +++ b/src/components/ui/StationSearchWindow/StationSearchWindow.stories.tsx @@ -0,0 +1,47 @@ +import { ChevronLeftIcon } from '@heroicons/react/24/outline'; +import type { Meta } from '@storybook/react'; +import { css, styled } from 'styled-components'; + +import Button from '../../common/Button'; +import Text from '../../common/Text'; +import Navigator from '../Navigator'; +import { Default as StationSearchBar } from './StationSearchBar.stories'; +import StationSearchWindow from './StationSearchWindow'; + +const meta = { + title: 'UI/StationSearchWindow', + component: StationSearchWindow, +} satisfies Meta<typeof StationSearchWindow>; + +export default meta; + +export const Default = () => { + return ( + <> + <Navigator /> + <S.Container> + <Button variant="label" aria-label="검색창 닫기"> + <ChevronLeftIcon width="2.4rem" stroke="#9c9fa7" /> + </Button> + <StationSearchBar /> + <Text tag="h2" fontSize={1.7} weight="bold" css={labelText}> + 주변 충전소 + </Text> + </S.Container> + </> + ); +}; + +const S = { + Container: styled.section` + width: 34rem; + height: 100vh; + background: #fcfcfc; + outline: 1.5px solid #e1e4eb; + padding: 2.8rem 2.2rem 5.2rem; + `, +}; + +const labelText = css` + padding: 3.6rem 0 2.2rem; +`; diff --git a/src/components/ui/StationSearchWindow/StationSearchWindow.tsx b/src/components/ui/StationSearchWindow/StationSearchWindow.tsx new file mode 100644 index 00000000..7e18c03b --- /dev/null +++ b/src/components/ui/StationSearchWindow/StationSearchWindow.tsx @@ -0,0 +1,48 @@ +import { css, styled } from 'styled-components'; + +import Text from '@common/Text'; + +import StationList from '@ui/StationListWindow/StationList'; + +import { MOBILE_BREAKPOINT, NAVIGATOR_PANEL_WIDTH } from '@constants'; + +import StationSearchBar from './StationSearchBar'; + +const StationSearchWindow = () => { + return ( + <S.Container> + <S.Padding> + <StationSearchBar /> + <Text tabIndex={0} tag="h2" fontSize={1.7} weight="bold" css={labelText}> + 주변 충전소 + </Text> + </S.Padding> + + <StationList /> + </S.Container> + ); +}; + +const S = { + Container: styled.article` + width: ${NAVIGATOR_PANEL_WIDTH}rem; + height: 100vh; + background: #fcfcfc; + outline: 1.5px solid #e1e4eb; + padding-top: 2.4rem; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + display: none; + } + `, + + Padding: styled.div` + padding: 0 2rem; + `, +}; + +const labelText = css` + padding: 4.2rem 0 2.2rem; +`; + +export default StationSearchWindow; diff --git a/src/components/ui/StationSearchWindow/hooks/useStationSearchWindow.tsx b/src/components/ui/StationSearchWindow/hooks/useStationSearchWindow.tsx new file mode 100644 index 00000000..66384619 --- /dev/null +++ b/src/components/ui/StationSearchWindow/hooks/useStationSearchWindow.tsx @@ -0,0 +1,119 @@ +import type { ChangeEvent, FocusEvent, FormEvent, MouseEvent } from 'react'; +import { useState } from 'react'; + +import { useQueryClient } from '@tanstack/react-query'; + +import { useRenderStationMarker } from '@marker/SmallMediumDeltaAreaMarkerContainer/hooks/useRenderStationMarker'; + +import { useSetExternalState } from '@utils/external-state'; + +import { googleMapActions } from '@stores/google-maps/googleMapStore'; +import { markerInstanceStore } from '@stores/google-maps/markerInstanceStore'; + +import { useStationInfoWindow } from '@hooks/google-maps/useStationInfoWindow'; +import { fetchSearchedStations } from '@hooks/tanstack-query/useSearchStations'; +import useMediaQueries from '@hooks/useMediaQueries'; + +import { useNavigationBar } from '@ui/Navigator/NavigationBar/hooks/useNavigationBar'; + +import { + QUERY_KEY_SEARCHED_STATION, + QUERY_KEY_STATION_DETAILS, + QUERY_KEY_STATION_MARKERS, +} from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +import type { StationDetails, StationPosition } from '@type'; + +import StationDetailsWindow from '../../StationDetailsWindow/index'; +import { convertStationDetailsToSummary } from '../tools/convertStationDetailsToSummary'; + +export const useStationSearchWindow = () => { + const queryClient = useQueryClient(); + + const [isFocused, setIsFocused] = useState(false); + const [searchWord, setSearchWord] = useState(''); + + const setMarkerInstances = useSetExternalState(markerInstanceStore); + + const { openLastPanel } = useNavigationBar(); + const { openStationInfoWindow } = useStationInfoWindow(); + const { createNewMarkerInstance, renderDefaultMarkers } = useRenderStationMarker(); + + const screen = useMediaQueries(); + + const handleOpenResult = (event: MouseEvent<HTMLInputElement> | FocusEvent<HTMLInputElement>) => { + event.stopPropagation(); + setIsFocused(true); + }; + + const handleCloseResult = () => { + setIsFocused(false); + }; + + const handleSubmitSearchWord = async (event: FormEvent<HTMLFormElement>) => { + event.preventDefault(); + handleCloseResult(); + + const searchedStations = await fetchSearchedStations(searchWord); + + if (searchedStations !== undefined && searchedStations.stations.length > 0) { + const [{ stationId, latitude, longitude }] = searchedStations.stations; + showStationDetails({ stationId, latitude, longitude }); + } + + queryClient.invalidateQueries({ queryKey: [QUERY_KEY_SEARCHED_STATION] }); + }; + + const showStationDetails = async ({ stationId, latitude, longitude }: StationPosition) => { + googleMapActions.moveTo({ lat: latitude, lng: longitude }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEY_STATION_MARKERS] }); + + if (!screen.get('isMobile')) { + openLastPanel(<StationDetailsWindow stationId={stationId} />); + } + + // 지금 보여지는 화면에 검색한 충전소가 존재할 경우의 처리 + if ( + markerInstanceStore + .getState() + .some(({ stationId: cachedStationId }) => cachedStationId === stationId) + ) { + openStationInfoWindow(stationId); + } else { + const stationDetails = await fetch( + `${SERVER_URL}/stations/${stationId}` + ).then<StationDetails>((response) => response.json()); + + const markerInstance = createNewMarkerInstance(stationDetails); + + setMarkerInstances((prev) => [...prev, { stationId, instance: markerInstance }]); + + renderDefaultMarkers( + [{ stationId, instance: markerInstance }], + [convertStationDetailsToSummary(stationDetails)] + ); + + openStationInfoWindow(stationId, markerInstance); + + queryClient.setQueryData([QUERY_KEY_STATION_DETAILS, stationId], stationDetails); + } + }; + + const handleChangeSearchWord = ({ target: { value } }: ChangeEvent<HTMLInputElement>) => { + const searchWord = encodeURIComponent(value); + + setIsFocused(true); + setSearchWord(searchWord); + }; + + return { + handleSubmitSearchWord, + handleChangeSearchWord, + handleOpenResult, + handleCloseResult, + showStationDetails, + isFocused, + searchWord, + }; +}; diff --git a/src/components/ui/StationSearchWindow/index.ts b/src/components/ui/StationSearchWindow/index.ts new file mode 100644 index 00000000..06a2de1c --- /dev/null +++ b/src/components/ui/StationSearchWindow/index.ts @@ -0,0 +1,3 @@ +import StationSearchWindow from './StationSearchWindow'; + +export default StationSearchWindow; diff --git a/src/components/ui/StationSearchWindow/tools/convertStationDetailsToSummary.ts b/src/components/ui/StationSearchWindow/tools/convertStationDetailsToSummary.ts new file mode 100644 index 00000000..8a10efa0 --- /dev/null +++ b/src/components/ui/StationSearchWindow/tools/convertStationDetailsToSummary.ts @@ -0,0 +1,41 @@ +import { QUICK_CHARGER_CAPACITY_THRESHOLD } from '@constants/chargers'; + +import type { StationDetails, StationSummary } from '@type'; + +export const convertStationDetailsToSummary = (stationDetails: StationDetails): StationSummary => { + const { + address, + companyName, + detailLocation, + isParkingFree, + isPrivate, + latitude, + longitude, + operatingTime, + stationId, + stationName, + chargers, + } = stationDetails; + + const availableCount = chargers.filter((charger) => charger.state === 'STANDBY').length; + const totalCount = chargers.length; + const quickChargerCount = chargers.filter( + (charger) => charger.capacity >= QUICK_CHARGER_CAPACITY_THRESHOLD + ).length; + + return { + address, + companyName, + detailLocation, + isParkingFree, + isPrivate, + latitude, + longitude, + operatingTime, + stationId, + stationName, + availableCount, + totalCount, + quickChargerCount, + }; +}; diff --git a/src/components/ui/StatisticsGraph/Graph/Bar.stories.tsx b/src/components/ui/StatisticsGraph/Graph/Bar.stories.tsx new file mode 100644 index 00000000..7040142a --- /dev/null +++ b/src/components/ui/StatisticsGraph/Graph/Bar.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta } from '@storybook/react'; + +import Box from '@common/Box'; + +import { NO_RATIO } from '@constants/congestion'; + +import Bar from './Bar'; + +const meta = { + title: 'UI/Bar', + component: Bar, + tags: ['autodocs'], +} satisfies Meta<typeof Bar>; + +export default meta; + +export const Default = () => { + return Array.from({ length: 25 }, (_, index) => ( + <Box key={index} my={1}> + <Bar align="column" ratio={(index / 24) * 100} hour={String(index)} /> + </Box> + )); +}; + +export const NoRatio = () => { + return <Bar align="column" ratio={NO_RATIO} hour="1" />; +}; diff --git a/src/components/ui/StatisticsGraph/Graph/Bar.tsx b/src/components/ui/StatisticsGraph/Graph/Bar.tsx new file mode 100644 index 00000000..72c831f1 --- /dev/null +++ b/src/components/ui/StatisticsGraph/Graph/Bar.tsx @@ -0,0 +1,80 @@ +import { css, styled } from 'styled-components'; + +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import { getHoverColor } from '@style'; + +import { NO_RATIO } from '@constants/congestion'; + +interface BarProps { + ratio: number; + hour: string; + align: 'row' | 'column'; +} + +const Bar = ({ ratio, hour, align }: BarProps) => { + return ( + <FlexBox + tag="li" + nowrap + width="100%" + direction={align === 'column' ? 'row' : 'column'} + css={align === 'row' && rowAlignCss} + alignItems="center" + > + <Text variant="caption" css={align === 'column' && textCss}> + {hour} + </Text> + <ProgressBar + value={ratio === NO_RATIO ? 100 : ratio * 100} + max={100} + color={getColorByRatio(ratio)} + /> + </FlexBox> + ); +}; + +const getColorByRatio = (ratio: number) => { + if (ratio === NO_RATIO) { + return getHoverColor('disable'); + } + + return '#2a6cd8'; +}; + +const ProgressBar = styled.progress<{ color: string }>` + width: 100%; + height: 1.2rem; + + -webkit-appearance: none; + appearance: none; + + &::-webkit-progress-bar { + background-color: #eee; + + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + border-top-right-radius: 10px; + border-bottom-right-radius: 10px; + } + + &::-webkit-progress-value { + background-color: ${({ color }) => color}; + + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + border-top-right-radius: 10px; + border-bottom-right-radius: 10px; + } +`; + +const rowAlignCss = css` + flex-direction: column-reverse; +`; + +const textCss = css` + width: 2rem; +`; + +export default Bar; diff --git a/src/components/ui/StatisticsGraph/Graph/BarContainer.tsx b/src/components/ui/StatisticsGraph/Graph/BarContainer.tsx new file mode 100644 index 00000000..2bb1344f --- /dev/null +++ b/src/components/ui/StatisticsGraph/Graph/BarContainer.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; + +import FlexBox from '@common/FlexBox'; + +import CongestionBarContainerSkeleton from '@ui/StationDetailsWindow/congestion/CongestionBarContainerSkeleton'; + +import type { Congestion } from '@type'; + +import type { GraphProps } from './index'; + +interface BarContainerProps extends GraphProps { + renderBar: (hour: string, ratio: number) => ReactNode; + statistics: Congestion[]; + isLoading: boolean; +} + +const BarContainer = ({ align, statistics, renderBar, isLoading }: BarContainerProps) => { + return ( + <> + {isLoading ? ( + <CongestionBarContainerSkeleton /> + ) : ( + <FlexBox + direction={align} + nowrap + alignItems={align === 'row' ? 'end' : 'start'} + width={align === 'column' && '100%'} + height={align === 'row' && '100%'} + > + {statistics.map(({ hour, ratio }) => renderBar(`${hour + 1}`.padStart(2, '0'), ratio))} + </FlexBox> + )} + </> + ); +}; + +export default BarContainer; diff --git a/src/components/ui/StatisticsGraph/Graph/CircleDaySelectButton.tsx b/src/components/ui/StatisticsGraph/Graph/CircleDaySelectButton.tsx new file mode 100644 index 00000000..1b1d3c98 --- /dev/null +++ b/src/components/ui/StatisticsGraph/Graph/CircleDaySelectButton.tsx @@ -0,0 +1,72 @@ +import { css } from 'styled-components'; + +import { type PropsWithChildren } from 'react'; + +import ButtonNext from '@common/ButtonNext'; + +import { + SHORT_ENGLISH_DAYS_OF_WEEK, + ENGLISH_DAYS_OF_WEEK_SHORT_TO_FULL, + ENGLISH_DAYS_TO_KOREAN_DAYS, + ENGLISH_DAYS_OF_WEEK_FULL_TO_SHORT, +} from '@constants/congestion'; + +import type { EnglishDaysOfWeek, ShortEnglishDaysOfWeek } from '@type/congestion'; + +interface DaySelectButtonProps extends PropsWithChildren { + dayOfWeek: EnglishDaysOfWeek; + onChangeDayOfWeek: (dayOfWeek: EnglishDaysOfWeek) => void; +} + +const isEnglishDays = (day: string): day is ShortEnglishDaysOfWeek => { + return SHORT_ENGLISH_DAYS_OF_WEEK.includes(day as ShortEnglishDaysOfWeek); +}; + +const CircleDaySelectButton = ({ + children, + onChangeDayOfWeek, + dayOfWeek, +}: DaySelectButtonProps) => { + const handleSelectDay = (day: string) => { + if (isEnglishDays(day)) { + onChangeDayOfWeek( + ENGLISH_DAYS_OF_WEEK_SHORT_TO_FULL[day as (typeof SHORT_ENGLISH_DAYS_OF_WEEK)[number]] + ); + } + }; + + return ( + <ButtonNext + size="sm" + variant={ + ENGLISH_DAYS_OF_WEEK_FULL_TO_SHORT[dayOfWeek] === children ? 'contained' : 'outlined' + } + css={[buttonCss, ENGLISH_DAYS_OF_WEEK_FULL_TO_SHORT[dayOfWeek] === children && colorCss]} + onClick={() => { + if (typeof children === 'string') { + handleSelectDay(children); + } + }} + > + {typeof children === 'string' && + isEnglishDays(children) && + `${ENGLISH_DAYS_TO_KOREAN_DAYS[children]}`} + </ButtonNext> + ); +}; + +const buttonCss = css` + width: 14.2%; + max-width: 4rem; + min-height: 4rem; + padding: 0; + + border: 1px solid #d4d4d4; + border-radius: 50%; +`; + +const colorCss = css` + color: #fff; +`; + +export default CircleDaySelectButton; diff --git a/src/components/ui/StatisticsGraph/Graph/DayMenus.tsx b/src/components/ui/StatisticsGraph/Graph/DayMenus.tsx new file mode 100644 index 00000000..bc158613 --- /dev/null +++ b/src/components/ui/StatisticsGraph/Graph/DayMenus.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from 'react'; + +import FlexBox from '@common/FlexBox'; + +export interface DayMenusProps { + menus: string[]; + renderMenuSelectButton: (menu: string) => ReactNode; +} + +const DayMenus = ({ menus, renderMenuSelectButton }: DayMenusProps) => { + return ( + <FlexBox nowrap width="100%" justifyContent="between" gap={0}> + {menus.map((menu) => renderMenuSelectButton(menu))} + </FlexBox> + ); +}; + +export default DayMenus; diff --git a/src/components/ui/StatisticsGraph/Graph/index.tsx b/src/components/ui/StatisticsGraph/Graph/index.tsx new file mode 100644 index 00000000..f7a82e21 --- /dev/null +++ b/src/components/ui/StatisticsGraph/Graph/index.tsx @@ -0,0 +1,27 @@ +import type { PropsWithChildren } from 'react'; + +import FlexBox from '@common/FlexBox'; + +import Bar from './Bar'; +import BarContainer from './BarContainer'; +import CircleDaySelectButton from './CircleDaySelectButton'; +import DayMenus from './DayMenus'; + +export interface GraphProps { + align: 'row' | 'column'; +} + +const Graph = ({ children }: PropsWithChildren<GraphProps>) => { + return ( + <FlexBox direction="column" gap={3} width="100%"> + {children} + </FlexBox> + ); +}; + +Graph.DayMenus = DayMenus; +Graph.CircleDaySelectButton = CircleDaySelectButton; +Graph.BarContainer = BarContainer; +Graph.Bar = Bar; + +export default Graph; diff --git a/src/components/ui/StatisticsGraph/StatisticsGraph.stories.tsx b/src/components/ui/StatisticsGraph/StatisticsGraph.stories.tsx new file mode 100644 index 00000000..1507a30d --- /dev/null +++ b/src/components/ui/StatisticsGraph/StatisticsGraph.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta } from '@storybook/react'; + +import { ENGLISH_DAYS_OF_WEEK } from '@constants/congestion'; + +import StatisticsGraph from '.'; +import { getCongestionStatistics } from '../../../mocks/data'; + +const meta = { + title: 'UI/StatisticsGraph', + component: StatisticsGraph, + tags: ['autodocs'], +} satisfies Meta<typeof StatisticsGraph>; + +export default meta; + +// TODO: 스토리북 빌드 실패로 임시로 조치해뒀으니 수정 바랍니다. + +export const Column = () => { + return ( + <StatisticsGraph + statistics={getCongestionStatistics('1').congestion.quick.FRI} + align="column" + menus={[...ENGLISH_DAYS_OF_WEEK]} + dayOfWeek={'friday'} + isLoading={false} + onChangeDayOfWeek={() => null} + /> + ); +}; + +export const Row = () => { + return ( + <StatisticsGraph + statistics={getCongestionStatistics('1').congestion.quick.FRI} + align="row" + menus={[...ENGLISH_DAYS_OF_WEEK]} + dayOfWeek={'friday'} + isLoading={false} + onChangeDayOfWeek={() => null} + /> + ); +}; diff --git a/src/components/ui/StatisticsGraph/index.tsx b/src/components/ui/StatisticsGraph/index.tsx new file mode 100644 index 00000000..54370671 --- /dev/null +++ b/src/components/ui/StatisticsGraph/index.tsx @@ -0,0 +1,44 @@ +import type { GraphProps } from 'components/ui/StatisticsGraph/Graph'; + +import type { DayMenusProps } from '@ui/StatisticsGraph/Graph/DayMenus'; + +import type { Congestion, EnglishDaysOfWeek } from '@type'; + +import Graph from './Graph'; + +interface Props extends GraphProps, Omit<DayMenusProps, 'renderMenuSelectButton'> { + dayOfWeek: EnglishDaysOfWeek; + onChangeDayOfWeek: (dayOfWeek: EnglishDaysOfWeek) => void; + isLoading: boolean; + statistics: Congestion[]; +} + +const StatisticsGraph = ({ + statistics, + align, + menus, + onChangeDayOfWeek, + dayOfWeek, + isLoading, +}: Props) => { + return ( + <Graph align={align}> + <Graph.DayMenus + menus={menus} + renderMenuSelectButton={(menu: string) => ( + <Graph.CircleDaySelectButton dayOfWeek={dayOfWeek} onChangeDayOfWeek={onChangeDayOfWeek}> + {menu} + </Graph.CircleDaySelectButton> + )} + /> + <Graph.BarContainer + align={align} + statistics={statistics} + renderBar={(hour, ratio) => <Graph.Bar hour={hour} ratio={ratio} align={align} />} + isLoading={isLoading} + /> + </Graph> + ); +}; + +export default StatisticsGraph; diff --git a/src/components/ui/Svg/LogoIcon.tsx b/src/components/ui/Svg/LogoIcon.tsx new file mode 100644 index 00000000..070ae8b7 --- /dev/null +++ b/src/components/ui/Svg/LogoIcon.tsx @@ -0,0 +1,68 @@ +interface Props { + width: number; +} + +const LogoIcon = ({ width }: Props) => { + return ( + <svg + width={`${width}rem`} + viewBox="0 0 306 267" + fill="#0540f2" + xmlns="http://www.w3.org/2000/svg" + aria-label="커피컵과 자동차가 그려진 파란색 로고" + > + <g clipPath="url(#clip0_377_112)"> + <mask + id="mask0_377_112" + style={{ maskType: 'alpha' }} + maskUnits="userSpaceOnUse" + x="0" + y="9" + width="306" + height="258" + > + <path + d="M77.2702 91.07C95.4302 91.07 113.59 91.04 131.75 91.08C140.79 91.1 142.07 92.68 140.59 101.29C138.76 111.95 136.96 122.61 135.2 133.29C135 134.51 133.88 136.08 135.33 136.94C136.64 137.72 137.8 136.29 138.85 135.6C152.11 126.93 166.19 119.89 182.12 118.5C206.03 116.41 229.99 117.41 252.55 126.24C264.98 131.1 277.08 137.41 287.67 145.99C293.87 151.01 297.62 157.58 299.73 165.11C302.71 175.7 305.43 186.36 305.38 197.48C305.38 198.97 305.48 200.57 305.01 201.93C304.03 204.76 302.83 208 299.17 207.85C295.67 207.71 295.62 204.4 295.09 201.85C294.26 197.9 293.18 194.09 290.51 190.91C285.76 185.24 279.62 185.02 274.63 190.53C269.83 195.83 268.01 202.35 267.4 209.3C267.3 210.46 267.27 211.63 267.28 212.8C267.32 227.51 269.76 225.44 254.1 229.85C242.4 233.15 230.62 236.17 218.94 239.54C214.82 240.73 212.97 239.44 212.52 235.36C212.08 231.4 212.15 227.36 210.78 223.53C210.33 222.28 209.95 220.99 209.37 219.79C206.7 214.3 203.07 209.65 196.45 209.4C190.21 209.16 186.43 213.4 183.52 218.27C179.08 225.72 178.29 233.98 179.12 242.4C179.89 250.26 179.59 251.21 171.92 252.34C141.85 256.77 111.84 256.42 81.9902 250.49C74.8502 249.07 67.3502 247.75 64.0402 239.65C63.4102 238.1 61.8302 238.66 60.5002 238.68C53.2802 238.79 46.2602 237.53 39.4102 235.29C36.5402 234.35 34.5802 232.72 34.0702 229.41C27.4102 186.12 20.6502 142.85 13.9802 99.57C12.9402 92.83 14.3202 91.18 21.2902 91.14C39.9502 91.03 58.6102 91.1 77.2702 91.1V91.07ZM77.4102 122.96C77.4102 123.02 77.4102 123.08 77.4102 123.14C61.7502 123.14 46.0902 123.19 30.4302 123.1C27.1202 123.08 25.5302 123.74 26.1502 127.61C29.5302 148.65 32.7602 169.72 35.9602 190.78C36.4202 193.78 37.7702 195.12 40.8802 195.08C48.8802 194.97 56.8702 195.03 64.8702 195.04C66.8102 195.04 68.2602 194.5 69.4402 192.69C76.6902 181.56 87.2602 174.34 98.9602 168.81C103.35 166.73 106.92 164.15 109.89 160.48C113.37 156.2 117.16 152.22 121.57 148.89C124.15 146.94 125.36 144.38 125.83 141.29C126.48 137.01 127.09 132.72 128 128.5C128.87 124.46 127.88 122.81 123.38 122.88C108.06 123.1 92.7302 122.97 77.4002 122.97L77.4102 122.96ZM157.76 170.58C157.76 170.64 157.76 170.71 157.76 170.77C168.59 170.77 179.42 170.77 190.26 170.77C192.41 170.77 194.19 170.87 195.2 167.92C198.98 156.82 206.27 147.92 214.08 139.42C214.94 138.48 216.85 138.03 216.42 136.5C215.89 134.59 213.91 135.29 212.54 135.28C197.04 135.23 181.54 135.24 166.04 135.28C164.72 135.28 163.32 135.34 162.11 135.78C146.51 141.55 133.47 151.21 121.72 162.75C120.28 164.17 118.19 166.1 119.09 168.34C120.11 170.86 123.04 170.52 125.27 170.54C136.1 170.65 146.93 170.59 157.76 170.59V170.58ZM227.05 169.57C241.94 164.45 256.83 159.3 271.74 154.23C275.5 152.95 274.1 151.59 272.02 149.98C261.61 141.9 249.96 139.3 237.27 142.77C229.5 144.89 222.85 149.09 217.48 156.21C222.83 156.48 228.65 151.23 231.81 158.06C234.04 162.88 230.08 165.99 227.05 169.58V169.57ZM152.37 217.84C152.37 217.96 152.37 218.07 152.38 218.19C155.51 217.96 158.7 218.02 161.77 217.44C167.15 216.44 170.23 212.36 170.14 207.27C170.09 204.26 168.6 202.89 165.78 203.33C158.71 204.44 151.76 206.06 145.78 210.26C143.81 211.64 140.75 213.31 141.37 215.73C142.06 218.45 145.52 217.6 147.9 217.8C149.38 217.93 150.88 217.82 152.37 217.82V217.84ZM111.64 237.73C115.44 238.1 119.88 237.59 124.28 236.69C125.43 236.46 126.92 236.23 126.86 234.86C126.79 233.34 125.31 233.38 124.15 233.63C114.21 235.8 104.4 234.96 94.6402 232.47C93.5902 232.2 92.0302 231.87 91.6302 233.3C91.2002 234.83 92.8802 235.12 93.8402 235.45C99.3802 237.37 105.12 237.98 111.66 237.73H111.64ZM74.3802 215.94C76.8402 215.58 79.9502 216.02 80.4802 212.46C80.8002 210.34 74.9702 204.48 72.7502 204.84C69.2502 205.41 69.7602 208.54 69.4902 211.07C69.1102 214.69 70.6402 216.38 74.3802 215.94Z" + fill="#0540f2" + /> + <path + d="M146.03 17.87C147.18 17.55 149.49 20.09 149.73 19.91C151.8 18.46 153.03 14.04 155.96 16.47C158.5 18.58 156.89 20.97 155.53 23.16C155.19 23.7 154.92 24.38 154.9 25C154.7 33.3 151.2 37.23 143.52 37.69C139.81 37.91 134.17 42.94 133.44 46.59C133.24 47.56 131.85 48.77 134.34 48.57C139.43 48.15 139.68 54.02 141.28 57.5C142.67 60.52 143.81 63.66 145.03 66.76C145.66 68.36 146.57 69.58 148.19 70.42C153.02 72.95 154.47 77.27 152.86 83.41C151.58 88.27 147.66 87.92 144.02 87.93C98.8802 87.98 53.7402 87.99 8.60017 88C4.60017 88 0.80017 87.91 0.20017 82.5C-0.61983 75.01 1.02017 71.38 6.70017 69.99C8.99017 69.43 10.0802 68.17 10.8602 66.21C12.3902 62.34 14.2102 58.56 15.4802 54.61C17.0502 49.7 20.1902 47.96 25.2202 48C44.0402 48.16 62.8602 48.06 81.6802 48.06C92.1702 48.06 102.68 48.54 113.16 48.13C119.86 47.86 120.56 45.62 122.61 42.34C124.37 39.52 125.56 38.03 127.6 35.4C129.93 32.38 130.6 26.68 130.49 25.3C129.9 18.05 133.22 14.25 140.72 12.99C141.6 12.84 142.43 12.03 143.14 11.37C144.71 9.92001 146.75 8.29001 148.41 10.27C149.1 11.1 150.27 12.03 148.85 13.65C148.02 14.6 146.76 16.42 146.04 17.86L146.03 17.87Z" + fill="#0540f2" + /> + <path + d="M210.3 239.8C210.02 246.7 209.11 253.45 205.56 259.52C200.94 267.43 193.13 267.64 187.97 260.1C181.47 250.58 181.3 228.61 187.66 218.81C192.42 211.47 200.35 211.37 205.04 218.76C209.1 225.16 210.04 232.42 210.29 239.8H210.3Z" + fill="#0540f2" + /> + <path + d="M270.08 214.27C269.75 208.04 270.84 201.88 273.87 196.14C275.84 192.41 278.56 189.54 283.1 189.55C287.56 189.55 290.09 192.65 291.64 196.23C296.91 208.38 296.52 220.51 290.59 232.35C288.86 235.8 286.05 238.2 281.92 238.06C277.81 237.92 275.32 235.31 273.51 231.9C270.61 226.44 269.86 220.56 270.09 214.28L270.08 214.27Z" + fill="#0540f2" + /> + <path + d="M94.0202 266.54C89.9602 266.35 86.6802 264.74 84.4902 261.23C83.5802 259.77 83.1902 258.17 84.1402 256.55C85.1402 254.84 86.8202 254.71 88.4902 254.94C91.9202 255.42 95.3302 256.04 98.7402 256.6C100.4 256.87 102.08 257.15 102.65 259.09C103.25 261.14 102.17 262.6 100.78 263.86C98.8802 265.58 96.6002 266.46 94.0202 266.55V266.54Z" + fill="#0540f2" + /> + <path + d="M90.5702 147.9C91.5702 147.9 92.5702 147.9 93.5602 147.9C95.9602 147.92 98.3302 148.05 99.2902 150.83C100.21 153.49 98.7502 155.32 96.9202 156.9C86.9802 165.48 77.0302 174.03 67.0502 182.56C65.2802 184.08 63.2002 184.82 61.2002 183.06C59.3402 181.43 59.1602 179.25 60.3202 177.15C62.0002 174.09 63.8802 171.15 65.6502 168.14C67.2002 165.5 66.5502 164.25 63.3502 164.08C60.4002 163.92 56.4602 165.41 55.1702 161.28C53.9602 157.39 57.4402 155.38 59.6702 153.03C65.7302 146.64 71.8402 140.29 77.9402 133.93C78.6302 133.21 79.2802 132.43 80.0602 131.82C82.3602 130.03 84.7002 127.11 87.8302 129.47C90.7402 131.66 88.8602 134.61 87.7102 137.18C87.1702 138.39 86.5502 139.57 85.9602 140.76C82.4802 147.82 82.5902 147.99 90.5702 147.9Z" + fill="#0540f2" + /> + </mask> + <g mask="url(#mask0_377_112)"> + <path d="M-4 49H314V272H-4V49Z" fill="#0540f2" /> + </g> + </g> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M144.764 19.9492C144.7 20.1797 144.644 20.4186 144.598 20.6658C144.392 21.0585 144.17 21.4443 143.934 21.8234C143.819 21.8832 143.745 21.9751 143.713 22.0991C143.598 22.1589 143.524 22.2507 143.492 22.3747C143.377 22.4345 143.303 22.5263 143.271 22.6503C142.812 23.1541 142.36 23.6687 141.916 24.1938C141.95 24.8656 142.291 25.3065 142.939 25.5168C143.126 25.6672 143.329 25.7039 143.547 25.627C143.83 25.5137 144.088 25.3574 144.321 25.1585C144.926 24.4453 145.571 23.7654 146.256 23.1189C146.637 22.8102 147.042 22.5437 147.473 22.3196C147.557 22.3236 147.612 22.2868 147.639 22.2093C148.789 21.8706 149.738 22.1646 150.486 23.0913C150.91 23.6068 151.076 24.1948 150.983 24.8553C150.762 25.5725 150.412 26.2156 149.933 26.7847C149.182 27.5333 148.472 28.3142 147.804 29.1275C147.898 29.3056 148 29.4802 148.109 29.6512C148.534 30.0576 148.977 30.4435 149.435 30.8088C149.533 30.9484 149.616 31.0954 149.684 31.2498C149.734 31.5404 149.752 31.8344 149.739 32.1318C149.688 32.1907 149.669 32.2641 149.684 32.3523C149.605 32.4634 149.569 32.592 149.574 32.7382C149.432 33.0034 149.303 33.279 149.187 33.565C149.066 33.7312 148.974 33.915 148.91 34.1163C147.851 36.0729 146.321 37.5429 144.321 38.5263C144.095 38.5749 143.892 38.6668 143.713 38.8019C143.513 38.8472 143.328 38.9206 143.16 39.0224C142.779 39.1306 142.411 39.2592 142.055 39.4083C141.893 39.4119 141.745 39.4486 141.612 39.5185C140.655 39.5649 139.697 39.5832 138.737 39.5736C138.583 39.5198 138.417 39.5014 138.24 39.5185C137.819 39.4061 137.395 39.3878 136.968 39.4634C136.849 39.4563 136.739 39.4839 136.637 39.5461C136.001 40.2168 135.365 40.8874 134.729 41.5581C134.651 41.6632 134.586 41.7734 134.536 41.8889C134.458 41.915 134.421 41.9701 134.425 42.0543C134.348 42.1314 134.293 42.2233 134.259 42.3299C134.181 42.356 134.145 42.4111 134.149 42.4953C133.845 42.8799 133.569 43.2842 133.319 43.708C133.242 43.7341 133.205 43.7892 133.209 43.8734C133.131 43.8995 133.094 43.9546 133.098 44.0388C133.02 44.0648 132.984 44.12 132.988 44.2041C132.868 44.3321 132.776 44.4792 132.711 44.6451C132.633 44.6712 132.597 44.7263 132.601 44.8105C132.523 44.8366 132.486 44.8917 132.49 44.9759C132.412 45.002 132.375 45.0571 132.379 45.1413C131.86 45.7917 131.38 46.4716 130.942 47.1809C130.864 47.207 130.827 47.2621 130.831 47.3463C130.755 47.4234 130.699 47.5153 130.666 47.6219C130.569 47.6858 130.513 47.7777 130.5 47.8975C130.286 48.2495 130.102 48.617 129.947 49C126.298 49 122.649 49 119 49C119.293 48.7536 119.56 48.478 119.802 48.1731C121.817 45.3922 123.789 42.5809 125.717 39.739C126.998 38.0761 128.214 36.3672 129.366 34.6124C129.411 34.5057 129.439 34.3954 129.449 34.2817C129.518 33.9437 129.555 33.5946 129.56 33.2343C129.56 33.1608 129.56 33.0873 129.56 33.0138C129.597 32.8402 129.615 32.6565 129.615 32.4625C129.62 32.1372 129.584 31.8249 129.505 31.5254C129.495 31.2203 129.458 30.9264 129.394 30.6434C129.313 29.2829 129.323 27.9232 129.422 26.5642C129.466 25.8023 129.622 25.0673 129.892 24.3592C129.969 24.3331 130.006 24.278 130.002 24.1938C129.993 24.1391 130.011 24.1024 130.057 24.0835C130.135 24.0575 130.172 24.0023 130.168 23.9182C130.158 23.8635 130.177 23.8268 130.223 23.8079C130.301 23.7819 130.338 23.7267 130.334 23.6425C130.44 23.4352 130.559 23.233 130.693 23.0362C131.751 21.6409 132.995 20.4465 134.425 19.4531C134.509 19.4571 134.565 19.4203 134.591 19.3428C134.841 19.2447 135.099 19.2172 135.365 19.2601C135.949 19.6688 136.539 20.0639 137.134 20.4453C137.296 20.5991 137.462 20.6175 137.632 20.5004C137.972 20.337 138.276 20.1165 138.544 19.8389C138.93 19.3162 139.345 18.8201 139.788 18.3506C140.005 18.2257 140.19 18.0603 140.341 17.8544C140.789 17.3829 141.342 17.0981 141.999 17C143.934 17.0279 144.856 18.011 144.764 19.9492Z" + fill="#0540f2" + /> + <defs> + <clipPath id="clip0_377_112"> + <rect width="305.4" height="266.54" fill="white" /> + </clipPath> + </defs> + </svg> + ); +}; + +export default LogoIcon; diff --git a/src/components/ui/ToastContainer.tsx b/src/components/ui/ToastContainer.tsx new file mode 100644 index 00000000..68a8111e --- /dev/null +++ b/src/components/ui/ToastContainer.tsx @@ -0,0 +1,14 @@ +import { useExternalValue } from '@utils/external-state'; + +import { toastListStore } from '@stores/layout/toastStore'; + +import type { ToastProps } from '@common/Toast/Toast'; +import Toast from '@common/Toast/Toast'; + +const ToastContainer = () => { + const toastItems = useExternalValue<ToastProps[]>(toastListStore); + + return toastItems.map((toastItem) => <Toast key={toastItem.toastId} {...toastItem} />); +}; + +export default ToastContainer; diff --git a/src/components/ui/WarningModal/ZoomWarningModal.style.ts b/src/components/ui/WarningModal/ZoomWarningModal.style.ts new file mode 100644 index 00000000..47c0b782 --- /dev/null +++ b/src/components/ui/WarningModal/ZoomWarningModal.style.ts @@ -0,0 +1,24 @@ +import { css } from 'styled-components'; + +import { getToastColor } from '@common/Toast/Toast.style'; + +import type { ZoomWarningModalProps } from '@ui/WarningModal/ZoomWarningModal'; + +export const ZOOM_WARNING_WIDTH = 21.8; + +export const backgroundCss = ({ + backgroundColor, + color, + css: _css, +}: Omit<ZoomWarningModalProps, 'message'>) => css` + ${!backgroundColor && getToastColor('dark')} + + width: ${ZOOM_WARNING_WIDTH}rem; + text-align: center; + border-width: 1.35px; + border-radius: 10px; + color: ${color}; + background: ${backgroundColor}; + + ${_css} +`; diff --git a/src/components/ui/WarningModal/ZoomWarningModal.tsx b/src/components/ui/WarningModal/ZoomWarningModal.tsx new file mode 100644 index 00000000..aeb5dcfa --- /dev/null +++ b/src/components/ui/WarningModal/ZoomWarningModal.tsx @@ -0,0 +1,22 @@ +import type { CSSProp } from 'styled-components'; + +import Text from '@common/Text'; + +import { backgroundCss } from '@ui/WarningModal/ZoomWarningModal.style'; + +export interface ZoomWarningModalProps { + message?: string; + color?: string; + backgroundColor?: string; + css?: CSSProp; +} + +const ZoomWarningModal = ({ message, color, backgroundColor, css }: ZoomWarningModalProps) => { + return ( + <Text variant="h6" py={5} css={backgroundCss({ backgroundColor, color, css })}> + {message ? message : '지도를 확대해 주세요'} + </Text> + ); +}; + +export default ZoomWarningModal; diff --git a/src/components/ui/WarningModal/index.tsx b/src/components/ui/WarningModal/index.tsx new file mode 100644 index 00000000..8014cca9 --- /dev/null +++ b/src/components/ui/WarningModal/index.tsx @@ -0,0 +1,3 @@ +import ZoomWarningModal from './ZoomWarningModal'; + +export default ZoomWarningModal; diff --git a/src/components/ui/WarningModalContainer.tsx b/src/components/ui/WarningModalContainer.tsx new file mode 100644 index 00000000..f70f2343 --- /dev/null +++ b/src/components/ui/WarningModalContainer.tsx @@ -0,0 +1,53 @@ +import { css } from 'styled-components'; + +import { useExternalValue } from '@utils/external-state'; + +import { navigationBarPanelStore } from '@stores/layout/navigationBarPanelStore'; +import { + warningModalActions, + warningModalContentStore, + warningModalOpenStore, +} from '@stores/layout/warningModalStore'; + +import Modal from '@common/Modal'; + +import { MOBILE_BREAKPOINT, NAVIGATOR_PANEL_WIDTH } from '@constants'; + +const WarningModalContainer = () => { + const isModalOpen = useExternalValue(warningModalOpenStore); + const modalContent = useExternalValue(warningModalContentStore); + const { basePanel, lastPanel } = useExternalValue(navigationBarPanelStore); + const navigationComponentWidth = + (basePanel === null ? 0 : NAVIGATOR_PANEL_WIDTH) + + (lastPanel === null ? 0 : NAVIGATOR_PANEL_WIDTH); + + return ( + <Modal + noOverflowHidden + noBackdrop + isOpen={isModalOpen} + onClose={() => warningModalActions.closeModal()} + css={warningModalCss(navigationComponentWidth)} + > + {modalContent} + </Modal> + ); +}; + +const warningModalCss = (navigationComponentWidth: number) => css` + position: fixed; + top: 50%; + left: 50%; + z-index: 9; + + transform: translate(calc(-50% + ${navigationComponentWidth / 2}rem), -50%); + margin: 0; + + background: transparent; + + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + transform: translate(-50%, -50%); + } +`; + +export default WarningModalContainer; diff --git a/src/components/ui/modal/CarModal/CarModal.stories.tsx b/src/components/ui/modal/CarModal/CarModal.stories.tsx new file mode 100644 index 00000000..555e7978 --- /dev/null +++ b/src/components/ui/modal/CarModal/CarModal.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta } from '@storybook/react'; + +import CarModal from './CarModal'; + +const meta = { + title: 'UI/CarModal', + component: CarModal, + tags: ['autodocs'], +} satisfies Meta<typeof CarModal>; + +export default meta; + +export const Default = () => { + return <CarModal />; +}; diff --git a/src/components/ui/modal/CarModal/CarModal.tsx b/src/components/ui/modal/CarModal/CarModal.tsx new file mode 100644 index 00000000..1faee6b7 --- /dev/null +++ b/src/components/ui/modal/CarModal/CarModal.tsx @@ -0,0 +1,180 @@ +import { css, styled } from 'styled-components'; + +import { useState } from 'react'; + +import { useQueryClient } from '@tanstack/react-query'; + +import { useExternalState } from '@utils/external-state'; + +import { modalActions } from '@stores/layout/modalStore'; +import { toastActions } from '@stores/layout/toastStore'; +import { memberInfoStore } from '@stores/login/memberInfoStore'; +import { serverStationFilterAction } from '@stores/station-filters/serverStationFiltersStore'; + +import { useCars } from '@hooks/tanstack-query/car/useCars'; + +import ButtonNext from '@common/ButtonNext'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import LogoIcon from '@ui/Svg/LogoIcon'; + +import { QUERY_KEY_STATION_MARKERS } from '@constants/queryKeys'; + +import { getCarFilters, submitMemberCar, submitMemberFilters } from './fetch'; + +const CarModal = () => { + const queryClient = useQueryClient(); + + const { data: cars, isLoading } = useCars(); + const { setAllServerStationFilters } = serverStationFilterAction; + + const [memberInfo, setMemberInfo] = useExternalState(memberInfoStore); + const [carName, setCarName] = useState(memberInfo.car !== null ? memberInfo.car.name : '모델명'); + const [vintage, setVintage] = useState(memberInfo.car !== null ? memberInfo.car.vintage : '연식'); + + const handleSelectCarName = (name: string) => { + setCarName(name); + setVintage('연식'); + }; + + const handleSelectVintage = (vintage: string) => { + setVintage(vintage); + }; + + const handleFetchCarFilters = async () => { + if (carName === '모델명') { + toastActions.showToast('차량 모델명을 선택해주세요', 'error'); + return; + } + + if (vintage === '연식') { + toastActions.showToast('차량 연식을 선택해주세요', 'error'); + return; + } + + try { + const { carId } = await submitMemberCar(carName, vintage); + const carFilters = await getCarFilters(carId); + const memberFilters = await submitMemberFilters(carFilters); + + setAllServerStationFilters(memberFilters); + queryClient.invalidateQueries({ queryKey: [QUERY_KEY_STATION_MARKERS] }); + setMemberInfo((prev) => ({ + ...prev, + car: { + ...prev.car, + name: carName, + vintage, + }, + })); + + toastActions.showToast('챠량 등록이 완료되었습니다'); + modalActions.closeModal(); + } catch (error) { + toastActions.showToast(error.message, 'error'); + modalActions.closeModal(); + } + }; + + if (isLoading || cars === undefined) { + return ( + <FlexBox direction="column" alignItems="center"> + <LogoIcon width={10} /> + <Text variant="h5">로딩중...</Text> + </FlexBox> + ); + } + + const carNames = [...new Set([...(cars ?? []).map((car) => car.name)])]; + + return ( + <FlexBox width={34} direction="column" alignItems="center" css={paddingCss}> + <Text variant="h5" mb={3}> + 차량 선택 + </Text> + <FlexBox width="100%"> + <FlexBox css={[flexRatioCss, selectBoxCss]}> + <SelectBox + options={['모델명', ...carNames]} + onChange={handleSelectCarName} + value={carName} + /> + </FlexBox> + <FlexBox css={[flexRatioCss, selectBoxCss]}> + <SelectBox + options={[ + '연식', + ...cars + .filter((car) => car.name === (carName !== '모델명' ? carName : carNames[0])) + .map((car) => car.vintage), + ]} + onChange={handleSelectVintage} + value={vintage} + /> + </FlexBox> + </FlexBox> + <FlexBox width="100%"> + <ButtonNext onClick={modalActions.closeModal} variant="outlined" css={flexRatioCss}> + 취소 + </ButtonNext> + <ButtonNext + onClick={() => { + try { + handleFetchCarFilters(); + } catch (error) { + toastActions.showToast(error.message, 'error'); + } + }} + variant="contained" + css={flexRatioCss} + > + 등록 + </ButtonNext> + </FlexBox> + </FlexBox> + ); +}; + +const SelectBox = ({ + options, + onChange, + value, +}: { + options: string[]; + onChange: (option: string) => void; + value: string; +}) => { + return ( + <StyledSelectBox + onChange={({ target: { value } }) => { + onChange(value); + }} + value={value} + > + {options.map((option, index) => ( + <option key={index} value={option}> + {option} + </option> + ))} + </StyledSelectBox> + ); +}; + +const flexRatioCss = css` + flex: 1; +`; + +const selectBoxCss = css` + height: 4rem; +`; + +const paddingCss = css` + padding: 1rem; +`; + +const StyledSelectBox = styled.select` + flex: 1; +`; + +export default CarModal; diff --git a/src/components/ui/modal/CarModal/fetch/index.ts b/src/components/ui/modal/CarModal/fetch/index.ts new file mode 100644 index 00000000..a910102b --- /dev/null +++ b/src/components/ui/modal/CarModal/fetch/index.ts @@ -0,0 +1,64 @@ +import { fetchUtils } from '@utils/fetch'; + +import { memberInfoStore } from '@stores/login/memberInfoStore'; +import { serverStationFilterAction } from '@stores/station-filters/serverStationFiltersStore'; + +import { SERVER_URL } from '@constants/server'; + +import type { StationFilters } from '@type'; +import type { Car } from '@type/cars'; + +/** + * 로그인한 member의 차량을 서버에 등록하고, 등록된 차량의 정보를 반환하는 메서드 + * + * @param carName 차량 이름 + * @param vintage 차량 연식 + * @returns 등록된 member의 차량 정보 { carId, name, vintage } + */ +export const submitMemberCar = async (carName: string, vintage: string): Promise<Car> => { + const memberId = memberInfoStore.getState()?.memberId; + + const memberCarInfo = await fetchUtils.post<Car, Omit<Car, 'carId'>>( + `${SERVER_URL}/members/${memberId}/cars`, + { name: carName, vintage }, + '차량 정보를 등록하는 중에 오류가 발생했습니다' + ); + + return memberCarInfo; +}; + +/** + * 선택한 차량에 해당하는 필터 정보를 반환하는 메서드 + * + * @param carId 차량 아이디 + * @returns 차량 필터 정보 { companies, capacities, connectorTypes } + */ +export const getCarFilters = async (carId: number): Promise<StationFilters> => { + const carFilters = await fetchUtils.get<StationFilters>( + `${SERVER_URL}/cars/${carId}/filters`, + '차량 필터 정보를 불러오는 중에 에러가 발생했습니다' + ); + + return carFilters; +}; + +/** + * 차량의 필터 정보를 member의 필터 정보에 저장한 후 저장된 모든 필터 정보들을 반환하는 메서드 + * + * @param carFilters 차량 필터 정보들 { companies, capacities, connectorTypes } + * @returns member에게 등록된 필터 정보 { companies, capacities, connectorTypes } + */ +export const submitMemberFilters = async (carFilters: StationFilters) => { + const memberId = memberInfoStore.getState()?.memberId; + const { setAllServerStationFilters, getMemberFilterRequestBody } = serverStationFilterAction; + setAllServerStationFilters(carFilters); + const memberFilterRequestBody = getMemberFilterRequestBody(); + + const memberFilters = fetchUtils.post<StationFilters, typeof memberFilterRequestBody>( + `${SERVER_URL}/members/${memberId}/filters`, + memberFilterRequestBody, + '필터링 정보를 저장하는 중 오류가 발생했습니다' + ); + + return memberFilters; +}; diff --git a/src/components/ui/modal/LoginModal/LoginModal.stories.tsx b/src/components/ui/modal/LoginModal/LoginModal.stories.tsx new file mode 100644 index 00000000..bce55e4c --- /dev/null +++ b/src/components/ui/modal/LoginModal/LoginModal.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta } from '@storybook/react'; + +import LoginModal from './LoginModal'; + +const meta = { + title: 'UI/LoginModal', + component: LoginModal, + tags: ['autodocs'], +} satisfies Meta<typeof LoginModal>; + +export default meta; + +export const Default = () => { + return <LoginModal />; +}; diff --git a/src/components/ui/modal/LoginModal/LoginModal.tsx b/src/components/ui/modal/LoginModal/LoginModal.tsx new file mode 100644 index 00000000..305041df --- /dev/null +++ b/src/components/ui/modal/LoginModal/LoginModal.tsx @@ -0,0 +1,74 @@ +import { XMarkIcon } from '@heroicons/react/24/outline'; +import { styled } from 'styled-components'; + +import { redirectToLoginPage } from '@utils/login'; + +import { modalActions } from '@stores/layout/modalStore'; + +import Button from '@common/Button'; +import FlexBox from '@common/FlexBox'; +import Text from '@common/Text'; + +import GoogleLogo from '@assets/google-logo.svg'; +import Logo from '@assets/logo-md.svg'; + +const LoginModal = () => { + const handleLogin = () => { + redirectToLoginPage('google'); + }; + + return ( + <FlexBox + tag="section" + direction="column" + justifyContent="center" + alignItems="center" + maxWidth={32} + width="calc(100vw - 4rem)" + height={23.6} + px={10} + > + <Button mt={-11} mr={-6} mb={-1} ml="auto" onClick={modalActions.closeModal}> + <XMarkIcon width={28} /> + </Button> + + <Logo /> + <Text tag="h2" variant="h5" weight="regular" color="#333" mt={2}> + 카페인 + </Text> + + <GoogleLogin onClick={handleLogin}> + <GoogleLogo width="24" /> + <Text variant="label" weight="regular" color="#666"> + 구글 로그인 + </Text> + </GoogleLogin> + </FlexBox> + ); +}; + +const GoogleLogin = styled.button` + display: flex; + align-items: center; + + width: 100%; + max-width: 24rem; + height: 4.4rem; + + margin-top: 3.2rem; + margin-bottom: -1.6rem; + padding: 0 1.8rem; + + border-radius: 8px; + box-shadow: 1px 1px 5px 2px #e7e7e7db; + + &:hover { + box-shadow: 0.8px 1px 5px 2px #e7e7e7; + } + + & p { + width: calc(100% - 40px); + } +`; + +export default LoginModal; diff --git a/src/hooks/fetch/fetchStationSummaries.ts b/src/hooks/fetch/fetchStationSummaries.ts new file mode 100644 index 00000000..d618ba06 --- /dev/null +++ b/src/hooks/fetch/fetchStationSummaries.ts @@ -0,0 +1,22 @@ +import { DELIMITER } from '@constants'; +import { SERVER_URL } from '@constants/server'; + +import type { StationSummary } from '@type'; + +export const fetchStationSummaries = async (stationIds: string[]) => { + const stationSummaries = await fetch( + `${SERVER_URL}/stations/summary?stationIds=${stationIds.join(DELIMITER)}`, + { + method: 'GET', + } + ).then<StationSummary[]>(async (response) => { + if (!response.ok) { + throw new Error('충전소 요약 정보를 불러오는데 실패했습니다.'); + } + + const data = await response.json(); + return data.stations; + }); + + return stationSummaries; +}; diff --git a/src/hooks/google-maps/useStationInfoWindow.tsx b/src/hooks/google-maps/useStationInfoWindow.tsx new file mode 100644 index 00000000..a26d661a --- /dev/null +++ b/src/hooks/google-maps/useStationInfoWindow.tsx @@ -0,0 +1,54 @@ +import { useExternalValue } from '@utils/external-state'; +import { getDisplayPosition } from '@utils/google-maps'; +import { getCalculatedMapDelta } from '@utils/google-maps/getCalculatedMapDelta'; + +import { getGoogleMapStore } from '@stores/google-maps/googleMapStore'; +import { markerInstanceStore } from '@stores/google-maps/markerInstanceStore'; +import { getStationInfoWindowStore } from '@stores/google-maps/stationInfoWindowStore'; + +import useMediaQueries from '@hooks/useMediaQueries'; + +import StationInfoWindow from '@ui/StationInfoWindow'; + +export const useStationInfoWindow = () => { + const googleMap = useExternalValue(getGoogleMapStore()); + const infoWindowInstance = useExternalValue(getStationInfoWindowStore()); + const screen = useMediaQueries(); + + const moveMapToStationMarker = (markerInstance: google.maps.marker.AdvancedMarkerElement) => { + const { latitudeDelta } = getDisplayPosition(getGoogleMapStore().getState()); + const { lat, lng } = markerInstance.position as google.maps.LatLngLiteral; + const calculatedMapDelta = getCalculatedMapDelta(); + + /* 모바일과 PC에서 UI가 서로 다르기 때문에 마커를 클릭했을 때 지도를 이동하는 위치가 달라지는 것을 반영한 코드입니다. + latitude는 PC에서는 그대로, 모바일에서는 살짝 아래로 밀리도록 계산 (검색창과 간단 정보창이 겹치는 경우 방지) + longitude는 PC에서는 오른쪽으로 밀리도록 (패널과 간단 정보창이 겹치는 경우 방지), 모바일에서는 그대로 계산 */ + const latitude = screen.get('isMobile') ? lat + latitudeDelta / 3 : lat; + const longitude = screen.get('isMobile') ? lng : lng - calculatedMapDelta / 2; + + googleMap.panTo({ lat: latitude, lng: longitude }); + }; + + const openStationInfoWindow = ( + stationId: string, + stationMarkerInstance?: google.maps.marker.AdvancedMarkerElement + ) => { + const stationMarker = markerInstanceStore + .getState() + .find((stationMarker) => stationMarker.stationId === stationId); + const markerInstance = stationMarkerInstance ?? stationMarker.instance; + + moveMapToStationMarker(markerInstance); + + infoWindowInstance.infoWindowInstance.open({ + anchor: markerInstance, + map: googleMap, + }); + + infoWindowInstance.stationInfoWindowRoot.render( + <StationInfoWindow selectedStationId={stationId} /> + ); + }; + + return { openStationInfoWindow }; +}; diff --git a/src/hooks/tanstack-query/car/useCars.ts b/src/hooks/tanstack-query/car/useCars.ts new file mode 100644 index 00000000..4da8af1d --- /dev/null +++ b/src/hooks/tanstack-query/car/useCars.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; + +import { SERVER_URL } from '@constants/server'; + +import type { Car } from '@type/cars'; + +interface CarResponse { + cars: Car[]; +} + +const fetchCars = async () => { + const cars = await fetch(`${SERVER_URL}/cars`).then<CarResponse>((response) => response.json()); + + return cars.cars; +}; + +export const useCars = () => { + return useQuery({ + queryKey: [], + queryFn: fetchCars, + }); +}; diff --git a/src/hooks/tanstack-query/station-details/reports/useStationChargerReport.ts b/src/hooks/tanstack-query/station-details/reports/useStationChargerReport.ts new file mode 100644 index 00000000..f218e141 --- /dev/null +++ b/src/hooks/tanstack-query/station-details/reports/useStationChargerReport.ts @@ -0,0 +1,36 @@ +import { useQuery } from '@tanstack/react-query'; + +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import { EMPTY_MEMBER_TOKEN } from '@constants'; +import { QUERY_KEY_STATION_CHARGER_REPORT } from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +const fetchStationChargerReport = (stationId: string) => { + const memberToken = memberTokenStore.getState(); + const headers = + memberToken === EMPTY_MEMBER_TOKEN + ? { + 'Content-Type': 'application/json', + } + : { + Authorization: `Bearer ${memberToken}`, + 'Content-Type': 'application/json', + }; + + return fetch(`${SERVER_URL}/stations/${stationId}/reports/me`, { + method: 'GET', + headers: headers, + }).then<boolean>(async (response) => { + const data = await response.json(); + return data.isReported; + }); +}; + +export const useStationChargerReport = (stationId: string) => { + return useQuery({ + queryKey: [QUERY_KEY_STATION_CHARGER_REPORT, stationId], + queryFn: () => fetchStationChargerReport(stationId), + refetchOnWindowFocus: false, + }); +}; diff --git a/src/hooks/tanstack-query/station-details/reports/useUpdateStationChargerReport.ts b/src/hooks/tanstack-query/station-details/reports/useUpdateStationChargerReport.ts new file mode 100644 index 00000000..ab444961 --- /dev/null +++ b/src/hooks/tanstack-query/station-details/reports/useUpdateStationChargerReport.ts @@ -0,0 +1,39 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { modalActions } from '@stores/layout/modalStore'; +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import { QUERY_KEY_STATION_CHARGER_REPORT, QUERY_KEY_STATION_DETAILS } from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +const fetchReportCharger = async (stationId: string) => { + const memberToken = memberTokenStore.getState(); + return fetch(`${SERVER_URL}/stations/${stationId}/reports`, { + method: 'POST', + headers: { + Authorization: `Bearer ${memberToken}`, + 'Content-Type': 'application/json', + }, + }); +}; +export const useUpdateStationChargerReport = (stationId: string) => { + const queryClient = useQueryClient(); + + const { mutate } = useMutation({ + mutationFn: fetchReportCharger, + onSuccess: () => { + alert('신고가 완료됐습니다.'); + modalActions.closeModal(); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY_STATION_DETAILS, stationId] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEY_STATION_CHARGER_REPORT] }); + }, + }); + + const updateStationChargerReport = (stationId: string) => { + mutate(stationId); + }; + + return { updateStationChargerReport }; +}; diff --git a/src/hooks/tanstack-query/station-details/reports/useUpdateStationReport.ts b/src/hooks/tanstack-query/station-details/reports/useUpdateStationReport.ts new file mode 100644 index 00000000..0135ebd3 --- /dev/null +++ b/src/hooks/tanstack-query/station-details/reports/useUpdateStationReport.ts @@ -0,0 +1,42 @@ +import { useMutation } from '@tanstack/react-query'; + +import { modalActions } from '@stores/layout/modalStore'; +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import type { Differences } from '@ui/StationDetailsWindow/reports/station/StationReportConfirmation'; + +import { SERVER_URL } from '@constants/server'; + +interface FetchReportStationRequest { + stationId: string; + differences: Differences[]; +} + +const fetchReportStation = async (fetchReportStationRequestParams: FetchReportStationRequest) => { + const { stationId, differences } = fetchReportStationRequestParams; + const memberToken = memberTokenStore.getState(); + + return fetch(`${SERVER_URL}/stations/${stationId}/misinformation-reports`, { + method: 'POST', + headers: { + Authorization: `${memberToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ stationDetailsToUpdate: differences }), + }); +}; +export const useUpdateStationReport = () => { + const { mutate, isLoading } = useMutation({ + mutationFn: fetchReportStation, + onSuccess: () => { + alert('제보가 완료됐습니다.'); + modalActions.closeModal(); + }, + }); + + const updateStationReport = (fetchReportStationRequestParams: FetchReportStationRequest) => { + mutate(fetchReportStationRequestParams); + }; + + return { updateStationReport, isLoading }; +}; diff --git a/src/hooks/tanstack-query/station-details/reviews/useCreateReply.ts b/src/hooks/tanstack-query/station-details/reviews/useCreateReply.ts new file mode 100644 index 00000000..4228fed4 --- /dev/null +++ b/src/hooks/tanstack-query/station-details/reviews/useCreateReply.ts @@ -0,0 +1,50 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { toastActions } from '@stores/layout/toastStore'; +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import { QUERY_KEY_STATION_PREVIEWS, QUERY_KEY_STATION_REVIEWS } from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +export interface FetchCreateReplyRequest { + reviewId: number; + content: string; +} + +const fetchCreateReply = async (fetchCreateReplyRequestParams: FetchCreateReplyRequest) => { + const { reviewId, content } = fetchCreateReplyRequestParams; + const memberToken = memberTokenStore.getState(); + return fetch(`${SERVER_URL}/reviews/${reviewId}/replies`, { + method: 'POST', + headers: { + Authorization: `Bearer ${memberToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ content }), + }); +}; + +export const useCreateReply = (stationId: string) => { + const queryClient = useQueryClient(); + + const { mutate, isLoading: isCreateReplyLoading } = useMutation({ + mutationFn: fetchCreateReply, + onSuccess: () => { + toastActions.showToast('답글이 등록됐습니다.', 'success', 'bottom-center'); + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_REVIEWS, stationId], + }); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_PREVIEWS, stationId], + }); + }, + }); + + const createReply = (fetchCreateReplyRequestParams: FetchCreateReplyRequest) => { + mutate(fetchCreateReplyRequestParams); + }; + + return { createReply, isCreateReplyLoading }; +}; diff --git a/src/hooks/tanstack-query/station-details/reviews/useCreateReview.ts b/src/hooks/tanstack-query/station-details/reviews/useCreateReview.ts new file mode 100644 index 00000000..693a109d --- /dev/null +++ b/src/hooks/tanstack-query/station-details/reviews/useCreateReview.ts @@ -0,0 +1,52 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { toastActions } from '@stores/layout/toastStore'; +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import { QUERY_KEY_STATION_PREVIEWS, QUERY_KEY_STATION_REVIEWS } from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +export interface FetchCreateReviewRequest { + stationId: string; + ratings: number; + content: string; +} + +const fetchCreateReview = async (fetchCreateReviewRequestParams: FetchCreateReviewRequest) => { + const { stationId, ratings, content } = fetchCreateReviewRequestParams; + const memberToken = memberTokenStore.getState(); + + return fetch(`${SERVER_URL}/stations/${stationId}/reviews`, { + method: 'POST', + headers: { + Authorization: `Bearer ${memberToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ratings, content }), + }); +}; + +export const useCreateReview = (stationId: string) => { + const queryClient = useQueryClient(); + + const { mutate, isLoading: isCreateReviewLoading } = useMutation({ + mutationFn: fetchCreateReview, + onSuccess: () => { + toastActions.showToast('리뷰가 등록됐습니다.', 'success', 'bottom-center'); + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_REVIEWS, stationId], + }); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_PREVIEWS, stationId], + }); + }, + }); + + const createReview = (fetchCreateReviewRequestParams: FetchCreateReviewRequest) => { + mutate(fetchCreateReviewRequestParams); + }; + + return { createReview, isCreateReviewLoading }; +}; diff --git a/src/hooks/tanstack-query/station-details/reviews/useInfiniteReplies.ts b/src/hooks/tanstack-query/station-details/reviews/useInfiniteReplies.ts new file mode 100644 index 00000000..f6db3ac2 --- /dev/null +++ b/src/hooks/tanstack-query/station-details/reviews/useInfiniteReplies.ts @@ -0,0 +1,28 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { QUERY_KEY_STATION_REPLIES } from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +import type { Reply } from '@type'; + +interface InfiniteRepliesResponse { + replies: Reply[]; + nextPage: number; +} + +export const useInfiniteReplies = (reviewId: number) => { + return useInfiniteQuery<InfiniteRepliesResponse>( + [QUERY_KEY_STATION_REPLIES, reviewId], + async ({ pageParam = 0 }) => { + const res = await fetch(`${SERVER_URL}/reviews/${reviewId}/replies?page=${pageParam}`); + const data = await res.json(); + return data; + }, + { + getNextPageParam: (lastPage) => { + return lastPage.nextPage > 0 ? lastPage.nextPage : undefined; + }, + refetchOnWindowFocus: false, + } + ); +}; diff --git a/src/hooks/tanstack-query/station-details/reviews/useInfiniteReviews.ts b/src/hooks/tanstack-query/station-details/reviews/useInfiniteReviews.ts new file mode 100644 index 00000000..10dc9a43 --- /dev/null +++ b/src/hooks/tanstack-query/station-details/reviews/useInfiniteReviews.ts @@ -0,0 +1,28 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { QUERY_KEY_STATION_REVIEWS } from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +import type { Review } from '@type'; + +interface InfiniteRepliesResponse { + reviews: Review[]; + nextPage: number; +} + +export const useInfiniteReviews = (stationId: string) => { + return useInfiniteQuery<InfiniteRepliesResponse>( + [QUERY_KEY_STATION_REVIEWS, stationId], + async ({ pageParam = 0 }) => { + const res = await fetch(`${SERVER_URL}/stations/${stationId}/reviews/?page=${pageParam}`); + const data = await res.json(); + return data; + }, + { + getNextPageParam: (lastPage) => { + return lastPage.nextPage > 0 ? lastPage.nextPage : undefined; + }, + refetchOnWindowFocus: false, + } + ); +}; diff --git a/src/hooks/tanstack-query/station-details/reviews/useModifyReply.ts b/src/hooks/tanstack-query/station-details/reviews/useModifyReply.ts new file mode 100644 index 00000000..23845c98 --- /dev/null +++ b/src/hooks/tanstack-query/station-details/reviews/useModifyReply.ts @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { toastActions } from '@stores/layout/toastStore'; +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import { + QUERY_KEY_STATION_PREVIEWS, + QUERY_KEY_STATION_REPLIES, + QUERY_KEY_STATION_REVIEWS, +} from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +export interface FetchModifyReplyRequest { + replyId: number; + reviewId: number; + content: string; +} + +const fetchModifyReply = async (fetchModifyReplyRequestParams: FetchModifyReplyRequest) => { + const { replyId, content, reviewId } = fetchModifyReplyRequestParams; + const memberToken = memberTokenStore.getState(); + return fetch(`${SERVER_URL}/reviews/${reviewId}/replies/${replyId}`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${memberToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ content }), + }); +}; + +export const useModifyReply = (stationId: string, reviewId: number) => { + const queryClient = useQueryClient(); + + const { mutate, isLoading: isModifyReplyLoading } = useMutation({ + mutationFn: fetchModifyReply, + onSuccess: () => { + toastActions.showToast('답글이 수정됐습니다.', 'success', 'bottom-center'); + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_REPLIES, reviewId], + }); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_REVIEWS, stationId], + }); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_PREVIEWS, stationId], + }); + }, + }); + + const modifyReply = (fetchModifyReplyRequestParams: FetchModifyReplyRequest) => { + mutate(fetchModifyReplyRequestParams); + }; + + return { modifyReply, isModifyReplyLoading }; +}; diff --git a/src/hooks/tanstack-query/station-details/reviews/useModifyReview.ts b/src/hooks/tanstack-query/station-details/reviews/useModifyReview.ts new file mode 100644 index 00000000..d9a51966 --- /dev/null +++ b/src/hooks/tanstack-query/station-details/reviews/useModifyReview.ts @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { toastActions } from '@stores/layout/toastStore'; +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import { + QUERY_KEY_STATION_PREVIEWS, + QUERY_KEY_STATION_REPLIES, + QUERY_KEY_STATION_REVIEWS, +} from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +export interface FetchModifyReviewRequest { + reviewId: number; + ratings: number; + content: string; +} + +const fetchModifyReview = async (fetchModifyReviewRequestParams: FetchModifyReviewRequest) => { + const { reviewId, ratings, content } = fetchModifyReviewRequestParams; + const memberToken = memberTokenStore.getState(); + return fetch(`${SERVER_URL}/reviews/${reviewId}`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${memberToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ratings, content }), + }); +}; + +export const useModifyReview = (stationId: string, reviewId: number) => { + const queryClient = useQueryClient(); + + const { mutate, isLoading: isModifyReviewLoading } = useMutation({ + mutationFn: fetchModifyReview, + onSuccess: () => { + toastActions.showToast('리뷰가 수정됐습니다.', 'success', 'bottom-center'); + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_REPLIES, reviewId], + }); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_REVIEWS, stationId], + }); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_PREVIEWS, stationId], + }); + }, + }); + + const modifyReview = (fetchModifyReviewRequestParams: FetchModifyReviewRequest) => { + mutate(fetchModifyReviewRequestParams); + }; + + return { modifyReview, isModifyReviewLoading }; +}; diff --git a/src/hooks/tanstack-query/station-details/reviews/useRemoveReply.ts b/src/hooks/tanstack-query/station-details/reviews/useRemoveReply.ts new file mode 100644 index 00000000..83c41fba --- /dev/null +++ b/src/hooks/tanstack-query/station-details/reviews/useRemoveReply.ts @@ -0,0 +1,56 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { toastActions } from '@stores/layout/toastStore'; +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import { + QUERY_KEY_STATION_PREVIEWS, + QUERY_KEY_STATION_REPLIES, + QUERY_KEY_STATION_REVIEWS, +} from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +export interface FetchRemoveReplyRequest { + reviewId: number; + replyId: number; +} + +const fetchRemoveReply = async (fetchRemoveReplyRequestParams: FetchRemoveReplyRequest) => { + const { replyId, reviewId } = fetchRemoveReplyRequestParams; + const memberToken = memberTokenStore.getState(); + return fetch(`${SERVER_URL}/reviews/${reviewId}/replies/${replyId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${memberToken}`, + 'Content-Type': 'application/json', + }, + }); +}; + +export const useRemoveReply = (stationId: string, reviewId: number) => { + const queryClient = useQueryClient(); + + const { mutate, isLoading: isRemoveReplyLoading } = useMutation({ + mutationFn: fetchRemoveReply, + onSuccess: () => { + toastActions.showToast('리뷰가 삭제됐습니다.', 'success', 'bottom-center'); + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_REPLIES, reviewId], + }); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_REVIEWS, stationId], + }); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_PREVIEWS, stationId], + }); + }, + }); + + const removeReply = (fetchRemoveReplyRequestParams: FetchRemoveReplyRequest) => { + mutate(fetchRemoveReplyRequestParams); + }; + + return { removeReply, isRemoveReplyLoading }; +}; diff --git a/src/hooks/tanstack-query/station-details/reviews/useRemoveReview.ts b/src/hooks/tanstack-query/station-details/reviews/useRemoveReview.ts new file mode 100644 index 00000000..44f8f4cd --- /dev/null +++ b/src/hooks/tanstack-query/station-details/reviews/useRemoveReview.ts @@ -0,0 +1,55 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { toastActions } from '@stores/layout/toastStore'; +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import { + QUERY_KEY_STATION_PREVIEWS, + QUERY_KEY_STATION_REPLIES, + QUERY_KEY_STATION_REVIEWS, +} from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +export interface FetchRemoveReviewRequest { + reviewId: number; +} + +const fetchRemoveReview = async (fetchRemoveReviewRequestParams: FetchRemoveReviewRequest) => { + const { reviewId } = fetchRemoveReviewRequestParams; + const memberToken = memberTokenStore.getState(); + return fetch(`${SERVER_URL}/reviews/${reviewId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${memberToken}`, + 'Content-Type': 'application/json', + }, + }); +}; + +export const useRemoveReview = (stationId: string, reviewId: number) => { + const queryClient = useQueryClient(); + + const { mutate, isLoading: isRemoveReviewLoading } = useMutation({ + mutationFn: fetchRemoveReview, + onSuccess: () => { + toastActions.showToast('리뷰가 삭제됐습니다.', 'success', 'bottom-center'); + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_REPLIES, reviewId], + }); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_REVIEWS, stationId], + }); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY_STATION_PREVIEWS, stationId], + }); + }, + }); + + const removeReview = (fetchRemoveReviewRequestParams: FetchRemoveReviewRequest) => { + mutate(fetchRemoveReviewRequestParams); + }; + + return { removeReview, isRemoveReviewLoading }; +}; diff --git a/src/hooks/tanstack-query/station-details/reviews/useReviewRatings.ts b/src/hooks/tanstack-query/station-details/reviews/useReviewRatings.ts new file mode 100644 index 00000000..8c0b1281 --- /dev/null +++ b/src/hooks/tanstack-query/station-details/reviews/useReviewRatings.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; + +import { SERVER_URL } from '@constants/server'; + +import type { StationRatings } from '@type'; + +export const fetchReviewRatings = async (stationId: string) => { + return fetch(`${SERVER_URL}/stations/${stationId}/total-ratings`).then<StationRatings>( + async (response): Promise<StationRatings> => { + const data = await response.json(); + return data; + } + ); +}; +export const useReviewRatings = (stationId: string) => { + return useQuery({ + queryKey: ['review-ratings', stationId], + queryFn: () => fetchReviewRatings(stationId), + }); +}; diff --git a/src/hooks/tanstack-query/station-details/reviews/useReviews.ts b/src/hooks/tanstack-query/station-details/reviews/useReviews.ts new file mode 100644 index 00000000..ad0cafde --- /dev/null +++ b/src/hooks/tanstack-query/station-details/reviews/useReviews.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; + +import { SERVER_URL } from '@constants/server'; + +import type { Review } from '@type'; + +export const fetchReviews = async (stationId: string) => { + return fetch(`${SERVER_URL}/stations/${stationId}/reviews/?page=0`).then<Review[]>( + async (response) => { + const data = await response.json(); + return data.reviews as Review[]; + } + ); +}; + +export const useReviews = (stationId: string) => { + return useQuery({ + queryKey: ['previews', stationId], + queryFn: () => fetchReviews(stationId), + refetchOnWindowFocus: false, + }); +}; diff --git a/src/hooks/tanstack-query/station-details/useStationCongestionStatistics.ts b/src/hooks/tanstack-query/station-details/useStationCongestionStatistics.ts new file mode 100644 index 00000000..c16319e0 --- /dev/null +++ b/src/hooks/tanstack-query/station-details/useStationCongestionStatistics.ts @@ -0,0 +1,35 @@ +import { useQuery } from '@tanstack/react-query'; + +import { ERROR_MESSAGES } from '@constants/errorMessages'; +import { QUERY_KEY_STATION_CONGESTION_STATISTICS } from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +import type { CongestionStatistics, EnglishDaysOfWeek } from '@type/congestion'; + +export const fetchStationStatistics = async (selectedStationId: string, dayOfWeek: string) => { + const statistics = await fetch( + `${SERVER_URL}/stations/${selectedStationId}/statistics?dayOfWeek=${dayOfWeek}`, + { + method: 'GET', + } + ).then<CongestionStatistics>(async (response) => { + if (!response.ok) { + throw new Error(ERROR_MESSAGES.STATION_STATISTICS_FETCH_ERROR); + } + + const congestionStatistics: CongestionStatistics = await response.json(); + + return congestionStatistics; + }); + + return statistics; +}; + +export const useStationCongestionStatistics = (stationId: string, dayOfWeek: EnglishDaysOfWeek) => { + return useQuery({ + queryKey: [QUERY_KEY_STATION_CONGESTION_STATISTICS, stationId, dayOfWeek], + queryFn: () => fetchStationStatistics(stationId, dayOfWeek), + enabled: !!stationId, + refetchOnWindowFocus: false, + }); +}; diff --git a/src/hooks/tanstack-query/station-details/useStationDetails.ts b/src/hooks/tanstack-query/station-details/useStationDetails.ts new file mode 100644 index 00000000..5d772b00 --- /dev/null +++ b/src/hooks/tanstack-query/station-details/useStationDetails.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; + +import { ERROR_MESSAGES } from '@constants/errorMessages'; +import { QUERY_KEY_STATION_DETAILS } from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +import type { StationDetails } from '@type'; + +export const fetchStationDetails = async (stationId: string) => { + if (stationId === null) { + throw new Error('선택된 충전소가 없습니다.'); + } + + const stationDetails = await fetch(`${SERVER_URL}/stations/${stationId}`, { + method: 'GET', + }).then<StationDetails>(async (response) => { + if (!response.ok) { + throw new Error(ERROR_MESSAGES.STATION_DETAILS_FETCH_ERROR); + } + + const data: StationDetails = await response.json(); + return data; + }); + + return stationDetails; +}; + +export const useStationDetails = (selectedStationId: string) => { + return useQuery({ + queryKey: [QUERY_KEY_STATION_DETAILS, selectedStationId], + queryFn: () => fetchStationDetails(selectedStationId), + refetchOnWindowFocus: false, + }); +}; diff --git a/src/hooks/tanstack-query/station-filters/useCarFilters.ts b/src/hooks/tanstack-query/station-filters/useCarFilters.ts new file mode 100644 index 00000000..30750de1 --- /dev/null +++ b/src/hooks/tanstack-query/station-filters/useCarFilters.ts @@ -0,0 +1,36 @@ +import { useQuery } from '@tanstack/react-query'; + +import { memberInfoStore } from '@stores/login/memberInfoStore'; + +import { QUERY_KEY_MEMBER_CAR_FILTERS } from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +import type { StationFilters } from '@type'; + +const fetchCarFilters = async (): Promise<StationFilters> => { + const memberInfo = memberInfoStore.getState(); + + if (memberInfo.car === undefined || memberInfo.car === null) { + return new Promise((resolve) => + resolve({ + capacities: [], + companies: [], + connectorTypes: [], + }) + ); + } + + const carFilters = await fetch( + `${SERVER_URL}/cars/${memberInfo.car.carId}/filters` + ).then<StationFilters>((response) => response.json()); + + return carFilters; +}; + +export const useCarFilters = () => { + return useQuery({ + queryKey: [QUERY_KEY_MEMBER_CAR_FILTERS], + queryFn: fetchCarFilters, + refetchOnWindowFocus: false, + }); +}; diff --git a/src/hooks/tanstack-query/station-filters/useMemberFilters.ts b/src/hooks/tanstack-query/station-filters/useMemberFilters.ts new file mode 100644 index 00000000..74931585 --- /dev/null +++ b/src/hooks/tanstack-query/station-filters/useMemberFilters.ts @@ -0,0 +1,35 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchUtils } from '@utils/fetch'; + +import { memberInfoStore } from '@stores/login/memberInfoStore'; + +import { EMPTY_MEMBER_ID } from '@constants'; +import { QUERY_KEY_MEMBER_SELECTED_FILTERS } from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +import type { StationFilters } from '@type'; + +const fetchMemberFilters = async (): Promise<StationFilters> => { + const memberId = memberInfoStore.getState().memberId; + + try { + if (memberId === EMPTY_MEMBER_ID) { + throw new Error('로그인이 필요합니다.'); + } + return await fetchUtils.get<StationFilters>( + `${SERVER_URL}/members/${memberId}/filters`, + '저장된 필터 정보를 불러오는데 실패했습니다.' + ); + } catch (error) { + return new Promise((resolve) => resolve({ capacities: [], companies: [], connectorTypes: [] })); + } +}; + +export const useMemberFilters = () => { + return useQuery({ + queryKey: [QUERY_KEY_MEMBER_SELECTED_FILTERS], + queryFn: fetchMemberFilters, + refetchOnWindowFocus: false, + }); +}; diff --git a/src/hooks/tanstack-query/station-filters/useServerStationFilters.ts b/src/hooks/tanstack-query/station-filters/useServerStationFilters.ts new file mode 100644 index 00000000..64144090 --- /dev/null +++ b/src/hooks/tanstack-query/station-filters/useServerStationFilters.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; + +import { QUERY_KEY_SERVER_STATION_FILTERS } from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; + +import type { StationFilters } from '@type'; + +const fetchServerStationFilters = async () => { + const serverStationFilters = await fetch(`${SERVER_URL}/filters`).then<StationFilters>( + (response) => response.json() + ); + + return serverStationFilters; +}; + +export const useServerStationFilters = () => { + return useQuery({ + queryKey: [QUERY_KEY_SERVER_STATION_FILTERS], + queryFn: fetchServerStationFilters, + }); +}; diff --git a/src/hooks/tanstack-query/useSearchStations.ts b/src/hooks/tanstack-query/useSearchStations.ts new file mode 100644 index 00000000..51ec9129 --- /dev/null +++ b/src/hooks/tanstack-query/useSearchStations.ts @@ -0,0 +1,39 @@ +import { useQuery } from '@tanstack/react-query'; + +import { ERROR_MESSAGES } from '@constants/errorMessages'; +import { QUERY_KEY_SEARCHED_STATION } from '@constants/queryKeys'; +import { SERVER_URL } from '@constants/server'; +import { SEARCH_SCOPE } from '@constants/stationSearch'; + +import type { SearchedCity, SearchedStation } from '@type/stations'; + +interface SearchedStationResponse { + stations: SearchedStation[]; + cities: SearchedCity[]; +} + +export const fetchSearchedStations = async (searchWord: string) => { + const searchedStations = await fetch( + `${SERVER_URL}/stations/search?q=${searchWord}${SEARCH_SCOPE}` + ).then<SearchedStationResponse>(async (response) => { + if (!response.ok) { + throw new Error(ERROR_MESSAGES.NO_SEARCH_RESULT); + } + + const data: SearchedStationResponse = await response.json(); + + return data; + }); + + return searchedStations; +}; + +export const useSearchStations = (searchWord: string) => { + return useQuery({ + queryKey: [QUERY_KEY_SEARCHED_STATION, searchWord], + queryFn: () => fetchSearchedStations(searchWord), + enabled: !!searchWord, + keepPreviousData: true, + refetchOnWindowFocus: false, + }); +}; diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 00000000..990de373 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,11 @@ +import { useEffect, useCallback } from 'react'; + +export const useDebounce = <T>(func: (param: T) => void, dependencies: string[], delay: number) => { + const callback = useCallback(func, dependencies); + + useEffect(() => { + const timeout = setTimeout(callback, delay); + + return () => clearTimeout(timeout); + }, [callback, delay]); +}; diff --git a/src/hooks/useMediaQueries.ts b/src/hooks/useMediaQueries.ts new file mode 100644 index 00000000..12e1485c --- /dev/null +++ b/src/hooks/useMediaQueries.ts @@ -0,0 +1,39 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { MOBILE_BREAKPOINT } from '@constants'; + +type ScreenBreakpoint = 'mobile'; + +type ScreenKey = `is${Capitalize<ScreenBreakpoint>}`; +type Screen = Map<ScreenKey, MediaQueryList>; + +/** + * @example screen.get('isMobile') ? '100%' : '32rem'; + */ +const useMediaQueries = () => { + const mediaQueries = useMemo<Screen>(() => { + return new Map([['isMobile', window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`)]]); + }, []); + + const [screen, setScreen] = useState( + new Map([...mediaQueries].map(([key, mediaQuery]) => [key, mediaQuery.matches])) + ); + + useEffect(() => { + const cleanHandlers = [...mediaQueries].map(([key, mediaQuery]) => { + const handleMediaQueryChange = ({ matches }: MediaQueryListEvent) => { + setScreen((screen) => new Map(screen).set(key, matches)); + }; + + mediaQuery.addEventListener('change', handleMediaQueryChange); + + return () => mediaQuery.removeEventListener('change', handleMediaQueryChange); + }); + + return () => cleanHandlers.forEach((cleanHandler) => cleanHandler()); + }, []); + + return screen; +}; + +export default useMediaQueries; diff --git a/src/hooks/useServerStationFilterActions.ts b/src/hooks/useServerStationFilterActions.ts new file mode 100644 index 00000000..7aa88922 --- /dev/null +++ b/src/hooks/useServerStationFilterActions.ts @@ -0,0 +1,91 @@ +import { useExternalState } from '@utils/external-state'; + +import { toastActions } from '@stores/layout/toastStore'; +import { + selectedCapacitiesFilterStore, + selectedConnectorTypesFilterStore, + selectedCompaniesFilterStore, +} from '@stores/station-filters/serverStationFiltersStore'; + +import type { CapaCityBigDecimal, CompanyKey, ConnectorTypeKey } from '@type/serverStationFilter'; + +import { useCarFilters } from './tanstack-query/station-filters/useCarFilters'; + +export const useServerStationFilterStoreActions = () => { + const { data: carFilters } = useCarFilters(); + const [selectedCompaniesFilters, setSelectedCompaniesFilter] = useExternalState( + selectedCompaniesFilterStore + ); + const [selectChargerTypesFilters, setSelectedChargerTypesFilter] = useExternalState( + selectedConnectorTypesFilterStore + ); + const [selectedCapacityFilters, setSelectedChargeSpeedsFilter] = useExternalState( + selectedCapacitiesFilterStore + ); + + const toggleCompanyFilter = (company: CompanyKey) => { + setSelectedCompaniesFilter((prev) => { + if (prev.has(company)) { + return new Set([...prev].filter((companyName) => companyName !== company)); + } + return new Set([...prev, company]); + }); + }; + + const toggleConnectorTypeFilter = (connectorType: ConnectorTypeKey) => { + setSelectedChargerTypesFilter((prev) => { + if (prev.has(connectorType)) { + if (carFilters.connectorTypes.includes(connectorType)) { + toastActions.showToast('차량 필터는 해제할 수 없습니다.', 'warning'); + return prev; + } + return new Set([...prev].filter((connector) => connector !== connectorType)); + } + return new Set([...prev, connectorType]); + }); + }; + + const toggleCapacityFilter = (capacity: CapaCityBigDecimal) => { + setSelectedChargeSpeedsFilter((prev) => { + if (prev.has(capacity)) { + if (carFilters.capacities.includes(capacity)) { + toastActions.showToast('차량 필터는 해제할 수 없습니다.', 'warning'); + return prev; + } + return new Set([...prev].filter((prevCapacity) => prevCapacity !== capacity)); + } + return new Set([...prev, capacity]); + }); + }; + + const getIsCompanySelected = (company: CompanyKey) => { + return selectedCompaniesFilters.has(company); + }; + + const getIsConnectorTypeSelected = (connectorType: ConnectorTypeKey) => { + return selectChargerTypesFilters.has(connectorType); + }; + + const getIsCapacitySelected = (capacity: CapaCityBigDecimal) => { + return selectedCapacityFilters.has(capacity); + }; + + const resetAllFilters = () => { + setSelectedCompaniesFilter(new Set([])); + setSelectedChargeSpeedsFilter(new Set([...carFilters.capacities])); + setSelectedChargerTypesFilter(new Set([...carFilters.connectorTypes])); + }; + + return { + selectedCompaniesFilters, + selectChargerTypesFilters, + selectedCapacityFilters, + toggleCompanyFilter, + toggleConnectorTypeFilter, + toggleCapacityFilter, + getIsCompanySelected, + getIsConnectorTypeSelected, + getIsCapacitySelected, + resetAllFilters, + }; +}; diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts new file mode 100644 index 00000000..9c10cad9 --- /dev/null +++ b/src/mocks/browser.ts @@ -0,0 +1,5 @@ +import { setupWorker } from 'msw'; + +import { handlers } from './handlers'; + +export const worker = setupWorker(...handlers); diff --git a/src/mocks/data.ts b/src/mocks/data.ts new file mode 100644 index 00000000..4e197891 --- /dev/null +++ b/src/mocks/data.ts @@ -0,0 +1,444 @@ +import type { Region, RegionName } from '@marker/MaxDeltaAreaMarkerContainer/types'; + +import { getTypedObjectFromEntries } from '@utils/getTypedObjectFromEntries'; +import { getTypedObjectKeys } from '@utils/getTypedObjectKeys'; +import { generateRandomData, generateRandomToken, getRandomTime } from '@utils/randomDataGenerator'; + +import { + CAPACITIES, + COMPANIES, + CONNECTOR_TYPES, + QUICK_CHARGER_CAPACITY_THRESHOLD, +} from '@constants/chargers'; +import { NO_RATIO, SHORT_ENGLISH_DAYS_OF_WEEK } from '@constants/congestion'; +import { MAX_SEARCH_RESULTS } from '@constants/stationSearch'; + +import type { Car } from '@type/cars'; +import type { Capacity, ChargerDetails } from '@type/chargers'; +import type { Congestion, ShortEnglishDaysOfWeek } from '@type/congestion'; +import type { CapaCityBigDecimal, ConnectorTypeKey } from '@type/serverStationFilter'; +import type { CompanyName, Reply, Review, Station, StationFilters } from '@type/stations'; + +export const generateRandomChargers = () => { + const length = Math.floor(Math.random() * 10) + 1; + const chargers: ChargerDetails[] = Array.from({ length }, () => ({ + type: generateRandomData<ConnectorTypeKey>(getTypedObjectKeys(CONNECTOR_TYPES)), + price: generateRandomData([200, 250, 300, 350, 400]), + capacity: generateRandomData<Capacity>([3, 7, 50, 100, 200]), + latestUpdateTime: getRandomTime(), + state: generateRandomData([ + 'COMMUNICATION_ERROR', + 'STANDBY', + 'CHARGING_IN_PROGRESS', + 'OPERATION_SUSPENDED', + 'UNDER_INSPECTION', + 'STATUS_UNKNOWN', + ]), + method: generateRandomData(['단독', '동시']), + })); + + return chargers; +}; + +const generateRandomStationId = () => { + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const numbers = '0123456789'; + + const randomChar = (source: string) => source[Math.floor(Math.random() * source.length)]; + + const randomLetter1 = randomChar(letters); + const randomLetter2 = randomChar(letters); + const randomNumber = Array.from({ length: 6 }, () => randomChar(numbers)).join(''); + + return `${randomLetter1}${randomLetter2}${randomNumber}`; +}; + +export const stations: Station[] = Array.from({ length: 60000 }, () => { + const randomStationId = generateRandomStationId(); + const chargers = generateRandomChargers(); + const totalCount = chargers.length; + const availableCount = chargers.filter(({ state }) => state === 'STANDBY').length; + const quickChargerCount = chargers.filter( + ({ capacity }) => capacity >= QUICK_CHARGER_CAPACITY_THRESHOLD + ).length; + + return { + stationId: randomStationId, + stationName: `충전소 ${randomStationId}`, + companyName: generateRandomData<CompanyName>(Object.values(COMPANIES)), + contact: generateRandomData(['', '010-1234-5678', '02-000-0000']), + chargers: chargers, + isParkingFree: generateRandomData<boolean>([true, false]), + operatingTime: generateRandomData<string>([ + '24시간', + '09:00 ~ 19:00', + '평일 09:00~19:00 / 주말 미운영', + ]), + address: generateRandomData([ + '서울시 송파구 신천동 7-22', + '서울시 강남구 테헤란로 411', + '서울시 종로구 관철동 13-22', + 'null', + ]), + detailLocation: generateRandomData<string>(['지상 1층', '지하 1층', '지하 2층', '']), + latitude: 37 + 0.25 + 9999 * Math.random() * 0.00005, + longitude: 127 - 0.25 + 9999 * Math.random() * 0.00005, + isPrivate: generateRandomData<boolean>([true, false]), + totalCount, + availableCount, + quickChargerCount, + stationState: generateRandomData(['yyyy-mm-dd일부터 충전소 공사합니다.', 'null', null]), + privateReason: generateRandomData(['아파트', 'null', null]), + reportCount: generateRandomData([0, 0, Math.floor(Math.random() * 99)]), + }; +}); + +export const getSearchedStations = (searchWord: string) => { + const searchApiStations = stations.map((station) => { + const { stationId, stationName, chargers, address, latitude, longitude } = station; + + const onlyCapacity = chargers.map(({ capacity }) => capacity); + const speed = onlyCapacity.map((num) => + num >= QUICK_CHARGER_CAPACITY_THRESHOLD ? 'QUICK' : 'STANDARD' + ); + + return { stationId, stationName, speed, address, latitude, longitude }; + }); + + return searchApiStations + .filter( + (station) => station.stationName.includes(searchWord) || station.address.includes(searchWord) + ) + .slice(0, MAX_SEARCH_RESULTS); +}; + +interface CongestionStatisticsMockData { + stationId: string; + congestion: { + standard: Record<ShortEnglishDaysOfWeek, Congestion[]>; + quick: Record<ShortEnglishDaysOfWeek, Congestion[]>; + }; +} + +export const getCongestionStatistics = (stationId: string): CongestionStatisticsMockData => { + const foundStation = stations.find((station) => station.stationId === stationId); + const hasOnlyStandardChargers = foundStation.quickChargerCount === 0; + const hasOnlyQuickChargers = foundStation.chargers.every( + ({ capacity }) => capacity >= QUICK_CHARGER_CAPACITY_THRESHOLD + ); + + return { + stationId: foundStation.stationId, + congestion: { + quick: getCongestions(hasOnlyStandardChargers), + standard: getCongestions(hasOnlyQuickChargers), + }, + }; +}; + +const getCongestions = ( + hasOnlyOneChargerType: boolean +): Record<ShortEnglishDaysOfWeek, Congestion[]> => { + return getTypedObjectFromEntries( + SHORT_ENGLISH_DAYS_OF_WEEK, + SHORT_ENGLISH_DAYS_OF_WEEK.map(() => + Array.from({ length: 24 }, (_, index) => { + return { + hour: index, + ratio: hasOnlyOneChargerType || Math.random() > 0.95 ? NO_RATIO : Math.random(), + }; + }) + ) + ); +}; + +export const generateReviews = (): Review[] => { + return Array.from({ length: 10 }, (_, index) => { + return { + reviewId: index, + memberId: generateRandomToken(), + latestUpdateDate: getRandomTime(), + ratings: Math.floor(Math.random() * 5) + 1, + content: generateRandomData([ + '정말 멋진 충전소네요.', + '고장이 잘나요', + '주차 공간이 너무 좁아요', + '후면 주차가 어려워요', + '손잡이가 드러워요', + '비매너 사용자들이 많아요', + '자리가 넉넉해요', + '비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요', + ]), + isUpdated: generateRandomData([true, false]), + isDeleted: generateRandomData([true, false]), + replySize: generateRandomData([0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7]), + }; + }); +}; + +export const generateReplies = (): Reply[] => { + return Array.from({ length: 10 }, (_, index) => { + return { + replyId: index, + reviewId: generateRandomToken(), + memberId: generateRandomToken(), + latestUpdateDate: getRandomTime(), + content: generateRandomData([ + '정말 멋진 충전소네요.', + '고장이 잘나요', + '주차 공간이 너무 좁아요', + '후면 주차가 어려워요', + '손잡이가 드러워요', + '비매너 사용자들이 많아요', + '자리가 넉넉해요', + '비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요', + ]), + isUpdated: generateRandomData([true, false]), + isDeleted: generateRandomData([true, false]), + }; + }); +}; + +export const generateCars = (): Car[] => { + const name = Array.from({ length: 6 }).map((_, i) => `아이오닉${i + 1}`); + const vintage = Array.from({ length: 5 }).map((_, i) => `${2019 + i}`); + + const car = name + .map((n) => { + const randomLength = Math.floor(Math.random() * 4) + 1; + + const randomYear = vintage.slice(0, randomLength); + return randomYear.map((rV) => ({ + carId: Math.random(), + name: n, + vintage: rV, + })); + }) + .reduce((acc, curr) => [...acc, ...curr], []); + + return car; +}; + +export const generateCarFilters = (): StationFilters => { + const randomSortedCapacities = ( + [...CAPACITIES.map((capacity) => `${capacity}.00`)] as CapaCityBigDecimal[] + ).sort(() => (Math.random() - 0.5 > 0 ? 1 : -1)); + const randomSortedConnectorTypes = [...getTypedObjectKeys(CONNECTOR_TYPES)].sort(() => + Math.random() - 0.5 > 0 ? 1 : -1 + ); + + const capacities = randomSortedCapacities.slice( + 0, + Math.floor(Math.random() * (randomSortedCapacities.length - 1) + 1) + ); + const connectorTypes = randomSortedConnectorTypes.slice(0, 3); + + return { + companies: [], + capacities, + connectorTypes, + }; +}; + +export const regions: Region[] = [ + { + regionName: '서울특별시', + latitude: 37.540705, + longitude: 126.956764, + count: 8128, + }, + { + regionName: '인천광역시', + latitude: 37.469221, + longitude: 126.573234, + count: 2665, + }, + { + regionName: '광주광역시', + latitude: 35.126033, + longitude: 126.831302, + count: 2155, + }, + { + regionName: '대구광역시', + latitude: 35.798838, + longitude: 128.583052, + count: 2871, + }, + { + regionName: '울산광역시', + latitude: 35.519301, + longitude: 129.239078, + count: 1238, + }, + { + regionName: '대전광역시', + latitude: 36.321655, + longitude: 127.378953, + count: 1783, + }, + { + regionName: '부산광역시', + latitude: 35.198362, + longitude: 129.053922, + count: 3337, + }, + { + regionName: '경기도', + latitude: 37.567167, + longitude: 127.190292, + count: 14710, + }, + { + regionName: '강원특별자치도', + latitude: 37.555837, + longitude: 128.209315, + count: 2918, + }, + { + regionName: '충청남도', + latitude: 36.557229, + longitude: 126.779757, + count: 3191, + }, + { + regionName: '충청북도', + latitude: 36.628503, + longitude: 127.929344, + count: 2283, + }, + { + regionName: '경상북도', + latitude: 36.248647, + longitude: 128.664734, + count: 3805, + }, + { + regionName: '경상남도', + latitude: 35.259787, + longitude: 128.664734, + count: 3869, + }, + { + regionName: '전라북도', + latitude: 35.716705, + longitude: 127.144185, + count: 2938, + }, + { + regionName: '전라남도', + latitude: 34.8194, + longitude: 126.893113, + count: 2873, + }, + { + regionName: '제주특별자치도', + latitude: 33.364805, + longitude: 126.542671, + count: 2942, + }, +]; + +export const getRegionName = (regionName: string): RegionName | undefined => { + switch (regionName) { + case 'SEOUL': + return '서울특별시'; + case 'INCHEON': + return '인천광역시'; + case 'GWANGJU': + return '광주광역시'; + case 'DAEGU': + return '대구광역시'; + case 'ULSAN': + return '울산광역시'; + case 'DAEJEON': + return '대전광역시'; + case 'BUSAN': + return '부산광역시'; + case 'GYEONGGI': + return '경기도'; + case 'GANGWON': + return '강원특별자치도'; + case 'CHUNGNAM': + return '충청남도'; + case 'CHUNGBUK': + return '충청북도'; + case 'GYEONGBUK': + return '경상북도'; + case 'GYEONGNAM': + return '경상남도'; + case 'JEONBUK': + return '전라북도'; + case 'JEONNAM': + return '전라남도'; + case 'JEJU': + return '제주특별자치도'; + default: + return undefined; + } +}; + +export const getCities = () => { + return [ + { + cityName: '서울특별시', + latitude: 37.5666103, + longitude: 126.9783882, + }, + { + cityName: '서울특별시 강동구', + latitude: 37.530126, + longitude: 127.1237708, + }, + { + cityName: '서울특별시 강동구 천호동', + latitude: 37.5450159, + longitude: 127.1368066, + }, + { + cityName: '경기도 하남시 미사동', + latitude: 37.560359, + longitude: 127.1888042, + }, + { + cityName: '경기도 하남시 망월동', + latitude: 37.5696083, + longitude: 127.1880625, + }, + { + cityName: '경상남도 진주시 신안동', + latitude: 35.1844853, + longitude: 128.0689824, + }, + { + cityName: '경상남도 진주시', + latitude: 35.180325, + longitude: 128.107646, + }, + { + cityName: '경기도 안산시 단원구 선부동', + latitude: 37.3342173, + longitude: 126.8044133, + }, + { + cityName: '경기도 오산시 부산동', + latitude: 37.1527237, + longitude: 127.088125, + }, + { + cityName: '부산광역시', + latitude: 35.179816, + longitude: 129.0750223, + }, + { + cityName: '부산광역시 기장군', + latitude: 35.244498, + longitude: 129.222312, + }, + { + cityName: '부산광역시 기장군 철마면', + latitude: 35.2752833, + longitude: 129.1497125, + }, + ]; +}; diff --git a/src/mocks/handlers/car/carHandler.ts b/src/mocks/handlers/car/carHandler.ts new file mode 100644 index 00000000..25574b15 --- /dev/null +++ b/src/mocks/handlers/car/carHandler.ts @@ -0,0 +1,18 @@ +import { generateCars, generateCarFilters } from '@mocks/data'; +import { rest } from 'msw'; + +import { DEVELOP_SERVER_URL } from '@constants/server'; + +export const carHandler = [ + rest.get(`${DEVELOP_SERVER_URL}/cars`, (_, res, ctx) => { + const cars = generateCars(); + + return res(ctx.json({ cars }), ctx.delay(200), ctx.status(200)); + }), + + rest.get(`${DEVELOP_SERVER_URL}/cars/:carId/filters`, (_, res, ctx) => { + const carFilters = generateCarFilters(); + + return res(ctx.json(carFilters), ctx.delay(200), ctx.status(200)); + }), +]; diff --git a/src/mocks/handlers/index.ts b/src/mocks/handlers/index.ts new file mode 100644 index 00000000..bdb95c82 --- /dev/null +++ b/src/mocks/handlers/index.ts @@ -0,0 +1,27 @@ +import { carHandler } from './car/carHandler'; +import { loginHandlers } from './login/loginHandlers'; +import { memberHandlers } from './memberHandlers'; +import { stationReportHandlers } from './station-details/reports/stationReportHandlers'; +import { stationReviewHandlers } from './station-details/reviews/stationReviewHandlers'; +import { stationDetailHandlers } from './station-details/stationDetailHandlers'; +import { statisticsHandlers } from './station-details/statisticsHandlers'; +import { memberFilterHandlers } from './station-filters/memberFilterHandlers'; +import { serverFilterHandlers } from './station-filters/serverFilterHandlers'; +import { stationHandlers } from './station-markers/stationHandlers'; +import { stationMarkerHandlers } from './station-markers/stationMarkerHandlers'; +import { stationSearchHandlers } from './stationSearchHandlers'; + +export const handlers = [ + ...stationMarkerHandlers, // stationHandlers의 stations/:id에 의해 방해받는 메서드가 있기에 stationMarkerHandlers를 먼저 선언해야 함 + ...memberHandlers, + ...stationSearchHandlers, + ...stationHandlers, + ...stationDetailHandlers, + ...statisticsHandlers, + ...memberFilterHandlers, + ...serverFilterHandlers, + ...stationReportHandlers, + ...stationReviewHandlers, + ...loginHandlers, + ...carHandler, +]; diff --git a/src/mocks/handlers/login/loginHandlers.ts b/src/mocks/handlers/login/loginHandlers.ts new file mode 100644 index 00000000..0f77f3c3 --- /dev/null +++ b/src/mocks/handlers/login/loginHandlers.ts @@ -0,0 +1,19 @@ +import { rest } from 'msw'; + +import { DEVELOP_SERVER_URL } from '@constants/server'; + +export const loginHandlers = [ + rest.get(`${DEVELOP_SERVER_URL}/oauth/google/login-uri`, (_, res, ctx) => { + return res( + ctx.json({ + loginUri: 'http://localhost:3000/google?code=mock-data-code', + }), + ctx.delay(1000), + ctx.status(200) + ); + }), + + rest.post(`${DEVELOP_SERVER_URL}/oauth/google/login`, (_, res, ctx) => { + return res(ctx.json({ token: 'mock-token' }), ctx.delay(1000), ctx.status(200)); + }), +]; diff --git a/src/mocks/handlers/memberHandlers.ts b/src/mocks/handlers/memberHandlers.ts new file mode 100644 index 00000000..87800e42 --- /dev/null +++ b/src/mocks/handlers/memberHandlers.ts @@ -0,0 +1,48 @@ +import { rest } from 'msw'; + +import { EMPTY_MEMBER_TOKEN } from '@constants'; +import { DEVELOP_SERVER_URL } from '@constants/server'; + +export const memberHandlers = [ + rest.get(`${DEVELOP_SERVER_URL}/members/me`, (req, res, ctx) => { + const memberToken = req.headers.get('Authorization'); + + if (memberToken === undefined || memberToken.replace('Bearer', '') === EMPTY_MEMBER_TOKEN) { + return res(ctx.status(401), ctx.json('unauthorized error')); + } + + return res( + ctx.status(200), + ctx.json({ + memberId: Math.random(), + car: { + carId: Math.random(), + name: '아이오닉3', + vintage: '2019', + }, + }) + ); + }), + + rest.post(`${DEVELOP_SERVER_URL}/members/:memberId/cars`, async (req, res, ctx) => { + const memberToken = req.headers.get('Authorization'); + + if (memberToken === undefined || memberToken.replace('Bearer', '') === EMPTY_MEMBER_TOKEN) { + return res(ctx.status(401), ctx.json('unauthorized error')); + } + + const carInfo = await req.json(); + const name = carInfo.name; + const vintage = carInfo.vintage; + + return res( + ctx.status(200), + ctx.delay(200), + ctx.json({ + carId: 1, + name, + vintage, + }) + ); + }), +]; diff --git a/src/mocks/handlers/station-details/reports/stationReportHandlers.ts b/src/mocks/handlers/station-details/reports/stationReportHandlers.ts new file mode 100644 index 00000000..957b7049 --- /dev/null +++ b/src/mocks/handlers/station-details/reports/stationReportHandlers.ts @@ -0,0 +1,41 @@ +import { rest } from 'msw'; + +import { getSessionStorage, setSessionStorage } from '@utils/storage'; + +import { DEVELOP_SERVER_URL } from '@constants/server'; +import { SESSION_KEY_REPORTED_STATIONS } from '@constants/storageKeys'; + +export const stationReportHandlers = [ + rest.post(`${DEVELOP_SERVER_URL}/stations/:stationId/reports`, (req, res, ctx) => { + const stationId = req.params.stationId as string; + const prevReportedStations = getSessionStorage<string[]>(SESSION_KEY_REPORTED_STATIONS, []); + + setSessionStorage<string[]>(SESSION_KEY_REPORTED_STATIONS, [ + ...new Set([...prevReportedStations, stationId]), + ]); + + return res(ctx.delay(200), ctx.status(204)); + }), + + rest.get(`${DEVELOP_SERVER_URL}/stations/:stationId/reports/me`, (req, res, ctx) => { + console.log(req.headers.get('Authorization')); // TODO: 이후에 비로그인 기능도 구현할 때 활용해야함 + const stationId = req.params.stationId as string; + const reportedStations = getSessionStorage<string[]>(SESSION_KEY_REPORTED_STATIONS, []); + + return res( + ctx.delay(200), + ctx.status(200), + ctx.json({ isReported: reportedStations.includes(stationId) }) + ); + }), + + rest.post( + `${DEVELOP_SERVER_URL}/stations/:stationId/misinformation-reports`, + async (req, res, ctx) => { + const body = await req.json(); + console.log(JSON.stringify(body.stationDetailsToUpdate)); + + return res(ctx.delay(200), ctx.status(204)); + } + ), +]; diff --git a/src/mocks/handlers/station-details/reviews/stationReviewHandlers.ts b/src/mocks/handlers/station-details/reviews/stationReviewHandlers.ts new file mode 100644 index 00000000..e06dd24d --- /dev/null +++ b/src/mocks/handlers/station-details/reviews/stationReviewHandlers.ts @@ -0,0 +1,131 @@ +import { generateReplies, generateReviews } from '@mocks/data'; +import { rest } from 'msw'; + +import { DEVELOP_SERVER_URL } from '@constants/server'; + +export const stationReviewHandlers = [ + rest.get(`${DEVELOP_SERVER_URL}/stations/:stationId/total-ratings`, (req, res, ctx) => { + const reviews = generateReviews(); + const validReviews = reviews.filter((review) => !review.isDeleted); + const min = 1; + const max = 2000; + return res( + ctx.json({ + totalRatings: parseFloat( + (validReviews.reduce((a, b) => a + b.ratings, 0) / reviews.length).toFixed(2) + ), + totalCount: Math.floor(Math.random() * (max - min + 1)) + min, + }), + ctx.delay(1000), + ctx.status(200) + ); + }), + + rest.get(`${DEVELOP_SERVER_URL}/stations/:stationId/reviews`, (req, res, ctx) => { + const reviews = generateReviews(); + const { searchParams } = req.url; + const page = Number(searchParams.get('page')); + console.log(`충전소 후기 조회 page=${page}`); + + if (page === 5) { + return res( + ctx.json({ + reviews: reviews.slice(0, 3), + currentPage: page, + nextPage: -1, + }), + ctx.delay(1000), + ctx.status(200) + ); + } else if (page > 5) { + return res( + ctx.json({ + reviews: [], + currentPage: page, + nextPage: -1, + }), + ctx.delay(1000), + ctx.status(200) + ); + } else { + return res( + ctx.json({ + reviews, + currentPage: page, + nextPage: page + 1, + }), + ctx.delay(1000), + ctx.status(200) + ); + } + }), + + rest.post(`${DEVELOP_SERVER_URL}/stations/:stationId/reviews`, async (req, res, ctx) => { + const body = await req.json(); + console.log(`충전소 후기 작성 :${JSON.stringify(body)}`); + return res(ctx.delay(200), ctx.status(204)); + }), + rest.patch(`${DEVELOP_SERVER_URL}/reviews/:reviewId`, async (req, res, ctx) => { + const body = await req.json(); + console.log(`충전소 후기 수정 :${JSON.stringify(body)}`); + return res(ctx.delay(200), ctx.status(204)); + }), + rest.delete(`${DEVELOP_SERVER_URL}/reviews/:reviewId`, async (req, res, ctx) => { + console.log(`충전소 후기 삭제`); + return res(ctx.delay(200), ctx.status(204)); + }), + + rest.get(`${DEVELOP_SERVER_URL}/reviews/:reviewId/replies`, (req, res, ctx) => { + const replies = generateReplies(); + const { searchParams } = req.url; + const page = Number(searchParams.get('page')); + console.log(`충전소 답글 조회 page=${page}`); + + if (page === 2) { + return res( + ctx.json({ + replies: replies.slice(0, 3), + currentPage: page, + nextPage: -1, + }), + ctx.delay(1000), + ctx.status(200) + ); + } else if (page > 2) { + return res( + ctx.json({ + replies: [], + currentPage: page, + nextPage: -1, + }), + ctx.delay(1000), + ctx.status(200) + ); + } else { + return res( + ctx.json({ + replies, + currentPage: page, + nextPage: page + 1, + }), + ctx.delay(1000), + ctx.status(200) + ); + } + }), + + rest.post(`${DEVELOP_SERVER_URL}/reviews/:reviewId/replies`, async (req, res, ctx) => { + const body = await req.json(); + console.log(`충전소 후기 답글 작성 :${JSON.stringify(body)}`); + return res(ctx.delay(200), ctx.status(204)); + }), + rest.patch(`${DEVELOP_SERVER_URL}/reviews/:reviewId/replies/:replyId`, async (req, res, ctx) => { + const body = await req.json(); + console.log(`충전소 후기 답글 수정 :${JSON.stringify(body)}`); + return res(ctx.delay(200), ctx.status(204)); + }), + rest.delete(`${DEVELOP_SERVER_URL}/reviews/:reviewId/replies/:replyId`, (req, res, ctx) => { + console.log(`충전소 후기 답글 삭제`); + return res(ctx.delay(200), ctx.status(204)); + }), +]; diff --git a/src/mocks/handlers/station-details/stationDetailHandlers.ts b/src/mocks/handlers/station-details/stationDetailHandlers.ts new file mode 100644 index 00000000..f7f3ec6b --- /dev/null +++ b/src/mocks/handlers/station-details/stationDetailHandlers.ts @@ -0,0 +1,18 @@ +import { stations } from '@mocks/data'; +import { rest } from 'msw'; + +import { ERROR_MESSAGES } from '@constants/errorMessages'; +import { DEVELOP_SERVER_URL } from '@constants/server'; + +export const stationDetailHandlers = [ + rest.get(`${DEVELOP_SERVER_URL}/stations/:id`, async (req, res, ctx) => { + const stationId = req.params.id; + const selectedStation = stations.find((station) => station.stationId === stationId); + + if (!selectedStation) { + return res(ctx.status(404), ctx.json({ message: ERROR_MESSAGES.NO_STATION_FOUND })); + } + + return res(ctx.delay(200), ctx.status(200), ctx.json(selectedStation)); + }), +]; diff --git a/src/mocks/handlers/station-details/statisticsHandlers.ts b/src/mocks/handlers/station-details/statisticsHandlers.ts new file mode 100644 index 00000000..4570a118 --- /dev/null +++ b/src/mocks/handlers/station-details/statisticsHandlers.ts @@ -0,0 +1,36 @@ +import { getCongestionStatistics } from '@mocks/data'; +import { rest } from 'msw'; + +import { ENGLISH_DAYS_OF_WEEK_FULL_TO_SHORT } from '@constants/congestion'; +import { DEVELOP_SERVER_URL } from '@constants/server'; + +import type { ShortEnglishDaysOfWeek } from '@type'; + +export const statisticsHandlers = [ + rest.get(`${DEVELOP_SERVER_URL}/stations/:stationId/statistics`, (req, res, ctx) => { + const stationId = req.url.pathname + .split('?')[0] + .replace(/\/api\/stations\//, '') + .replace(/\/statistics/, ''); + const dayOfWeek = req.url.searchParams.get('dayOfWeek') as ShortEnglishDaysOfWeek; + + const fullCongestionStatistics = getCongestionStatistics(stationId); + const congestionStatistics = { + ...fullCongestionStatistics, + congestion: { + standard: [ + ...fullCongestionStatistics['congestion']['standard'][ + ENGLISH_DAYS_OF_WEEK_FULL_TO_SHORT[dayOfWeek] + ], + ], + quick: [ + ...fullCongestionStatistics['congestion']['quick'][ + ENGLISH_DAYS_OF_WEEK_FULL_TO_SHORT[dayOfWeek] + ], + ], + }, + }; + + return res(ctx.json(congestionStatistics), ctx.delay(1000), ctx.status(200)); + }), +]; diff --git a/src/mocks/handlers/station-filters/memberFilterHandlers.ts b/src/mocks/handlers/station-filters/memberFilterHandlers.ts new file mode 100644 index 00000000..b57d0e0d --- /dev/null +++ b/src/mocks/handlers/station-filters/memberFilterHandlers.ts @@ -0,0 +1,46 @@ +import { rest } from 'msw'; + +import { EMPTY_MEMBER_TOKEN } from '@constants'; +import { DEVELOP_SERVER_URL } from '@constants/server'; + +export const memberFilterHandlers = [ + rest.post(`${DEVELOP_SERVER_URL}/members/:memberId/filters`, async (req, res, ctx) => { + const memberToken = req.headers.get('Authorization'); + const requestBody = await req.json(); + + const connectorTypes = requestBody.filters + .filter( + (filterOption: { type: string; name: string }) => filterOption.type === 'connectorType' + ) + .map((filterOption: { type: string; name: string }) => filterOption.name); + const capacities = requestBody.filters + .filter((filterOption: { type: string; name: string }) => filterOption.type === 'capacity') + .map((filterOption: { type: string; name: string }) => filterOption.name); + const companies = requestBody.filters + .filter((filterOption: { type: string; name: string }) => filterOption.type === 'company') + .map((filterOption: { type: string; name: string }) => filterOption.name); + + if (memberToken === undefined || memberToken.replace('Bearer', '') === EMPTY_MEMBER_TOKEN) { + return res(ctx.status(401), ctx.json('unauthorized error')); + } + + return res(ctx.status(200), ctx.json({ connectorTypes, capacities, companies })); + }), + + rest.get(`${DEVELOP_SERVER_URL}/members/:memberId/filters`, (req, res, ctx) => { + const memberToken = req.headers.get('Authorization'); + + if (memberToken === undefined || memberToken.replace('Bearer', '') === EMPTY_MEMBER_TOKEN) { + return res(ctx.status(401), ctx.json('unauthorized error')); + } + + return res( + ctx.status(200), + ctx.json({ + companies: ['AM', 'BA', 'BG', 'BK'], + capacities: ['3.00', '7.00'], + connectorTypes: ['DC_COMBO'], + }) + ); + }), +]; diff --git a/src/mocks/handlers/station-filters/serverFilterHandlers.ts b/src/mocks/handlers/station-filters/serverFilterHandlers.ts new file mode 100644 index 00000000..d1e9a4ab --- /dev/null +++ b/src/mocks/handlers/station-filters/serverFilterHandlers.ts @@ -0,0 +1,19 @@ +import { rest } from 'msw'; + +import { DEVELOP_SERVER_URL } from '@constants/server'; + +import { CAPACITIES, CONNECTOR_TYPES, COMPANIES } from '../../../constants/chargers'; + +export const serverFilterHandlers = [ + rest.get(`${DEVELOP_SERVER_URL}/filters`, (_, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + connectorTypes: Object.keys(CONNECTOR_TYPES), + capacities: CAPACITIES.map((capacity) => `${capacity}.00`), + companies: Object.keys(COMPANIES), + }), + ctx.delay(1000) + ); + }), +]; diff --git a/src/mocks/handlers/station-markers/stationHandlers.ts b/src/mocks/handlers/station-markers/stationHandlers.ts new file mode 100644 index 00000000..85b023ab --- /dev/null +++ b/src/mocks/handlers/station-markers/stationHandlers.ts @@ -0,0 +1,60 @@ +import { stations } from '@mocks/data'; +import { rest } from 'msw'; + +import { DELIMITER } from '@constants'; +import { DEVELOP_SERVER_URL } from '@constants/server'; + +import type { StationSummary } from '@type'; + +export const stationHandlers = [ + rest.get(`${DEVELOP_SERVER_URL}/stations/summary`, async (req, res, ctx) => { + const { searchParams } = req.url; + // ?stationIds=PE123456,PE123457,PE123465 ==> 대략 10개, 검사는 안함 + const stationIdsParam = searchParams.get('stationIds'); + if (stationIdsParam === undefined) { + return res(ctx.delay(1000), ctx.status(200), ctx.json([])); + } + const stationIds = stationIdsParam.split(DELIMITER); + const foundStations = stations.filter((station) => stationIds.includes(station.stationId)); + const stationSummaries: StationSummary[] = foundStations.map((station) => { + const { + address, + availableCount, + companyName, + detailLocation, + isParkingFree, + isPrivate, + latitude, + longitude, + operatingTime, + stationId, + stationName, + totalCount, + quickChargerCount, + }: StationSummary = station; + + return { + address, + availableCount, + companyName, + detailLocation, + isParkingFree, + isPrivate, + latitude, + longitude, + operatingTime, + stationId, + stationName, + totalCount, + quickChargerCount, + }; + }); + return res( + ctx.delay(1000), + ctx.status(200), + ctx.json({ + stations: stationSummaries, + }) + ); + }), +]; diff --git a/src/mocks/handlers/station-markers/stationMarkerHandlers.ts b/src/mocks/handlers/station-markers/stationMarkerHandlers.ts new file mode 100644 index 00000000..9679d5fa --- /dev/null +++ b/src/mocks/handlers/station-markers/stationMarkerHandlers.ts @@ -0,0 +1,120 @@ +import { getRegionName, regions, stations } from '@mocks/data'; +import { rest } from 'msw'; + +import { DELIMITER } from '@constants'; +import { COMPANIES } from '@constants/chargers'; +import { DEVELOP_SERVER_URL } from '@constants/server'; + +import type { StationMarker, StationSummary } from '@type'; +import type { CompanyKey } from '@type/serverStationFilter'; + +export const stationMarkerHandlers = [ + rest.get(`${DEVELOP_SERVER_URL}/stations`, async (req, res, ctx) => { + const { searchParams } = req.url; + + const latitude = Number(searchParams.get('latitude')); + const longitude = Number(searchParams.get('longitude')); + const latitudeDelta = Number(searchParams.get('latitudeDelta')); + const longitudeDelta = Number(searchParams.get('longitudeDelta')); + + const isChargerTypeFilterSelected = searchParams.get('chargerTypes') !== null; + const isCapacityFilterSelected = searchParams.get('capacities') !== null; + const isCompanyNameFilterSelected = searchParams.get('companyNames') !== null; + + const selectedChargerTypes = searchParams.get('chargerTypes')?.split(DELIMITER); + const selectedCapacities = searchParams.get('capacities')?.split(DELIMITER)?.map(Number); + const selectedCompanies = searchParams.get('companyNames')?.split(DELIMITER); + + const northEastBoundary = { + latitude: latitude + latitudeDelta, + longitude: longitude + longitudeDelta, + }; + + const southWestBoundary = { + latitude: latitude - latitudeDelta, + longitude: longitude - longitudeDelta, + }; + + const isStationLatitudeWithinBounds = (station: StationSummary) => { + return ( + station.latitude > southWestBoundary.latitude && + station.latitude < northEastBoundary.latitude + ); + }; + + const isStationLongitudeWithinBounds = (station: StationSummary) => { + return ( + station.longitude > southWestBoundary.longitude && + station.longitude < northEastBoundary.longitude + ); + }; + + const foundStations: StationMarker[] = stations + .filter( + (station) => + isStationLatitudeWithinBounds(station) && isStationLongitudeWithinBounds(station) + ) + .filter((station) => { + const isChargerTypeFilterInvalid = + isChargerTypeFilterSelected && + !station.chargers.some((charger) => selectedChargerTypes.includes(charger.type)); + const isCapacityFilterInvalid = + isCapacityFilterSelected && + !station.chargers.some((charger) => selectedCapacities.includes(charger.capacity)); + const isCompanyNameFilterInvalid = + isCompanyNameFilterSelected && + !selectedCompanies + .map((companyId) => COMPANIES[companyId as CompanyKey]) + .includes(station.companyName as (typeof COMPANIES)[CompanyKey]); + + if (isChargerTypeFilterInvalid || isCapacityFilterInvalid || isCompanyNameFilterInvalid) { + return false; + } + return true; + }) + .map((station: StationMarker) => { + const { + stationId, + latitude, + longitude, + stationName, + availableCount, + isParkingFree, + isPrivate, + quickChargerCount, + } = station; + + return { + stationId, + latitude, + longitude, + stationName, + availableCount, + isParkingFree, + isPrivate, + quickChargerCount, + }; + }); + + console.log('찾은 충전소 갯수: ' + foundStations.length); + + return res( + ctx.delay(1000), + ctx.status(200), + ctx.json({ + stations: foundStations, + }) + ); + }), + rest.get(`${DEVELOP_SERVER_URL}/stations/regions`, async (req, res, ctx) => { + const { searchParams } = req.url; + + const region = searchParams.get('regions'); + const regionName = getRegionName(region); + const foundRegions = + regionName === undefined + ? regions + : regions.filter((region) => region.regionName === regionName); + return res(ctx.delay(1000), ctx.status(200), ctx.json(foundRegions)); + }), +]; diff --git a/src/mocks/handlers/stationSearchHandlers.ts b/src/mocks/handlers/stationSearchHandlers.ts new file mode 100644 index 00000000..b9a902dd --- /dev/null +++ b/src/mocks/handlers/stationSearchHandlers.ts @@ -0,0 +1,24 @@ +import { getCities, getSearchedStations, stations } from '@mocks/data'; +import { rest } from 'msw'; + +import { ERROR_MESSAGES } from '@constants/errorMessages'; +import { DEVELOP_SERVER_URL } from '@constants/server'; + +export const stationSearchHandlers = [ + rest.get(`${DEVELOP_SERVER_URL}/stations/search`, async (req, res, ctx) => { + const searchWord = req.url.searchParams.get('q'); + + if (!stations.length) { + return res(ctx.status(404), ctx.json({ message: ERROR_MESSAGES.NO_SEARCH_RESULT })); + } + + const searchResult = { + cities: getCities() + .filter((city) => city.cityName.includes(searchWord)) + .slice(0, 3), + stations: getSearchedStations(searchWord), + }; + + return res(ctx.delay(200), ctx.status(200), ctx.json(searchResult)); + }), +]; diff --git a/src/mocks/node.ts b/src/mocks/node.ts new file mode 100644 index 00000000..1ada4078 --- /dev/null +++ b/src/mocks/node.ts @@ -0,0 +1,8 @@ +import { setupServer } from 'msw/node'; + +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); + +beforeAll(() => server.listen()); +afterAll(() => server.close()); diff --git a/src/router/index.tsx b/src/router/index.tsx new file mode 100644 index 00000000..af075673 --- /dev/null +++ b/src/router/index.tsx @@ -0,0 +1,16 @@ +import { createBrowserRouter } from 'react-router-dom'; + +import GoogleLogin from '@components/login-page/GoogleLogin'; + +import App from '../App'; + +export const router = createBrowserRouter([ + { + path: '/', + element: <App />, + }, + { + path: '/google', + element: <GoogleLogin />, + }, +]); diff --git a/src/style/GlobalStyle.ts b/src/style/GlobalStyle.ts new file mode 100644 index 00000000..86b44715 --- /dev/null +++ b/src/style/GlobalStyle.ts @@ -0,0 +1,41 @@ +import { createGlobalStyle } from 'styled-components'; + +import { reset } from './reset'; + +export const GlobalStyle = createGlobalStyle` + ${reset} + + * { + font-family: 'Noto Sans KR', sans-serif !important; + } + + html, + body { + font-size: 62.5%; + /********** hidden scroll **********/ + scrollbar-width: none; + } + + &::-webkit-scrollbar { + display: none; + } + + :root { + --light-color: #e9edf8; + --lighter-color: #eef0f5; + + --gray-200-color: #ebebeb; + } + + body:has(.modal-open) { + overflow: hidden; + } + + button.gm-ui-hover-effect { + visibility: hidden; + } + + div.gm-style .gm-style-iw-c { + padding: 0; + } +`; diff --git a/src/style/index.ts b/src/style/index.ts new file mode 100644 index 00000000..47f94b87 --- /dev/null +++ b/src/style/index.ts @@ -0,0 +1,128 @@ +import { css } from 'styled-components'; + +import type { BorderRadiusDirectionType, Color, ToastPosition } from '@type/style'; + +export const borderRadius = (direction: BorderRadiusDirectionType) => css` + ${direction === 'all' && 'border-radius: 0;'} + ${direction === 'top' && 'border-top-left-radius: 0;'} + ${direction === 'top' && 'border-top-right-radius: 0;'} + ${direction === 'bottom' && 'border-bottom-left-radius: 0;'} + ${direction === 'bottom' && 'border-bottom-right-radius: 0;'} + ${direction === 'left' && 'border-top-left-radius: 0;'} + ${direction === 'left' && 'border-bottom-left-radius: 0;'} +`; + +export const pillStyle = css` + height: 3.6rem; + padding-top: 0; + padding-bottom: 0; + line-height: 1.8rem; + font-size: 1.5rem; + border-radius: 2.1rem; +`; + +export const getSize = (size: string | number) => { + if (size !== undefined) { + return typeof size === 'number' ? `${size}rem` : size; + } + return 'auto'; +}; + +export const getColor = (color?: Color) => { + switch (color) { + case 'primary': + return '#2a6cd8'; + case 'secondary': + return '#212529BF'; + case 'success': + return '#198754'; + case 'error': + return '#dc3545'; + case 'warning': + return '#ffc107'; + case 'info': + return '#0dcaf0'; + case 'light': + return '#f8f9fa'; + case 'dark': + return '#212529'; + default: + return '#2a6cd8'; + } +}; + +export const getHoverColor = (color?: Color) => { + switch (color) { + case 'primary': + return '#0b5ed7'; + case 'secondary': + return '#495057'; + case 'success': + return '#147a3d'; + case 'error': + return '#c82333'; + case 'warning': + return '#e0a800'; + case 'info': + return '#0da8d6'; + case 'light': + return '#f8f9fa'; + case 'disable': + return '#9a9a9a'; + case 'dark': + return '#16181b'; + default: + return '#0b5ed7'; + } +}; + +type ToastPositionProps = `${ToastPosition['column']}-${ToastPosition['row']}`; + +export const getPopupAnimation = (position: ToastPositionProps, duration: number) => { + return css` + ${position.includes('top') ? 'top: 0' : 'bottom: 0'}; + ${position.includes('left') && 'left: 0'}; + ${position.includes('right') && 'right: 0'}; + ${position.includes('center') && 'left: 50%; transform: translateX(-50%);'}; + + animation: + fadeIn ${duration}s, + PopUp 1s forwards; + + @keyframes fadeIn { + 0% { + opacity: 0; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0; + } + } + + ${popup(position)} + `; +}; + +const popup = (position: ToastPositionProps = 'bottom-center') => { + const column = position.includes('top') ? 'top' : 'bottom'; + const row = position.includes('center') + ? 'center' + : position.includes('right') + ? 'right' + : 'left'; + + return css` + @keyframes PopUp { + from { + ${row === 'center' ? `${column}: 0; left: 50%;` : `${column}: 12%; ${row}: 0;`} + } + to { + ${column}: 12%; + left: 50%; + transform: translateX(-50%); + } + } + `; +}; diff --git a/src/style/mediaQuery.ts b/src/style/mediaQuery.ts new file mode 100644 index 00000000..07738038 --- /dev/null +++ b/src/style/mediaQuery.ts @@ -0,0 +1,15 @@ +import { css } from 'styled-components'; + +import { MOBILE_BREAKPOINT } from '@constants'; + +export const displayNoneInMobile = css` + @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { + display: none; + } +`; + +export const displayNoneInWeb = css` + @media screen and (min-width: ${MOBILE_BREAKPOINT}px) { + display: none; + } +`; diff --git a/src/style/reset.ts b/src/style/reset.ts new file mode 100644 index 00000000..bdfeb3cb --- /dev/null +++ b/src/style/reset.ts @@ -0,0 +1,102 @@ +import { css } from 'styled-components'; + +// prettier-ignore + +export const reset = css` + /* http://meyerweb.com/eric/tools/css/reset/ + v5.0.1 | 20191019 + License: none (public domain) + */ + + html, body, div, span, applet, object, iframe, + h1, h2, h3, h4, h5, h6, p, blockquote, pre, + a, abbr, acronym, address, big, cite, code, + del, dfn, em, img, ins, kbd, q, s, samp, + small, strike, strong, sub, sup, tt, var, + b, u, i, center, + dl, dt, dd, menu, ol, ul, li, + fieldset, form, label, legend, + table, caption, tbody, tfoot, thead, tr, th, td, + article, aside, canvas, details, embed, + figure, figcaption, footer, header, hgroup, + main, menu, nav, output, ruby, section, summary, + time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; + } + + /* HTML5 display-role reset for older browsers */ + article, aside, details, figcaption, figure, + footer, header, hgroup, main, menu, nav, section { + display: block; + } + + /* HTML5 hidden-attribute fix for newer browsers */ + *[hidden] { + display: none; + } + + body { + line-height: 1; + } + + menu, ol, ul, dd { + list-style: none; + } + + blockquote, q { + quotes: none; + } + + blockquote:before, blockquote:after, + q:before, q:after { + content: ''; + content: none; + } + + table { + border-collapse: collapse; + border-spacing: 0; + } + + /****** Elad Shechter's RESET *******/ + /*** box sizing border-box for all elements ***/ + *, + *::before, + *::after { + box-sizing: border-box; + } + + a { + text-decoration: none; + color: inherit; + cursor: pointer; + } + + button { + background-color: transparent; + color: inherit; + border-width: 0; + padding: 0; + cursor: pointer; + } + + input::-moz-focus-inner { + border: 0; + padding: 0; + margin: 0; + } + + progress { + border: none; + box-shadow: none; + } + progress[value] { + -webkit-appearance: none; + appearance: none; + } +` diff --git a/src/types/assets.d.ts b/src/types/assets.d.ts new file mode 100644 index 00000000..3de0c72f --- /dev/null +++ b/src/types/assets.d.ts @@ -0,0 +1,6 @@ +declare module '*.svg' { + import React = require('react'); + export const ReactComponent: (props: React.SVGProps<SVGSVGElement>) => JSX.Element; + const src: string; + export default content; +} diff --git a/src/types/cars.ts b/src/types/cars.ts new file mode 100644 index 00000000..f00e6ea0 --- /dev/null +++ b/src/types/cars.ts @@ -0,0 +1,5 @@ +export interface Car { + carId: number; + name: string; + vintage: string; +} diff --git a/src/types/chargers.ts b/src/types/chargers.ts new file mode 100644 index 00000000..4de90354 --- /dev/null +++ b/src/types/chargers.ts @@ -0,0 +1,21 @@ +import type { CAPACITIES, CONNECTOR_TYPES } from '@constants/chargers'; +import type { CHARGER_STATES } from '@constants/chargers'; + +import type { ConnectorTypeKey } from './serverStationFilter'; + +export type ChargerStateType = keyof typeof CHARGER_STATES; +export type ChargerMethodType = '단독' | '동시' | null; +export type ConnectorTypeName = (typeof CONNECTOR_TYPES)[ConnectorTypeKey]; +export type Capacity = (typeof CAPACITIES)[number]; + +export interface ChargerSummary { + type: ConnectorTypeKey; + price: number; + capacity: Capacity; +} + +export interface ChargerDetails extends ChargerSummary { + latestUpdateTime: string | null; + state: ChargerStateType; + method: ChargerMethodType; +} diff --git a/src/types/congestion.ts b/src/types/congestion.ts new file mode 100644 index 00000000..e1e4398d --- /dev/null +++ b/src/types/congestion.ts @@ -0,0 +1,22 @@ +import type { + ENGLISH_DAYS_OF_WEEK, + SHORT_ENGLISH_DAYS_OF_WEEK, + SHORT_KOREAN_DAYS_OF_WEEK, +} from '@constants/congestion'; + +export type ShortEnglishDaysOfWeek = (typeof SHORT_ENGLISH_DAYS_OF_WEEK)[number]; +export type EnglishDaysOfWeek = (typeof ENGLISH_DAYS_OF_WEEK)[number]; +export type KoreanDaysOfWeek = (typeof SHORT_KOREAN_DAYS_OF_WEEK)[number]; + +export interface Congestion { + hour: number; + ratio: number; +} + +export interface CongestionStatistics { + stationId: string; + congestion: { + standard: Congestion[]; + quick: Congestion[]; + }; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..3179a767 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,5 @@ +export * from './stations'; +export * from './chargers'; +export * from './congestion'; +export * from './style'; +export { DisplayPosition } from '@type/map'; diff --git a/src/types/map.ts b/src/types/map.ts new file mode 100644 index 00000000..099907a1 --- /dev/null +++ b/src/types/map.ts @@ -0,0 +1,18 @@ +export interface DisplayPosition { + longitude: number; + latitude: number; + longitudeDelta: number; + latitudeDelta: number; + zoom: number; +} + +export interface Bounds { + northEast: { + latitude: number; + longitude: number; + }; + southWest: { + latitude: number; + longitude: number; + }; +} diff --git a/src/types/serverStationFilter.ts b/src/types/serverStationFilter.ts new file mode 100644 index 00000000..cf392cf5 --- /dev/null +++ b/src/types/serverStationFilter.ts @@ -0,0 +1,7 @@ +import type { CAPACITIES, COMPANIES } from '@constants/chargers'; + +import type { CONNECTOR_TYPES } from './../constants/chargers'; + +export type CompanyKey = keyof typeof COMPANIES; +export type CapaCityBigDecimal = `${(typeof CAPACITIES)[number]}.00`; +export type ConnectorTypeKey = keyof typeof CONNECTOR_TYPES; diff --git a/src/types/stations.ts b/src/types/stations.ts new file mode 100644 index 00000000..41336482 --- /dev/null +++ b/src/types/stations.ts @@ -0,0 +1,132 @@ +import type { CHARGING_SPEED, COMPANIES, CONNECTOR_TYPES } from '@constants/chargers'; + +import type { Capacity, ChargerMethodType, ChargerStateType } from '@type/chargers'; + +import type { CapaCityBigDecimal, CompanyKey, ConnectorTypeKey } from './serverStationFilter'; + +export interface Charger { + capacity: Capacity; + latestUpdateTime: string; + method: ChargerMethodType; + price: number; + state: ChargerStateType; + type: keyof typeof CONNECTOR_TYPES; +} + +export interface Station { + address: string; + availableCount: number; + quickChargerCount: number; + chargers: Charger[]; + companyName: string; + contact: string; + detailLocation: string; + isParkingFree: boolean; + isPrivate: boolean; + latitude: number; + longitude: number; + operatingTime: string; + privateReason: string; + reportCount: number; + stationId: string; + stationName: string; + stationState: string; + totalCount: number; +} + +export type StationMarker = Pick< + Station, + | 'latitude' + | 'longitude' + | 'stationId' + | 'stationName' + | 'availableCount' + | 'isParkingFree' + | 'isPrivate' + | 'quickChargerCount' +>; + +export type StationSummary = Pick< + Station, + | 'address' + | 'availableCount' + | 'companyName' + | 'detailLocation' + | 'isParkingFree' + | 'isPrivate' + | 'latitude' + | 'longitude' + | 'operatingTime' + | 'stationId' + | 'stationName' + | 'totalCount' + | 'quickChargerCount' +>; + +export type StationDetails = Pick< + Station, + | 'address' + | 'chargers' + | 'companyName' + | 'contact' + | 'detailLocation' + | 'isParkingFree' + | 'isPrivate' + | 'latitude' + | 'longitude' + | 'operatingTime' + | 'privateReason' + | 'reportCount' + | 'stationId' + | 'stationName' + | 'stationState' +>; + +export type CompanyName = (typeof COMPANIES)[CompanyKey]; + +export interface SearchedStation + extends Pick<Station, 'stationId' | 'stationName' | 'address' | 'latitude' | 'longitude'> { + speed: keyof typeof CHARGING_SPEED; +} + +export interface SearchedCity { + cityName: string; + latitude: number; + longitude: number; +} + +export type StationPosition = Pick<Station, 'stationId' | 'longitude' | 'latitude'>; + +export type StationDetailsWithoutChargers = Omit<StationDetails, 'chargers'>; + +export interface StationRatings { + totalRatings: number; + totalCount: number; +} + +export interface Review { + reviewId: number; + memberId: number; + latestUpdateDate: string; + ratings: number; + content: string; + isUpdated: boolean; + isDeleted: boolean; + replySize: number; +} + +export interface Reply { + replyId: number; + reviewId: number; + memberId: number; + latestUpdateDate: string; + content: string; + isUpdated: boolean; + isDeleted: boolean; +} + +export interface StationFilters { + companies: CompanyKey[]; + connectorTypes: ConnectorTypeKey[]; + capacities: CapaCityBigDecimal[]; +} diff --git a/src/types/style.ts b/src/types/style.ts new file mode 100644 index 00000000..39244769 --- /dev/null +++ b/src/types/style.ts @@ -0,0 +1,23 @@ +type RowType = 'left' | 'right'; +type ColumnType = 'top' | 'bottom'; + +export interface ToastPosition { + row: RowType | 'center'; + column: ColumnType; +} + +export type BorderRadiusDirectionType = 'all' | ColumnType | 'left'; +export type AxisType = 'row' | 'column'; + +export type Color = + | 'primary' + | 'secondary' + | 'success' + | 'info' + | 'warning' + | 'error' + | 'light' + | 'disable' + | 'dark'; + +export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; diff --git a/src/utils/calculateToastDuration.test.ts b/src/utils/calculateToastDuration.test.ts new file mode 100644 index 00000000..05f17542 --- /dev/null +++ b/src/utils/calculateToastDuration.test.ts @@ -0,0 +1,14 @@ +import { calculateToastDuration } from '@utils/calculateToastDuration'; + +describe('calculateToastDuration를 테스트한다', () => { + it('메시지 길이가 5글자 이하면 4를 반환한다.', () => { + const message = '12345'; + const duration = calculateToastDuration(message); + expect(duration).toBe(4); + }); + it('메시지 길이가 5글자 초과면 4 + 올림(글자수 - 5 / 5)를 반환한다.', () => { + const message = '123456'; + const duration = calculateToastDuration(message); + expect(duration).toBe(5); + }); +}); diff --git a/src/utils/calculateToastDuration.ts b/src/utils/calculateToastDuration.ts new file mode 100644 index 00000000..b3e59f21 --- /dev/null +++ b/src/utils/calculateToastDuration.ts @@ -0,0 +1,13 @@ +export const calculateToastDuration = (message: string) => { + const charCount = message.length; + const baseDuration = 4; + const charThreshold = 5; + + if (charCount < charThreshold) { + return baseDuration; + } + + const additionalSeconds = Math.ceil((charCount - charThreshold) / charThreshold); + + return baseDuration + additionalSeconds; +}; diff --git a/src/utils/debounce.test.ts b/src/utils/debounce.test.ts new file mode 100644 index 00000000..bab68b2f --- /dev/null +++ b/src/utils/debounce.test.ts @@ -0,0 +1,20 @@ +import { debounce } from '@utils/debounce'; + +describe('debounce를 테스트한다', () => { + it('debounce 걸린 함수를 연속으로 3번 호출하지만 실제로는 1번만 호출된다.', () => { + jest.useFakeTimers(); + const func = jest.fn(); + const debouncedFunc = debounce(func, 1000); + + debouncedFunc(); + debouncedFunc(); + debouncedFunc(); + + expect(func).not.toBeCalled(); + + jest.runAllTimers(); + + expect(func).toBeCalled(); + expect(func).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts new file mode 100644 index 00000000..5edc3945 --- /dev/null +++ b/src/utils/debounce.ts @@ -0,0 +1,16 @@ +export type DebounceFunction = <T extends ((...args: T) => void)[]>( + func: (...args: T) => void, + delay: number +) => (...args: T) => void; + +export const debounce: DebounceFunction = (func, delay) => { + let timerId: NodeJS.Timeout; + + return (...args) => { + clearTimeout(timerId); + + timerId = setTimeout(() => { + func.apply(this, args); + }, delay); + }; +}; diff --git a/src/utils/fetch/index.ts b/src/utils/fetch/index.ts new file mode 100644 index 00000000..3d431acf --- /dev/null +++ b/src/utils/fetch/index.ts @@ -0,0 +1,69 @@ +import { handleInvalidTokenToLogout } from '@utils/login'; + +import { memberTokenStore } from '@stores/login/memberTokenStore'; + +import { EMPTY_MEMBER_TOKEN } from '@constants'; + +export const fetchUtils = { + /** + * @param url request url + * @param errorMessage 에러 발생시 보여줄 에러 메세지 + * @returns T + */ + async get<T>(url: string, errorMessage?: string) { + const isMemberTokenExist = memberTokenStore.getState() !== EMPTY_MEMBER_TOKEN; + + return await fetch(url, { + method: 'GET', + headers: isMemberTokenExist + ? { + Authorization: `Bearer ${memberTokenStore.getState()}`, + } + : undefined, + }).then<T>((response) => { + if (!response.ok) { + if (response.status === 401) { + handleInvalidTokenToLogout(); + + throw new Error('로그인이 필요합니다'); + } + + throw new Error(errorMessage ?? '에러가 발생했습니다'); + } + + return response.json(); + }); + }, + /** + * @param url 요청을 발생시킬 url + * @param body request body + * @param errorMessage 에러 발생시 보여줄 에러 메세지 + * @returns T + */ + async post<T, U extends object>(url: string, body: U, errorMessage?: string) { + const isMemberTokenExist = memberTokenStore.getState() !== EMPTY_MEMBER_TOKEN; + + return await fetch(url, { + method: 'POST', + headers: isMemberTokenExist + ? { + Authorization: `Bearer ${memberTokenStore.getState()}`, + 'Content-Type': 'application/json', + } + : { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).then<T>((response) => { + if (!response.ok) { + if (response.status === 401) { + handleInvalidTokenToLogout(); + + throw new Error('로그인이 필요합니다'); + } + + throw new Error(errorMessage ?? '에러가 발생했습니다'); + } + + return response.json(); + }); + }, +}; diff --git a/src/utils/getTypedObjectEntries.ts b/src/utils/getTypedObjectEntries.ts new file mode 100644 index 00000000..8254fd6e --- /dev/null +++ b/src/utils/getTypedObjectEntries.ts @@ -0,0 +1,7 @@ +type Entries<T> = { + [K in keyof T]: [K, T[K]]; +}[keyof T][]; + +export const getTypedObjectEntries = <T extends object>(obj: T) => { + return Object.entries(obj) as Entries<T>; +}; diff --git a/src/utils/getTypedObjectFromEntries.ts b/src/utils/getTypedObjectFromEntries.ts new file mode 100644 index 00000000..cb3f9bb9 --- /dev/null +++ b/src/utils/getTypedObjectFromEntries.ts @@ -0,0 +1,37 @@ +/** + * + * @description + * Object.fromEntries( + KEYS.map((day, index) => { + return [day, VALUES[index]]; + }) + * ) → 이 메서드를 사용할 때 key 타입 추론이 되지 않는 문제를 해결하기 위해 구현 + * @param keys KEY 배열 (객체의 key들) + * @param values VALUE 배열 (하나의 key당 올 수 있는 value들) + * @example getTypedObjectFromEntries(KEYS, VALUES) + * @returns {Object} An object with key-value pairs. + * @property key: value | value | value | ... + * @property key: value | value | value | ... + * @property key: value | value | value | ... + * @example getTypedObjectFromEntries( + KEYS, + KEYS.map(() => OBJECTS) + ) + * @returns {Object} An object with key-value pairs. + * @property key: value[OBJECT, {...}, ...] (= key: object[]) + * @property key: value[OBJECT, {...}, ...] + * @property key: value[OBJECT, {...}, ...] + */ + +export const getTypedObjectFromEntries = <T extends string | symbol | number, K>( + keys: readonly T[], + values: readonly K[] +): { [Key in T]: K } => + keys.reduce( + (result, key, index) => { + result[key] = values[index]; + + return result; + }, + {} as { [Key in T]: K } + ); diff --git a/src/utils/getTypedObjectKeys.ts b/src/utils/getTypedObjectKeys.ts new file mode 100644 index 00000000..8629f08e --- /dev/null +++ b/src/utils/getTypedObjectKeys.ts @@ -0,0 +1,3 @@ +export const getTypedObjectKeys = <T extends object>(obj: T) => { + return Object.keys(obj) as Array<keyof T>; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..c560ceb1 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,24 @@ +export const calculateLatestUpdateTime = (latestUpdateTimeString: string) => { + const currentDate = new Date(); + const latestUpdatedDate = new Date(latestUpdateTimeString); + const diffInSeconds = Math.floor((currentDate.getTime() - latestUpdatedDate.getTime()) / 1000); + + if (diffInSeconds < 60) { + return `방금 전`; + } + + const diffInMinutes = Math.floor(diffInSeconds / 60); + + if (diffInMinutes < 60) { + return `${diffInMinutes}분 전`; + } + + const diffInHours = Math.floor(diffInMinutes / 60); + + if (diffInHours < 24) { + return `${diffInHours}시간 전`; + } + + const diffInDays = Math.floor(diffInHours / 24); + return `${diffInDays}일 전`; +}; diff --git a/src/utils/login/index.ts b/src/utils/login/index.ts new file mode 100644 index 00000000..91542854 --- /dev/null +++ b/src/utils/login/index.ts @@ -0,0 +1,96 @@ +import { setSessionStorage } from '@utils/storage'; + +import { toastActions } from '@stores/layout/toastStore'; +import { memberInfoAction } from '@stores/login/memberInfoStore'; +import { memberTokenActions, memberTokenStore } from '@stores/login/memberTokenStore'; +import { serverStationFilterAction } from '@stores/station-filters/serverStationFiltersStore'; + +import { DEFAULT_TOKEN, EMPTY_MEMBER_TOKEN } from '@constants'; +import { SERVER_URL } from '@constants/server'; +import { SESSION_KEY_MEMBER_INFO, SESSION_KEY_MEMBER_TOKEN } from '@constants/storageKeys'; + +interface LoginUriResponse { + loginUri: string; +} + +export const redirectToLoginPage = (provider: string) => { + const { showToast } = toastActions; + const redirectUri = getRedirectUri(); + + fetch(`${SERVER_URL}/oauth/${provider}/login-uri?redirect-uri=${redirectUri}/${provider}`) + .then<LoginUriResponse>((response) => response.json()) + .then((data) => { + const loginUri = data.loginUri; + + if (loginUri !== undefined) { + window.location.href = loginUri; + } + }) + .catch(() => { + showToast('로그인에 실패했습니다', 'error'); + }); +}; + +interface TokenResponse { + token: string; +} + +export const getMemberToken = async (code: string, provider: string) => { + const redirectUri = getRedirectUri(); + + const tokenResponse = await fetch(`${SERVER_URL}/oauth/google/login`, { + method: 'POST', + headers: { + 'Content-type': 'application/json', + }, + body: JSON.stringify({ + code, + redirectUri: `${redirectUri}/${provider}`, + }), + }).then<TokenResponse>((response) => response.json()); + + const memberToken = tokenResponse.token; + + return memberToken; +}; + +export const getRedirectUri = () => { + const isProductionServer = window.location.href.search(/https:\/\/carffe.in/) !== -1; + const isDevServer = window.location.href.search(/https:\/\/dev.carffe.in/) !== -1; + + if (isProductionServer) { + return `https://carffe.in`; + } + + if (isDevServer) { + return `https://dev.carffe.in`; + } + + return 'http://localhost:3000'; +}; + +export const logout = () => { + const { setMemberToken } = memberTokenActions; + const { resetMemberInfo } = memberInfoAction; + const { deleteAllServerStationFilters } = serverStationFilterAction; + + setMemberToken(EMPTY_MEMBER_TOKEN); + resetMemberInfo(); + setSessionStorage(SESSION_KEY_MEMBER_TOKEN, EMPTY_MEMBER_TOKEN); + setSessionStorage( + SESSION_KEY_MEMBER_INFO, + `{ + "memberId": ${DEFAULT_TOKEN}, + "car": null + }` + ); + deleteAllServerStationFilters(); +}; + +export const handleInvalidTokenToLogout = () => { + const isTokenExist = memberTokenStore.getState() !== EMPTY_MEMBER_TOKEN; + + if (isTokenExist) { + logout(); + } +}; diff --git a/src/utils/mswModeActions.ts b/src/utils/mswModeActions.ts new file mode 100644 index 00000000..34850b0f --- /dev/null +++ b/src/utils/mswModeActions.ts @@ -0,0 +1,17 @@ +export const mswModeActions = { + startMsw: async () => { + const { worker } = require('@mocks/browser'); + + await worker.start({ + serviceWorker: { + url: '/mockServiceWorker.js', + }, + onUnhandledRequest: 'bypass', + }); + }, + stopMsw: async () => { + const { worker } = require('@mocks/browser'); + + await worker.stop(); + }, +}; diff --git a/src/utils/randomDataGenerator.ts b/src/utils/randomDataGenerator.ts new file mode 100644 index 00000000..13a46cf7 --- /dev/null +++ b/src/utils/randomDataGenerator.ts @@ -0,0 +1,49 @@ +/** + * 현 시점으로 부터 2일 이내의 랜덤한 시간 문자열을 생성한다. + */ +export const getRandomTime = () => { + const now = new Date(); + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); + + const randomTimestamp = + Math.random() * (now.getTime() - twoDaysAgo.getTime()) + twoDaysAgo.getTime(); + + const randomDate = new Date(randomTimestamp); + + const randomHour = Math.floor(Math.random() * 24); + const randomMinute = Math.floor(Math.random() * 60); + const randomSecond = Math.floor(Math.random() * 60); + + randomDate.setHours(randomHour); + randomDate.setMinutes(randomMinute); + randomDate.setSeconds(randomSecond); + + const isoString = randomDate.toISOString(); + return isoString; +}; + +/** + * 랜덤한 수 10,000,000 ~ 99,999,999 중 하나를 생성한다. + */ +export const generateRandomToken = () => { + const min = 10_000_000; + const max = 99_999_999; + + return Math.floor(Math.random() * (max - min + 1)) + min; +}; + +/** + * 범위 내의 랜덤한 수 하나를 생성한다. + */ +export const generateRandomCommentsLength = (min: number, max: number) => { + return Math.floor(Math.random() * (max - min + 1)) + min; +}; + +/** + * 배열 내부에 있는 값 중 아무거나 랜덤으로 반환한다. + */ +export const generateRandomData = <T>(array: T[]): T => { + const randomIndex = Math.floor(Math.random() * array.length); + + return array[randomIndex]; +}; diff --git a/src/utils/request-query-params/getQueryFormattedUrl.test.ts b/src/utils/request-query-params/getQueryFormattedUrl.test.ts new file mode 100644 index 00000000..c3d6ba0a --- /dev/null +++ b/src/utils/request-query-params/getQueryFormattedUrl.test.ts @@ -0,0 +1,25 @@ +import { getQueryFormattedUrl } from '@utils/request-query-params/index'; + +describe('getQueryFormattedUrl를 테스트한다', () => { + it('queryObject에 빈 객체를 넘기면 빈 문자열을 반환한다.', () => { + const queryObject = {}; + const result = getQueryFormattedUrl(queryObject); + expect(result).toBe(''); + }); + it('queryObject에 값이 빈 객체를 넘기면 빈 문자열을 반환한다.', () => { + const queryObject = { + a: '', + b: '', + }; + const result = getQueryFormattedUrl(queryObject); + expect(result).toBe(''); + }); + it('queryObject에 객체를 넘기면 query string을 반환한다.', () => { + const queryObject = { + a: '1', + b: '2', + }; + const result = getQueryFormattedUrl(queryObject); + expect(result).toBe('a=1&b=2'); + }); +}); diff --git a/src/utils/request-query-params/index.ts b/src/utils/request-query-params/index.ts new file mode 100644 index 00000000..6f254a56 --- /dev/null +++ b/src/utils/request-query-params/index.ts @@ -0,0 +1,9 @@ +export const getQueryFormattedUrl = (queryObject: { [key: string]: string }) => { + const queryFormattedUrl = Object.entries(queryObject).map(([key, value]) => { + if (value === '') return ''; + + return `${key}=${value}`; + }); + + return queryFormattedUrl.filter((queryParam) => queryParam !== '').join('&'); +}; diff --git a/tsconfig.json b/tsconfig.json index 16621c47..60abaa14 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,22 @@ ], "paths": { "@*": ["./src/*"], + "@mocks/*": ["./src/mocks/*"], + "@utils/*": ["./src/utils/*"], + "@map/*": ["./src/components/google-maps/map/*"], + "@marker/*": ["./src/components/google-maps/marker/*"], + "@ui/*": ["./src/components/ui/*"], + "@common/*": ["./src/components/common/*"], + "@components/*": ["./src/components/*"], + "@hooks/*": ["./src/hooks/*"], + "@stores/*": ["./src/stores/*"], + "@constants": ["./src/constants/index"], + "@constants/*": ["./src/constants/*"], + "@style": ["./src/style/index"], + "@style/*": ["./src/style/*"], + "@assets/*": ["./src/assets/*"], + "@type": ["./src/types/index"], + "@type/*": ["./src/types/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], From b32d78bf98914b550e41cf4437ba6816958b78d3 Mon Sep 17 00:00:00 2001 From: gabrielyoon7 <gabrielyoon7@gmail.com> Date: Tue, 10 Oct 2023 15:35:05 +0900 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20=EC=83=88=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=98=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20+=20next.js=EC=97=90=20=EB=A7=9E=EB=8A=94=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=82=BD=EC=9E=85=20=EB=B0=A9?= =?UTF-8?q?=EB=B2=95=EC=9C=BC=EB=A1=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/useRegionMarkers.ts | 13 +++++++------ src/components/ui/Navigator/NavigationBar/Menu.tsx | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRegionMarkers.ts b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRegionMarkers.ts index 01d73106..df81c25f 100644 --- a/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRegionMarkers.ts +++ b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/hooks/useRegionMarkers.ts @@ -6,15 +6,16 @@ import { SERVER_URL } from '@constants/server'; import type { Region } from '../types'; export const fetchRegionMarkers = async () => { - const stationMarkers = await fetch(`${SERVER_URL}/stations/regions?regions=all`) - .then<Region[]>(async (response) => { + const stationMarkers = await fetch(`${SERVER_URL}/stations/regions?regions=all`).then<Region[]>( + async (response) => { + if (!response.ok) { + throw new Error('지역 마커를 수신을 실패했습니다.'); + } const data = await response.json(); return data; - }) - .catch((error) => { - throw new Error('지역 마커를 수신을 실패했습니다.', error); - }); + } + ); return stationMarkers; }; diff --git a/src/components/ui/Navigator/NavigationBar/Menu.tsx b/src/components/ui/Navigator/NavigationBar/Menu.tsx index 97a1bc7f..be418348 100644 --- a/src/components/ui/Navigator/NavigationBar/Menu.tsx +++ b/src/components/ui/Navigator/NavigationBar/Menu.tsx @@ -25,6 +25,7 @@ import { EMPTY_MEMBER_TOKEN, MOBILE_BREAKPOINT } from '@constants'; import Logo from '@assets/logo-sm.svg'; import { useNavigationBar } from './hooks/useNavigationBar'; +import Image from 'next/image'; const Menu = () => { const { openBasePanel } = useNavigationBar(); @@ -45,8 +46,7 @@ const Menu = () => { aria-label="새로 고침" onClick={() => location.reload()} > - {/*<Logo />*/} - 로고 + <Image src={Logo} alt="로고"/> </Button> <Button From e2779c72343551f9f896ef504639ce557ae75d3f Mon Sep 17 00:00:00 2001 From: gabrielyoon7 <gabrielyoon7@gmail.com> Date: Tue, 10 Oct 2023 16:50:42 +0900 Subject: [PATCH 07/13] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EB=B6=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/Alert/Alert.stories.tsx | 42 ---- src/components/common/Box/Box.stories.tsx | 145 ----------- .../common/Button/Button.stories.tsx | 177 -------------- .../common/ButtonNext/ButtonNext.stories.tsx | 231 ------------------ .../common/FlexBox/FlexBox.stories.tsx | 221 ----------------- src/components/common/List/List.stories.tsx | 82 ------- .../common/ListItem/ListItem.stories.tsx | 63 ----- .../common/Loader/Loader.stories.tsx | 43 ---- src/components/common/Modal/Modal.stories.tsx | 202 --------------- .../common/SelectBox/SelectBox.stories.tsx | 43 ---- .../common/Skeleton/Skeleton.stories.tsx | 61 ----- src/components/common/Text/Text.stories.tsx | 159 ------------ .../common/TextField/TextField.stories.tsx | 99 -------- src/components/common/Toast/Toast.stories.tsx | 124 ---------- .../components/RegionMarker.stories.tsx | 32 --- .../CarFfeineMarker/CarFfeine.stories.tsx | 30 --- src/components/ui/Loading/Loading.stories.tsx | 14 -- .../NavigationBar/NavigationBar.stories.tsx | 81 ------ .../ProfileMenu/ProfileMenu.stories.tsx | 64 ----- .../ui/modal/CarModal/CarModal.stories.tsx | 15 -- .../modal/LoginModal/LoginModal.stories.tsx | 15 -- 21 files changed, 1943 deletions(-) delete mode 100644 src/components/common/Alert/Alert.stories.tsx delete mode 100644 src/components/common/Box/Box.stories.tsx delete mode 100644 src/components/common/Button/Button.stories.tsx delete mode 100644 src/components/common/ButtonNext/ButtonNext.stories.tsx delete mode 100644 src/components/common/FlexBox/FlexBox.stories.tsx delete mode 100644 src/components/common/List/List.stories.tsx delete mode 100644 src/components/common/ListItem/ListItem.stories.tsx delete mode 100644 src/components/common/Loader/Loader.stories.tsx delete mode 100644 src/components/common/Modal/Modal.stories.tsx delete mode 100644 src/components/common/SelectBox/SelectBox.stories.tsx delete mode 100644 src/components/common/Skeleton/Skeleton.stories.tsx delete mode 100644 src/components/common/Text/Text.stories.tsx delete mode 100644 src/components/common/TextField/TextField.stories.tsx delete mode 100644 src/components/common/Toast/Toast.stories.tsx delete mode 100644 src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarker.stories.tsx delete mode 100644 src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeine.stories.tsx delete mode 100644 src/components/ui/Loading/Loading.stories.tsx delete mode 100644 src/components/ui/Navigator/NavigationBar/NavigationBar.stories.tsx delete mode 100644 src/components/ui/Navigator/ProfileMenu/ProfileMenu.stories.tsx delete mode 100644 src/components/ui/modal/CarModal/CarModal.stories.tsx delete mode 100644 src/components/ui/modal/LoginModal/LoginModal.stories.tsx diff --git a/src/components/common/Alert/Alert.stories.tsx b/src/components/common/Alert/Alert.stories.tsx deleted file mode 100644 index 0c3fa94b..00000000 --- a/src/components/common/Alert/Alert.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import type { AlertProps } from './Alert'; -import Alert from './Alert'; - -const meta = { - title: 'Components/Alert', - component: Alert, - tags: ['autodocs'], - args: { - color: 'primary', - text: 'You forget a thousand things every day. Make sure this is one of them.', - }, - argTypes: { - color: { - description: '선택한 색상에 따라 배경색이 변합니다.', - }, - text: { - description: '원하는 글자를 입력해 테스트를 할 수 있습니다.', - }, - }, -} satisfies Meta<typeof Alert>; - -export default meta; - -export const Default = (args: AlertProps) => { - return <Alert {...args} />; -}; -export const Colors = () => { - return ( - <> - <Alert color="primary" text="primary alert" /> - <Alert color="secondary" text="secondary alert" /> - <Alert color="success" text="success alert" /> - <Alert color="error" text="error alert" /> - <Alert color="warning" text="warning alert" /> - <Alert color="info" text="info alert" /> - <Alert color="light" text="light alert" /> - <Alert color="dark" text="dark alert" /> - </> - ); -}; diff --git a/src/components/common/Box/Box.stories.tsx b/src/components/common/Box/Box.stories.tsx deleted file mode 100644 index 38435231..00000000 --- a/src/components/common/Box/Box.stories.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import Text from '../Text'; -import type { BoxProps } from './Box'; -import Box from './Box'; - -const meta = { - title: 'Components/Box', - component: Box, - tags: ['autodocs'], - args: { - border: false, - pl: 0, - pr: 0, - pt: 0, - pb: 0, - px: 0, - py: 0, - p: 0, - ml: 0, - mr: 0, - mt: 0, - mb: 0, - mx: 0, - my: 0, - m: 0, - height: 0, - minHeight: 0, - maxHeight: 0, - width: 0, - minWidth: 0, - maxWidth: 0, - position: 'absolute', - }, - argTypes: { - children: { - description: 'div처럼 사용할 수 있습니다.', - }, - border: { - description: '테두리를 그릴 수 있습니다.', - }, - pl: { - description: '왼쪽의 방향으로 패딩을 줍니다. 우선 순위가 제일 높습니다.', - }, - pr: { - description: '오른쪽의 방향으로 패딩을 줍니다. 우선 순위가 제일 높습니다.', - }, - pt: { - description: '천장의 방향으로 패딩을 줍니다. 우선 순위가 제일 높습니다.', - }, - pb: { - description: '바닥의 방향으로 패딩을 줍니다. 우선 순위가 제일 높습니다.', - }, - px: { - description: 'x축의 방향으로 패딩을 줍니다. 우선 순위가 중간입니다.', - }, - py: { - description: 'y축의 방향으로 패딩을 줍니다. 우선 순위가 중간입니다.', - }, - p: { - description: '4방향으로 패딩을 줍니다. 우선 순위가 제일 낮습니다.', - }, - ml: { - description: '왼쪽의 방향으로 마진을 줍니다. 우선 순위가 제일 높습니다.', - }, - mr: { - description: '오른쪽의 방향으로 마진을 줍니다. 우선 순위가 제일 높습니다.', - }, - mt: { - description: '천장의 방향으로 마진을 줍니다. 우선 순위가 제일 높습니다.', - }, - mb: { - description: '바닥의 방향으로 마진을 줍니다. 우선 순위가 제일 높습니다.', - }, - mx: { - description: 'x축의 방향으로 마진을 줍니다. 우선 순위가 중간입니다.', - }, - my: { - description: 'y축의 방향으로 마진을 줍니다. 우선 순위가 중간입니다.', - }, - m: { - description: '4방향으로 마진을 줍니다. 우선 순위가 제일 낮습니다.', - }, - height: { - description: '높이 길이를 설정할 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', - }, - minHeight: { - description: '최소 높이 길이를 설정할 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', - }, - maxHeight: { - description: '최대 높이 길이를 설정할 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', - }, - width: { - description: '너비 길이를 설정할 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', - }, - minWidth: { - description: '최소 너비 길이를 설정할 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', - }, - maxWidth: { - description: '최대 너비 길이를 설정할 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', - }, - bgColor: { - description: '배경 색을 정할 수 있습니다.', - }, - color: { - description: '대표 글자 색을 정할 수 있습니다.', - }, - position: { - options: { - none: false, - static: 'static', - relative: 'relative', - absolute: 'absolute', - fixed: 'fixed', - sticky: 'sticky', - }, - control: { - type: 'select', - }, - description: 'position을 설정합니다.', - }, - top: { - description: 'position과 함께 쓸 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', - }, - left: { - description: 'position과 함께 쓸 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', - }, - bottom: { - description: 'position과 함께 쓸 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', - }, - right: { - description: 'position과 함께 쓸 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', - }, - }, -} satisfies Meta<typeof Box>; - -export default meta; - -export const Default = (args: BoxProps) => { - return ( - <Box {...args}> - <Text variant="body">이것은 아무것도 없는 박스입니다.</Text> - </Box> - ); -}; diff --git a/src/components/common/Button/Button.stories.tsx b/src/components/common/Button/Button.stories.tsx deleted file mode 100644 index 028a102e..00000000 --- a/src/components/common/Button/Button.stories.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import type { Meta } from '@storybook/react'; -import styled from 'styled-components'; - -import { BUTTON_PADDING_SIZE } from '@common/Button/Button.style'; - -import type { ButtonProps } from './Button'; -import Button from './Button'; - -const meta = { - title: 'Components/Button', - component: Button, - tags: ['autodocs'], - args: { - children: 'Button', - width: 'auto', - height: 'auto', - outlined: true, - shadow: false, - hover: true, - size: 'sm', - onClick: () => { - alert('click'); - }, - }, - argTypes: { - children: { - control: { - type: 'text', - }, - description: - '버튼 내용을 입력할 수 있습니다.<br> 원하는 컴포넌트, 텍스트 등을 넣을 수 있습니다.', - }, - width: { - description: - '숫자를 입력하면 `입력한 숫자 x 10px` 만큼 버튼 너비가 늘어납니다.<br> 단위를 포함해 문자(ex. "100%")로 입력하면 원하는 만큼 버튼 너비가 늘어납니다.', - }, - height: { - description: - '숫자를 입력하면 `입력한 숫자 x 10px` 버튼 높이가 늘어납니다.<br> 단위를 포함해 문자(ex. "100%")로 입력하면 원하는 만큼 버튼 높이가 늘어납니다.', - }, - variant: { - options: { none: false, pill: 'pill' }, - control: { - type: 'select', - }, - description: '버튼 모양을 변경할 수 있습니다.', - }, - noRadius: { - options: { none: false, all: 'all', top: 'top', bottom: 'bottom' }, - control: { - type: 'select', - }, - description: '특정 방향의 radius 속성을 제거할 수 있습니다.', - }, - shadow: { - control: { - type: 'boolean', - }, - description: 'true: 버튼 주변으로 그림자가 생깁니다.', - }, - size: { - options: Object.keys({ ...BUTTON_PADDING_SIZE, none: undefined }), - control: { - type: 'select', - }, - description: '선택한 사이즈에 따라 버튼의 크기가 변합니다.', - }, - outlined: { - control: { - type: 'boolean', - }, - description: 'true: 버튼에 검은색 테두리가 생깁니다.', - }, - background: { - control: { - type: 'color', - }, - description: '선택한 색상에 따라 버튼의 배경색이 변합니다.', - }, - hover: { - description: '호버했을 때 버튼 배경색이 변합니다.', - }, - css: { - description: '원하는 css를 적용할 수 있습니다.', - }, - onClick: { - control: false, - }, - }, -} satisfies Meta<typeof Button>; - -export default meta; - -export const Default = (args: ButtonProps) => { - return <Button {...args} />; -}; - -export const Sizes = () => { - return ( - <> - <Container> - <Button outlined size="xs"> - Button - </Button> - <Button outlined size="sm"> - Button - </Button> - <Button outlined size="md"> - Button - </Button> - <Button outlined size="lg"> - Button - </Button> - <Button outlined size="xl"> - Button - </Button> - </Container> - <Container> - <Button outlined variant="pill" size="xs"> - Button - </Button> - <Button outlined variant="pill" size="sm"> - Button - </Button> - <Button outlined variant="pill" size="md"> - Button - </Button> - <Button outlined variant="pill" size="lg"> - Button - </Button> - <Button outlined variant="pill" size="xl"> - Button - </Button> - </Container> - </> - ); -}; - -export const Styles = () => { - return ( - <Container> - <Button outlined size="sm"> - Button - </Button> - <Button shadow size="sm"> - Button - </Button> - <Button outlined size="sm" noRadius="all"> - Button - </Button> - <Button outlined size="sm" noRadius="top"> - Button - </Button> - <Button outlined size="sm" noRadius="bottom"> - Button - </Button> - <Button outlined size="sm" variant="pill"> - Button - </Button> - <Button shadow size="sm" variant="pill"> - Button - </Button> - </Container> - ); -}; - -const Container = styled.div` - margin-top: 2rem; - - &:first-child { - margin-top: 0; - } - - & > button { - margin-right: 2rem; - } -`; diff --git a/src/components/common/ButtonNext/ButtonNext.stories.tsx b/src/components/common/ButtonNext/ButtonNext.stories.tsx deleted file mode 100644 index efd2bc84..00000000 --- a/src/components/common/ButtonNext/ButtonNext.stories.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { useState } from 'react'; - -import Box from '@common/Box'; -import ButtonNext from '@common/ButtonNext'; -import type { ButtonNextProps } from '@common/ButtonNext/ButtonNext'; -import FlexBox from '@common/FlexBox'; -import Text from '@common/Text'; - -const meta = { - title: 'Components/ButtonNext', - component: ButtonNext, - tags: ['autodocs'], - args: { - children: 'Button', - }, - argTypes: { - children: { - control: { - type: 'text', - }, - description: - '버튼 내용을 입력할 수 있습니다.<br> 원하는 컴포넌트, 텍스트 등을 넣을 수 있습니다.', - }, - noTheme: { - description: '테마를 끌 수 있습니다.', - }, - variant: { - description: '버튼의 형태를 정할 수 있습니다. 가득참, 빈, 텍스트 모드로 전환할 수 있습니다.', - }, - size: { - description: '사이즈를 정할 수 있습니다.', - }, - disabled: { - description: '버튼을 비활성화 할 수 있습니다.', - }, - fullWidth: { - description: '버튼을 가득 채울 수 있습니다.', - }, - pill: { - description: '버튼을 알약 모양으로 만들 수 있습니다.', - }, - css: { - description: '버튼에 CSS를 부여할 수 있습니다.', - }, - }, -}; -export default meta; - -export const Default = (args: ButtonNextProps) => { - return <ButtonNext {...args}>Button</ButtonNext>; -}; - -export const Variant = () => { - return ( - <> - <ButtonNext variant="text">Text</ButtonNext> - <ButtonNext variant="outlined">Outlined</ButtonNext> - <ButtonNext variant="contained">Contained</ButtonNext> - </> - ); -}; - -export const Clickable = () => { - const [isSelected, setIsSelected] = useState(false); - return ( - <> - <Text>Click Me!</Text> - <ButtonNext - variant={isSelected ? 'outlined' : 'contained'} - onClick={() => setIsSelected(!isSelected)} - > - {isSelected ? 'OFF' : 'ON'} - </ButtonNext> - </> - ); -}; - -export const Pill = () => { - return ( - <> - <ButtonNext pill>pill</ButtonNext> - <ButtonNext>no pill</ButtonNext> - </> - ); -}; - -export const Size = () => { - return ( - <> - <ButtonNext size="xs">sm</ButtonNext> - <ButtonNext size="sm">sm</ButtonNext> - <ButtonNext size="md">md</ButtonNext> - <ButtonNext size="lg">lg</ButtonNext> - <ButtonNext size="xl">xl</ButtonNext> - <ButtonNext size="xxl">xxl</ButtonNext> - </> - ); -}; - -export const Colors = () => { - return ( - <> - <Box> - <ButtonNext variant="contained" color="primary"> - primary - </ButtonNext> - <ButtonNext variant="outlined" color="primary"> - primary - </ButtonNext> - <ButtonNext variant="text" color="primary"> - primary - </ButtonNext> - </Box> - <Box> - <ButtonNext variant="contained" color="secondary"> - secondary - </ButtonNext> - <ButtonNext variant="outlined" color="secondary"> - secondary - </ButtonNext> - <ButtonNext variant="text" color="secondary"> - secondary - </ButtonNext> - </Box> - <Box> - <ButtonNext variant="contained" color="info"> - info - </ButtonNext> - <ButtonNext variant="outlined" color="info"> - info - </ButtonNext> - <ButtonNext variant="text" color="info"> - info - </ButtonNext> - </Box> - <Box> - <ButtonNext variant="contained" color="success"> - success - </ButtonNext> - <ButtonNext variant="outlined" color="success"> - success - </ButtonNext> - <ButtonNext variant="text" color="success"> - success - </ButtonNext> - </Box> - <Box> - <ButtonNext variant="contained" color="warning"> - warning - </ButtonNext> - <ButtonNext variant="outlined" color="warning"> - warning - </ButtonNext> - <ButtonNext variant="text" color="warning"> - warning - </ButtonNext> - </Box> - <Box> - <ButtonNext variant="contained" color="error"> - error - </ButtonNext> - <ButtonNext variant="outlined" color="error"> - error - </ButtonNext> - <ButtonNext variant="text" color="error"> - error - </ButtonNext> - </Box> - <Box> - <ButtonNext variant="contained" color="dark"> - dark - </ButtonNext> - <ButtonNext variant="outlined" color="dark"> - dark - </ButtonNext> - <ButtonNext variant="text" color="dark"> - dark - </ButtonNext> - </Box> - <Box> - <ButtonNext variant="contained" color="light"> - light - </ButtonNext> - <ButtonNext variant="outlined" color="light"> - light - </ButtonNext> - <ButtonNext variant="text" color="light"> - light - </ButtonNext> - </Box> - <ButtonNext>None</ButtonNext> - </> - ); -}; - -export const Disabled = () => { - return <ButtonNext disabled>사용할 수 없는 버튼</ButtonNext>; -}; - -export const NoTheme = () => { - return <ButtonNext noTheme>테마가 모두 사라져버린 버튼</ButtonNext>; -}; - -export const FullWidth = () => { - return <ButtonNext fullWidth>길쭉이</ButtonNext>; -}; - -export const FullWidthExample = () => { - return ( - <> - <Box mb={5}> - <Text variant="title">with fullWidth</Text> - <FlexBox nowrap> - <ButtonNext color="error" fullWidth> - 취소 - </ButtonNext> - <ButtonNext color="success" fullWidth> - 제출 - </ButtonNext> - </FlexBox> - </Box> - <Box> - <Text variant="title">without fullWidth</Text> - <FlexBox nowrap> - <ButtonNext color="error">취소</ButtonNext> - <ButtonNext color="success">제출</ButtonNext> - </FlexBox> - </Box> - </> - ); -}; diff --git a/src/components/common/FlexBox/FlexBox.stories.tsx b/src/components/common/FlexBox/FlexBox.stories.tsx deleted file mode 100644 index f46e62be..00000000 --- a/src/components/common/FlexBox/FlexBox.stories.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import type { Meta } from '@storybook/react'; -import styled from 'styled-components'; - -import { FLEX_BOX_ITEM_POSITION } from '@common/FlexBox/FlexBox.style'; - -import type { FlexBoxProps } from './FlexBox'; -import FlexBox from './FlexBox'; - -const Box = styled.div` - width: 30%; - padding: 1rem; - border: 0.15rem solid #000; - border-radius: 0.8rem; -`; - -const Boxes = (tag?: 'li') => { - return ( - <> - <Box as={tag}>1 박스</Box> - <Box as={tag}>2 박스</Box> - <Box as={tag}>3 박스</Box> - <Box as={tag}>1 박스</Box> - <Box as={tag}>2 박스</Box> - <Box as={tag}>3 박스</Box> - </> - ); -}; - -const meta = { - title: 'Components/FlexBox', - component: FlexBox, - tags: ['autodocs'], - args: { - tag: 'ul', - width: '100%', - height: 24, - justifyContent: 'center', - alignContent: 'center', - outlined: true, - direction: 'row', - nowrap: false, - children: Boxes('li'), - }, - argTypes: { - tag: { - description: '태그명(ex. ul, section)을 입력해 플렉스 컨테이너의 태그를 바꿀 수 있습니다.', - }, - width: { - description: - '숫자를 입력하면 `입력한 숫자 x 10px` 만큼 플렉스 박스 너비가 늘어납니다.<br> 단위를 포함해 문자(ex. "100%")로 입력하면 원하는 만큼 플렉스 박스 너비가 늘어납니다.', - }, - height: { - description: - '숫자를 입력하면 `입력한 숫자 x 10px` 플렉스 박스 높이가 늘어납니다.<br> 단위를 포함해 문자(ex. "100%")로 입력하면 원하는 만큼 플렉스 박스 높이가 늘어납니다.', - }, - justifyContent: { - options: Object.keys({ ...FLEX_BOX_ITEM_POSITION, none: undefined }), - control: { - type: 'select', - }, - description: - '플렉스 컨테이너 안에 있는 아이템의 위치를 조절할 수 있습니다.<br>- direction이 row일 경우: 수평을 기준으로 아이템이 이동합니다.<br>- direction이 column일 경우: 수직을 기준으로 아이템이 이동합니다.', - }, - alignContent: { - options: Object.keys({ ...FLEX_BOX_ITEM_POSITION, none: undefined }), - control: { - type: 'select', - }, - description: - '플렉스 컨테이너 안에 있는 아이템의 위치를 조절할 수 있습니다.<br>- direction이 row일 경우: 수직을 기준으로 아이템이 이동합니다.<br>- direction이 column일 경우: 수평을 기준으로 아이템이 이동합니다.<br> ❗`wrap`(noWrap이 false)일 때만 사용 가능합니다.', - }, - alignItems: { - options: Object.keys({ ...FLEX_BOX_ITEM_POSITION, none: undefined }), - control: { - type: 'select', - }, - description: - '플렉스 컨테이너 안에 있는 아이템의 위치를 조절할 수 있습니다.<br>- direction이 row일 경우: 수직을 기준으로 아이템이 이동합니다.<br>- direction이 column일 경우: 수평을 기준으로 아이템이 이동합니다.', - }, - noRadius: { - options: { none: false, all: 'all', top: 'top', bottom: 'bottom' }, - control: { - type: 'select', - }, - description: '특정 방향의 radius 속성을 제거할 수 있습니다.', - }, - outlined: { - control: { - type: 'boolean', - }, - description: 'true: 플렉스 컨테이너에 검은색 테두리가 생깁니다.', - }, - background: { - control: { - type: 'color', - }, - description: '선택한 색상에 따라 플렉스 컨테이너의 배경색이 변합니다.', - }, - direction: { - description: `row: 플렉스 컨테이너 안 아이템이 수직 방향으로 정렬됩니다. 기본값입니다.<br>column: 플렉스 컨테이너 안 아이템이 수직 방향으로 정렬됩니다.`, - }, - nowrap: { - description: - 'true: 플렉스 컨테이너, 아이템 크기에 상관없이 무조건 한 줄로 정렬합니다. 아이템의 width, height가 정해져 있지 않다면 플렉스 컨테이너의 크기만큼 늘어납니다.', - }, - gap: { - description: - '숫자를 입력해 플렉스 컨테이너 안 아이템 간의 간격을 조절할 수 있습니다. `입력한 숫자 x 4px` 만큼 간격이 늘어납니다.', - }, - rowGap: { - description: - '숫자를 입력해 플렉스 컨테이너 안 아이템 간의 행 높이를 조절할 수 있습니다. `입력한 숫자 x 4px` 만큼 간격이 늘어납니다.<br>❗gap이 적용되어 있으면(undefined가 아니면) row gap은 적용되지 않습니다.', - }, - columnGap: { - description: - '숫자를 입력해 플렉스 컨테이너 안 아이템 간의 열 너비를 조절할 수 있습니다. `입력한 숫자 x 4px` 만큼 간격이 늘어납니다.<br>❗gap이 적용되어 있으면(undefined가 아니면) column gap은 적용되지 않습니다.', - }, - children: { - table: { - disable: true, - }, - }, - css: { - description: '원하는 css를 적용할 수 있습니다.', - }, - }, -} satisfies Meta<typeof FlexBox>; - -export default meta; - -export const Default = (args: FlexBoxProps) => { - return <FlexBox {...args} />; -}; - -export const JustifyContent = () => { - return ( - <FlexBox nowrap columnGap={4} justifyContent="between"> - <FlexBox direction="column" rowGap={5}> - <FlexBox outlined justifyContent="start" height={10}> - {Boxes()} - </FlexBox> - <FlexBox outlined justifyContent="center" height={10}> - {Boxes()} - </FlexBox> - <FlexBox outlined justifyContent="end" height={10}> - {Boxes()} - </FlexBox> - <FlexBox outlined justifyContent="between" height={10}> - {Boxes()} - </FlexBox> - </FlexBox> - <FlexBox outlined direction="column" justifyContent="start" width={12}> - {Boxes()} - </FlexBox> - <FlexBox outlined direction="column" justifyContent="center" width={12}> - {Boxes()} - </FlexBox> - <FlexBox outlined direction="column" justifyContent="end" width={12}> - {Boxes()} - </FlexBox> - <FlexBox outlined direction="column" justifyContent="between" width={12}> - {Boxes()} - </FlexBox> - </FlexBox> - ); -}; - -export const AlignItems = () => { - return ( - <FlexBox nowrap columnGap={4} justifyContent="between"> - <FlexBox direction="column" rowGap={5}> - <FlexBox outlined alignContent="start" height={10}> - {Boxes()} - </FlexBox> - <FlexBox outlined alignContent="center" height={10}> - {Boxes()} - </FlexBox> - <FlexBox outlined alignContent="end" height={10}> - {Boxes()} - </FlexBox> - <FlexBox outlined alignContent="between" height={10}> - {Boxes()} - </FlexBox> - </FlexBox> - <FlexBox outlined direction="column" alignContent="start" width={12} height={26}> - {Boxes()} - </FlexBox> - <FlexBox outlined direction="column" alignContent="center" width={12} height={26}> - {Boxes()} - </FlexBox> - <FlexBox outlined direction="column" alignContent="end" width={12} height={26}> - {Boxes()} - </FlexBox> - <FlexBox outlined direction="column" alignContent="between" width={12} height={26}> - {Boxes()} - </FlexBox> - </FlexBox> - ); -}; - -export const Gap = () => { - return ( - <Container> - <FlexBox justifyContent="center" rowGap={4} columnGap={8}> - {Boxes()} - </FlexBox> - </Container> - ); -}; - -const Container = styled.div` - margin-top: 20px; - - &:first-child { - margin-top: 0; - } - - & > button { - margin-right: 20px; - } -`; diff --git a/src/components/common/List/List.stories.tsx b/src/components/common/List/List.stories.tsx deleted file mode 100644 index 49ca1a72..00000000 --- a/src/components/common/List/List.stories.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import ListItem from '../ListItem'; -import Text from '../Text'; -import type { ListProps } from './List'; -import List from './List'; - -const meta = { - title: 'Components/List', - component: List, - tags: ['autodocs'], - args: { - children: 'List', - p: 0, - border: false, - }, - argTypes: { - children: { - control: { - type: 'text', - }, - description: '리스트의 하위 요소는 `<ListItem/>` 컴포넌트가 와야합니다.', - }, - p: { - control: { - type: 'number', - }, - description: 'padding을 줄 수 있습니다. 숫자 * 0.4rem 만큼 적용됩니다.', - }, - border: { - control: { - type: 'boolean', - }, - description: 'border를 기본적으로 줄 수 있습니다.', - }, - }, -} satisfies Meta<typeof List>; - -export default meta; - -export const Default = (args: ListProps) => { - return <List {...args} />; -}; - -export const ListWithoutPadding = () => { - return ( - <List css={{ backgroundColor: 'yellow' }}> - <ListItem css={{ backgroundColor: 'red' }}> - <Text>패딩이 없는</Text> - </ListItem> - <ListItem css={{ backgroundColor: 'orange' }}> - <Text>리스트라니</Text> - </ListItem> - </List> - ); -}; - -export const ListWithPadding = () => { - return ( - <List p={4} css={{ backgroundColor: 'yellow' }}> - <ListItem css={{ backgroundColor: 'red' }}> - <Text>패딩이 있는</Text> - </ListItem> - <ListItem css={{ backgroundColor: 'orange' }}> - <Text>리스트라니</Text> - </ListItem> - </List> - ); -}; - -export const ListWithBorder = () => { - return ( - <List p={4} border> - <ListItem css={{ backgroundColor: 'red' }}> - <Text>패딩이 있는</Text> - </ListItem> - <ListItem css={{ backgroundColor: 'orange' }}> - <Text>리스트라니</Text> - </ListItem> - </List> - ); -}; diff --git a/src/components/common/ListItem/ListItem.stories.tsx b/src/components/common/ListItem/ListItem.stories.tsx deleted file mode 100644 index d2aea42b..00000000 --- a/src/components/common/ListItem/ListItem.stories.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import List from '../List'; -import Text from '../Text'; -import type { ListItemProps } from './ListItem'; -import ListItem from './index'; - -const meta = { - title: 'Components/ListItem', - component: ListItem, - tags: ['autodocs'], - args: { - children: 'ListItem', - divider: false, - NoLastDivider: false, - }, - argTypes: { - children: { - control: { - type: 'text', - }, - description: '이 컴포넌트는 반드시 `<List/>`로 감싸야 합니다.', - }, - divider: { - description: 'true: 하단에 밑줄을 그을 수 있습니다.', - }, - NoLastDivider: { - description: 'true: 마지막 리스트 아이템의 하단 밑줄을 제거할 수 있습니다.', - }, - }, -} satisfies Meta<typeof ListItem>; - -export default meta; - -export const Default = (args: ListItemProps) => { - return ( - <List> - <ListItem {...args} /> - <ListItem {...args} /> - </List> - ); -}; - -export const Menu = () => { - return ( - <div style={{ width: '150px' }}> - <List border> - <ListItem> - <Text variant="body">메뉴1</Text> - </ListItem> - <ListItem> - <Text variant="body">메뉴2</Text> - </ListItem> - <ListItem divider> - <Text variant="body">메뉴3</Text> - </ListItem> - <ListItem> - <Text variant="body">로그아웃</Text> - </ListItem> - </List> - </div> - ); -}; diff --git a/src/components/common/Loader/Loader.stories.tsx b/src/components/common/Loader/Loader.stories.tsx deleted file mode 100644 index 82423361..00000000 --- a/src/components/common/Loader/Loader.stories.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import type { LoaderProps } from './Loader'; -import Loader from './index'; - -const meta = { - title: 'Components/Loader', - component: Loader, - tags: ['autodocs'], - args: { - size: 'xs', - }, - argTypes: { - size: { - options: { none: false, xs: 'xs', sm: 'sm', md: 'md', lg: 'lg', xl: 'xl', xxl: 'xxl' }, - control: { - type: 'select', - }, - description: - '사이즈를 부여할 수 있습니다. Size Props이외에도 필요에 따라 수동으로도 제어할 수 있습니다.', - }, - }, -} satisfies Meta<typeof Loader>; - -export default meta; - -export const Default = (args: LoaderProps) => { - return <Loader {...args} />; -}; -export const Sizes = () => { - return ( - <> - <Loader /> - <Loader size="xs" /> - <Loader size="sm" /> - <Loader size="md" /> - <Loader size="lg" /> - <Loader size="xl" /> - <Loader size="xxl" /> - <Loader size="100px" /> - </> - ); -}; diff --git a/src/components/common/Modal/Modal.stories.tsx b/src/components/common/Modal/Modal.stories.tsx deleted file mode 100644 index 319e9f67..00000000 --- a/src/components/common/Modal/Modal.stories.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import { useState } from 'react'; - -import Button from '../Button'; -import Text from '../Text'; -import Modal from './Modal'; - -const meta = { - title: 'Components/Modal', - component: Modal, - tags: ['autodocs'], -} satisfies Meta<typeof Modal>; - -export default meta; - -export const Default = () => { - const [isModalOpen, setIsModalOpen] = useState(false); - return ( - <> - <Button outlined size="md" onClick={() => setIsModalOpen(true)}> - 모달 열기 - </Button> - <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}> - <Text>버튼으로 모달을 열어봤어요</Text> - </Modal> - </> - ); -}; - -export const StaticBackdropModal = () => { - const [isModalOpen, setIsModalOpen] = useState(false); - return ( - <> - <Button outlined size="md" onClick={() => setIsModalOpen(true)}> - 모달 열기 - </Button> - <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} staticBackdrop> - <Text>백 드롭을 눌러도 꺼지지 않아요.</Text> - </Modal> - </> - ); -}; - -export const LongModal = () => { - const [isModalOpen, setIsModalOpen] = useState(false); - return ( - <> - <Button outlined size="md" onClick={() => setIsModalOpen(true)}> - 모달 열기 - </Button> - <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}> - <Text variant={'body'}> - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc euismod ultrices elit vitae - pharetra. Vestibulum volutpat molestie viverra. Pellentesque libero mauris, tristique et - tortor luctus, volutpat aliquet urna. Nunc ut urna enim. Morbi maximus, mi ac sollicitudin - fermentum, massa nunc dapibus est, in pellentesque lacus est in massa. Curabitur et - elementum metus, non hendrerit est. Curabitur venenatis id leo in dapibus. Duis in ipsum - quis enim aliquam ornare. Cras in elementum augue. Donec tincidunt nisi nec neque maximus - eleifend. Donec finibus vitae magna ut volutpat. Mauris vitae finibus eros, nec - sollicitudin mi. Cras vitae nisi vel mi consequat sagittis. Nulla eget arcu vel velit - sodales venenatis in eu arcu. Duis et cursus nisl, ac fermentum enim. Ut posuere bibendum - ligula eu sagittis. Curabitur imperdiet rhoncus leo, nec faucibus eros tempus vel. - Pellentesque ut eros in mi porta ultricies. Mauris viverra dolor sit amet enim elementum, - eu consectetur dui interdum. Donec in malesuada nisi, vitae congue lorem. Sed lacinia ante - arcu, quis hendrerit mauris condimentum ac. Pellentesque non dapibus justo, sit amet - sollicitudin nibh. Quisque vitae lorem sed lorem rutrum elementum sed sed tortor. Proin - fermentum tellus sed iaculis aliquam. Suspendisse in quam non dui varius dictum sit amet - et nisi. In facilisis neque arcu, a mattis felis aliquam non. Sed id nisl non tortor - placerat interdum et sit amet ipsum. Aliquam volutpat sed nisl sit amet blandit. Ut sit - amet lacus nibh. Aliquam tristique, tortor a bibendum mollis, nunc magna dictum purus, - venenatis mattis orci quam in lorem. Fusce suscipit pretium nunc, id egestas magna dapibus - sed. Nam quis felis quis mauris condimentum consectetur tristique id lacus. Aliquam sed - dui vel nulla consequat iaculis. Praesent feugiat quam in accumsan laoreet. Aliquam cursus - arcu neque, porta fringilla urna suscipit eleifend. Ut rutrum erat eu mauris eleifend - ultricies vitae ac tortor. Suspendisse consequat aliquet mi imperdiet gravida. Vivamus - iaculis urna mauris, ac efficitur ligula aliquet suscipit. Mauris accumsan laoreet nisi, - eu suscipit mauris interdum imperdiet. Donec viverra libero sit amet leo mollis, eget - pulvinar ligula lobortis. Nullam nec viverra lorem. Duis faucibus odio enim, vel finibus - neque sollicitudin a. Phasellus venenatis viverra ex, ac tempor ante. In egestas erat vel - nisl dictum ornare. Aliquam ultricies purus turpis, a faucibus quam feugiat vel. Duis at - elementum sem. Proin condimentum, quam eu consequat posuere, leo nibh commodo eros, at - suscipit diam diam non urna. Ut mollis ultricies diam, a tempus nisl mollis at. - Suspendisse potenti. Nulla blandit rhoncus consectetur. Nam viverra velit ut aliquam - iaculis. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus - mus. Donec tincidunt nunc erat, ac iaculis metus varius a. Duis feugiat semper blandit. - Curabitur lobortis dignissim fermentum. Nunc nec nisl dui. Praesent tincidunt elit non - hendrerit accumsan. Pellentesque habitant morbi tristique senectus et netus et malesuada - fames ac turpis egestas. Nullam quis enim sed lorem tempus interdum. Nunc rhoncus lacinia - felis non scelerisque. Pellentesque iaculis fringilla lorem, et mollis dui sodales quis. - Vivamus ornare sapien eu ullamcorper porttitor. Duis hendrerit nibh odio, bibendum - placerat ipsum commodo tempor. Nam turpis tortor, sollicitudin in pretium a, consequat a - magna. Nam bibendum lacinia nulla id vestibulum. Curabitur id enim leo. Maecenas - consectetur, lorem eu iaculis dapibus, ex diam lobortis risus, eget lobortis dolor ante - rhoncus ex. Nullam quis nibh sed nunc gravida volutpat a et ligula. Sed sit amet venenatis - purus. Nullam pulvinar leo sit amet augue fermentum fermentum. Ut vel interdum mi. Duis - sapien nisl, consequat ut nisi eu, dignissim auctor odio. Nullam elementum lacus vitae - scelerisque fringilla. Morbi ultricies pellentesque vulputate. Sed laoreet lacus non elit - condimentum, eget tristique dui imperdiet. Ut dictum ex eu convallis aliquam. Nulla - tristique, nibh a gravida congue, nisi ligula faucibus urna, ut mollis dui lectus quis - massa. Fusce vel tellus sit amet lacus molestie egestas vel nec metus. Nullam facilisis - euismod sollicitudin. Ut vel vulputate nisi. Aliquam sed convallis arcu. Duis eros velit, - molestie vel turpis pellentesque, sollicitudin egestas enim. Donec faucibus finibus - egestas. Curabitur molestie velit vel viverra semper. Maecenas non lacus varius, semper - augue sed, fringilla lorem. Vestibulum mattis egestas porta. Nullam at leo molestie, - placerat magna blandit, sollicitudin eros. Aliquam lorem nisl, suscipit nec nisi vitae, - ultrices pretium est. Pellentesque non est ultricies, tincidunt orci ac, sagittis arcu. - Maecenas eget odio ut velit interdum tristique eu quis libero. Suspendisse efficitur diam - sem, lacinia mattis magna auctor tincidunt. Morbi rhoncus nec elit ac molestie. Vestibulum - facilisis arcu quis accumsan aliquet. Cras ullamcorper laoreet tempor. Nam ipsum leo, - eleifend eget lectus nec, fermentum facilisis lacus. Ut egestas neque ac nulla ultricies - semper. Cras condimentum, nisi sed euismod commodo, justo orci iaculis metus, vitae - porttitor ipsum turpis at tortor. Integer fermentum vestibulum mauris quis venenatis. - Integer in sollicitudin est. Vivamus nec augue vel ante sagittis mattis. Duis nulla nibh, - imperdiet in porttitor vitae, hendrerit aliquet sem. Proin blandit vehicula tellus ac - cursus. Proin nec euismod justo. Ut nec euismod nunc. Quisque fermentum tristique lorem, - sit amet lacinia elit tempor eget. Proin dignissim purus eget odio elementum facilisis. - Mauris aliquam, dolor at tincidunt volutpat, felis velit sollicitudin nunc, a porttitor - ligula dui tempor ex. Donec at turpis vitae elit aliquam facilisis convallis in neque. - Fusce volutpat eget lacus vulputate feugiat. Mauris vel turpis hendrerit lorem lobortis - dictum ultrices dapibus orci. Suspendisse euismod vehicula volutpat. Nulla malesuada - faucibus felis ac finibus. Class aptent taciti sociosqu ad litora torquent per conubia - nostra, per inceptos himenaeos. Integer fermentum a elit quis interdum. Maecenas at neque - ultrices, dapibus sem sed, faucibus urna. Sed sagittis nulla sed ultrices consequat. Nunc - auctor, purus ultricies condimentum pretium, ante tortor auctor massa, id ornare tortor - diam quis lacus. Cras dictum orci a arcu dignissim pulvinar. Donec ac lectus ac velit - volutpat lobortis. Curabitur et laoreet risus. Mauris efficitur volutpat blandit. - Phasellus pharetra ac mi ultrices vehicula. Nullam cursus neque sed est ultricies, vel - iaculis metus pellentesque. Curabitur sit amet risus convallis nisi faucibus pulvinar eget - sit amet nunc. Pellentesque habitant morbi tristique senectus et netus et malesuada fames - ac turpis egestas. Aliquam sit amet euismod massa, non cursus odio. Vestibulum arcu nulla, - accumsan vel ligula vitae, tempus suscipit nulla. Nullam at bibendum dui, interdum - ultricies tellus. Nullam vel lacus et sapien sodales lacinia. In posuere enim nec egestas - imperdiet. Maecenas in egestas purus. Ut hendrerit suscipit eros sed ornare. Nullam a mi - et justo dictum aliquam. Quisque a laoreet massa. Pellentesque laoreet risus ut augue - pulvinar faucibus. Maecenas eu dignissim arcu, quis egestas magna. Sed volutpat est leo, - eu sollicitudin metus aliquam et. In purus purus, vulputate quis magna eu, sodales maximus - nisl. Phasellus fringilla ut lorem volutpat sodales. Nam fermentum auctor dolor sed - blandit. Praesent consectetur ligula in massa dictum, quis euismod nisi mattis. Quisque - tincidunt mollis tempus. Ut vehicula leo diam, nec sollicitudin nunc maximus at. Nunc - turpis sapien, faucibus vitae posuere in, malesuada id massa. Ut laoreet dictum velit ac - sagittis. Integer ac blandit felis, ac porta nulla. Cras placerat efficitur lacus vitae - egestas. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia - curae; Aliquam sed maximus justo. Sed viverra posuere nisl vitae facilisis. Sed interdum - massa nulla, malesuada finibus nisl elementum lobortis. In finibus finibus odio ut - porttitor. Donec pellentesque massa non faucibus imperdiet. Donec eget turpis cursus, - commodo quam quis, dapibus velit. Maecenas hendrerit arcu sed tincidunt tincidunt. - Phasellus lobortis ultricies augue, et volutpat arcu feugiat quis. Integer maximus, justo - at ultrices sagittis, tortor nibh accumsan sapien, lacinia rhoncus elit elit ac lorem. In - at fermentum mauris. In maximus consectetur risus, quis posuere lectus lacinia et. - Curabitur suscipit ex nec dui vestibulum, sit amet placerat velit varius. Donec ultrices, - nisl sed pellentesque aliquet, mi sem porta ipsum, a placerat nulla lorem at nunc. Nullam - et tristique massa. Praesent a nisi vehicula erat consectetur interdum sodales non magna. - Vestibulum faucibus, nunc id suscipit pulvinar, purus nisl commodo mi, non posuere lorem - est quis enim. Vivamus vel elit malesuada turpis consequat aliquet ac et massa. Fusce - iaculis eget massa eget convallis. Vestibulum consequat sollicitudin leo, in lobortis - sapien consequat eu. In congue at erat eu convallis. Curabitur id interdum ipsum. Nulla - varius gravida accumsan. Nam lobortis ornare tincidunt. Aliquam rhoncus purus id lacus - faucibus, ac congue justo pellentesque. Cras luctus mauris non nisl posuere pulvinar. - Pellentesque feugiat, dui vitae euismod euismod, urna nulla eleifend lectus, a pretium - risus est vitae augue. Integer pulvinar metus a magna bibendum, at egestas ex posuere. - Quisque in sodales nulla, ac vestibulum enim. Praesent sed urna purus. Nullam non mollis - neque, ut venenatis ligula. Nulla facilisi. Nulla accumsan pharetra venenatis. Proin - mollis suscipit massa, id viverra augue fermentum sit amet. Aliquam malesuada neque sit - amet lorem varius tincidunt. Quisque elit dolor, finibus vestibulum metus non, convallis - blandit eros. Sed gravida nec dolor quis euismod. Quisque nec sem orci. Sed varius - faucibus justo in accumsan. Nam quis est vel sapien vulputate feugiat semper a augue. - Suspendisse potenti. Interdum et malesuada fames ac ante ipsum primis in faucibus. - Curabitur id pretium magna. Aenean a est justo. Vestibulum diam diam, porttitor vitae - tellus non, fermentum cursus turpis. Vivamus eu eros nisi. Suspendisse hendrerit risus a - ex egestas, eu consectetur ante fermentum. Ut vel justo sit amet odio lacinia pulvinar sed - ac nibh. Vivamus vulputate ipsum ante, eget iaculis ex tincidunt ac. Sed semper tellus - interdum tristique tempor. Proin massa arcu, ullamcorper interdum nisl sed, aliquam - commodo ligula. Morbi vel metus fermentum, molestie quam ac, ullamcorper ante. Nam - fermentum metus vel egestas molestie. In eget lacus gravida ligula eleifend fringilla sit - amet quis libero. Nunc vehicula vulputate ante, vitae elementum elit ullamcorper quis. - Donec vel felis id urna ultricies viverra. Proin sed diam vel ex vehicula tempus at quis - risus. Donec mollis nisi sed leo tincidunt, non faucibus enim mattis. Ut aliquam fermentum - neque, a porttitor ex consequat et. Ut congue libero volutpat, iaculis mauris in, - consequat diam. Vivamus vel ullamcorper lectus. Aliquam lectus felis, sollicitudin sed - faucibus vitae, lacinia id sapien. Aliquam lobortis aliquet metus, condimentum ornare mi - vestibulum at. Nullam pulvinar ac est vel egestas. Quisque varius ex sem, sit amet tempus - metus rhoncus id. Aliquam suscipit nibh nulla, ut volutpat velit feugiat in. Mauris - aliquam neque vel mi vehicula euismod. Vivamus vehicula turpis sed nulla malesuada tempor. - Maecenas interdum scelerisque accumsan. Duis eu mattis nunc, id lacinia tortor. Maecenas - hendrerit condimentum libero a scelerisque. Donec eros enim, volutpat nec fermentum id, - facilisis fringilla erat. Donec vel ipsum lobortis, porttitor velit sed, pulvinar justo. - Proin interdum magna accumsan ligula porttitor commodo. Curabitur sollicitudin sed orci ac - ullamcorper. Nullam non felis at mauris porttitor varius a vel velit. Integer id nunc - turpis. Integer tempus odio at enim efficitur, sed maximus arcu feugiat. Interdum et - malesuada fames ac ante ipsum primis in faucibus. Morbi laoreet mauris sed malesuada - ultrices. Vivamus lacinia lacus nec velit elementum condimentum. Sed eleifend tristique - dolor quis egestas. Proin vehicula volutpat nisi et rutrum. Nullam venenatis molestie - viverra. Maecenas felis neque, luctus sit amet tincidunt vitae, euismod sed sem. Nunc - volutpat mollis urna, at laoreet odio viverra non. Class aptent taciti sociosqu ad litora - torquent per conubia nostra, per inceptos himenaeos. Donec sed lectus lorem. Proin - volutpat diam nibh, eu lacinia neque euismod a. - </Text> - </Modal> - </> - ); -}; diff --git a/src/components/common/SelectBox/SelectBox.stories.tsx b/src/components/common/SelectBox/SelectBox.stories.tsx deleted file mode 100644 index 453b9159..00000000 --- a/src/components/common/SelectBox/SelectBox.stories.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import styled from 'styled-components'; - -import { useState } from 'react'; - -import Text from '@common/Text'; - -import SelectBox from './SelectBox'; - -const meta = { - title: 'Components/SelectBox', - component: SelectBox, - tags: ['autodocs'], - args: {}, - argTypes: {}, -}; - -export default meta; - -const Container = styled.div` - width: 300px; - height: 1000px; - margin: 20px auto; -`; - -const options = [ - { value: 'IONIC5', label: '아이오닉5' }, - { value: 'TSLA3', label: '테슬라3' }, - { value: 'tajiri', label: '지리자동차' }, -]; - -export const Default = () => { - const [selectedValue, setSelectedValue] = useState(''); - - const handleSelectChange = (value: string) => { - setSelectedValue(value); - }; - return ( - <Container> - <Text>selectedValue: {selectedValue}</Text> - <SelectBox options={options} onChange={handleSelectChange} value={selectedValue} /> - </Container> - ); -}; diff --git a/src/components/common/Skeleton/Skeleton.stories.tsx b/src/components/common/Skeleton/Skeleton.stories.tsx deleted file mode 100644 index 27d74e6d..00000000 --- a/src/components/common/Skeleton/Skeleton.stories.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import Box from '@common/Box'; - -import type { SkeletonProps } from './Skeleton'; -import Skeleton from './Skeleton'; - -const meta = { - title: 'Components/Skeleton', - component: Skeleton, - tags: ['autodocs'], - args: { - height: '100px', - width: '250px', - borderRadius: '6px', - }, - argTypes: { - height: { - control: { - type: 'text', - }, - description: '높이를 지정할 수 있습니다. 기본 값은 10px 입니다.', - }, - width: { - control: { - type: 'text', - }, - description: '높이를 지정할 수 있습니다. 기본 값은 100% 입니다.', - }, - borderRadius: { - control: { - type: 'text', - }, - description: '모서리의 둥근 정도를 설정할 수 있습니다. 기본 값은 6px 입니다.', - }, - }, -}; - -export default meta; - -export const Default = (args: SkeletonProps) => { - return <Skeleton {...args} />; -}; - -export const Example = () => { - return ( - <> - <Skeleton mb={2} /> - <Skeleton width="40rem" height="30rem" mb={2} /> - <Skeleton width="10rem" height="10rem" borderRadius="50%" /> - </> - ); -}; - -export const Spacing = () => { - return ( - <> - <Skeleton width="10rem" height="10rem" /> - <Skeleton width="10rem" height="10rem" my={10} /> - <Skeleton width="10rem" height="10rem" /> - </> - ); -}; diff --git a/src/components/common/Text/Text.stories.tsx b/src/components/common/Text/Text.stories.tsx deleted file mode 100644 index 3a63f4c6..00000000 --- a/src/components/common/Text/Text.stories.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import type { Meta } from '@storybook/react'; -import styled from 'styled-components'; - -import Text from './Text'; - -const meta = { - title: 'Components/Text', - component: Text, - tags: ['autodocs'], - args: { - tag: 'p', - variant: 'body', - align: 'left', - color: '#333', - lineClamp: 1, - children: 'You forget a thousand things every day. Make sure this is one of them.', - }, - argTypes: { - tag: { - description: - '태그명(ex. header, h2, span)을 입력해 텍스트 컴포넌트의 태그를 바꿀 수 있습니다.', - }, - children: { - control: { - type: 'text', - }, - description: '원하는 글자를 입력해 테스트를 할 수 있습니다.', - }, - variant: { - options: { - none: false, - h1: 'h1', - h2: 'h2', - h3: 'h3', - h4: 'h4', - h5: 'h5', - h6: 'h6', - title: 'title', - subtitle: 'subtitle', - label: 'label', - body: 'body', - caption: 'caption', - }, - control: { - type: 'select', - }, - description: '글자 크기 및 두께를 바꿀 수 있습니다. 기본값은 body입니다.', - }, - align: { - options: { - none: false, - center: 'center', - left: 'left', - right: 'right', - }, - control: { - type: 'select', - }, - description: '선택한 위치에 따라 글자가 정렬됩니다.', - }, - color: { - description: '선택한 색상에 따라 글씨색이 변합니다.', - }, - lineClamp: { - description: '블록 컨테이너 콘텐츠의 줄 수를 선택한 수만큼으로 제한할 수 있습니다.', - }, - fontSize: { - description: - '글자 크기를 직접 조절할 수 있습니다.<br>❗글자 크기를 직접 설정할 경우, variant 적용시에도 글자 크기는 변하지 않습니다.', - }, - weight: { - description: - '글자 두께를 직접 조절할 수 있습니다.<br>❗글자 두께를 직접 설정할 경우, variant 적용시에도 글자 두께는 변하지 않습니다.', - }, - lineHeight: { - control: { - type: 'text', - }, - description: '글자의 줄 간격을 조절할 수 있습니다.', - }, - }, -} satisfies Meta<typeof Text>; - -export default meta; - -interface Props { - children: string; - variant: - | 'h1' - | 'h2' - | 'h3' - | 'h4' - | 'h5' - | 'h6' - | 'title' - | 'subtitle' - | 'label' - | 'body' - | 'caption'; -} - -export const Default = (args: Props) => { - return ( - <> - <Text {...args} /> - <Text {...args} /> - </> - ); -}; - -export const Sizes = () => { - return ( - <> - <Text variant="h1">Heading 1</Text> - <Text variant="h2">Heading 2</Text> - <Text variant="h3">Heading 3</Text> - <Text variant="h4">Heading 4</Text> - <Text variant="h5">Heading 5</Text> - <Text variant="h6">Heading 6</Text> - <Text variant="title">Title</Text> - <Text variant="subtitle">Subtitle</Text> - <Text variant="label">Label</Text> - <Text variant="body">Body Text</Text> - <Text variant="caption">Caption Text</Text> - <Text>You forget a thousand things every day. Make sure this is one of them.</Text> - </> - ); -}; - -export const MarginBottom = () => { - return ( - <> - <Text variant="h1" mb={5}> - Heading 1 - </Text> - <Text variant="h1" mb={10}> - Heading 1 - </Text> - <Text variant="h1">Heading 1</Text> - </> - ); -}; - -export const LineClamp = () => { - return ( - <S.Container> - <Text lineClamp={1}> - You forget a thousand things every day. Make sure this is one of them. - </Text> - </S.Container> - ); -}; - -const S = { - Container: styled.div` - width: 30rem; - line-height: 1.5; - `, -}; diff --git a/src/components/common/TextField/TextField.stories.tsx b/src/components/common/TextField/TextField.stories.tsx deleted file mode 100644 index 58ccdc2b..00000000 --- a/src/components/common/TextField/TextField.stories.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import { useState } from 'react'; - -import Text from '../Text'; -import type { TextFieldProps } from './TextField'; -import TextField from './TextField'; - -const meta = { - title: 'Components/TextField', - component: TextField, - tags: ['autodocs'], - args: { - value: '아래 Control/Input 필드에서 저를 지워보세요', - width: 150, - label: 'Label', - supportingText: '도움말은 여기에 입력됩니다.', - }, - argTypes: { - label: { - control: { - type: 'text', - }, - description: 'input 내부에 표기할 기본 라벨 입니다.', - }, - value: { - control: { - type: 'text', - }, - description: '기존 input의 value 입니다. 반드시 문자열로 처리됩니다.', - }, - onChange: { - description: '기존 input의 onChange 입니다.', - }, - supportingText: { - description: '도움 메세지를 입력할 수 있습니다.', - }, - width: { - description: '너비 길이를 설정할 수 있습니다. 정수 * 0.4 rem 만큼의 길이가 설정됩니다.', - }, - cssForLabel: { - description: 'label의 CSS를 수동으로 지정할 수 있습니다.', - }, - cssForInput: { - description: 'input의 CSS를 수동으로 지정할 수 있습니다.', - }, - }, -} satisfies Meta<typeof TextField>; - -export default meta; - -export const Default = (args: TextFieldProps) => { - return <TextField {...args} />; -}; - -export const Label = () => { - return <TextField label="이름" />; -}; - -export const Value = () => { - const [value, setValue] = useState(''); - return ( - <> - <Text>value: {value}</Text> - <TextField - label="주소" - value={value} - onChange={(e) => { - setValue(e.target.value); - }} - /> - </> - ); -}; - -export const HelperText = () => { - const [value, setValue] = useState(''); - return ( - <> - <Text>value: {value}</Text> - <TextField - label="닉네임" - value={value} - onChange={(e) => { - setValue(e.target.value); - }} - supportingText={value.length < 2 && '닉네임은 2글자 이상 입력하셔야 합니다.'} - /> - </> - ); -}; - -export const Width = () => { - return <TextField label="가로가 긴 TextField" width={100} />; -}; - -export const FullWidth = () => { - return <TextField label="가로로 꽉찬 TextField" fullWidth />; -}; diff --git a/src/components/common/Toast/Toast.stories.tsx b/src/components/common/Toast/Toast.stories.tsx deleted file mode 100644 index 66fc2ff7..00000000 --- a/src/components/common/Toast/Toast.stories.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import type { Meta } from '@storybook/react'; -import { styled } from 'styled-components'; - -import { getToastColor } from '@common/Toast/Toast.style'; - -import { toastActions, toastListStore } from '../../../stores/layout/toastStore'; -import type { Color } from '../../../types'; -import { useExternalValue } from '../../../utils/external-state'; -import ButtonNext from '../ButtonNext'; -import Text from '../Text'; -import type { ToastProps } from './Toast'; -import Toast from './Toast'; - -const meta = { - title: 'Components/Toast', - component: Toast, - tags: ['autodocs'], - args: { - toastId: 0, - message: '사용자에게 보여줄 메시지를 입력하세요', - position: 'bottom-center', - color: 'success', - }, - argTypes: { - toastId: { - description: '토스트 고유의 id 입니다.', - }, - message: { - description: '원하는 글자를 입력해 테스트를 할 수 있습니다.', - }, - position: { - description: '선택한 위치에 따라 토스트가 나오는 방향을 선택할 수 있습니다.', - }, - color: { - description: '선택한 색상에 따라 토스트의 색상이 변합니다.', - }, - css: { - description: '원하는 css를 적용할 수 있습니다.', - }, - }, -} satisfies Meta<typeof Toast>; - -export default meta; - -export const Default = (args: ToastProps) => { - const toastItems = useExternalValue<ToastProps[]>(toastListStore); - const { showToast } = toastActions; - - const { message, position, color } = args; - - return ( - <> - <ButtonNext color="dark" onClick={() => showToast(message, color, position)}> - 나와라 토스트! - </ButtonNext> - <> - {toastItems.map((toastItem) => ( - <Toast key={toastItem.toastId} {...toastItem} /> - ))} - </> - </> - ); -}; - -export const Colors = () => { - return ( - <> - <Text variant="h5" mb={4}> - Primary - </Text> - <S.Toast color="primary">이삭 토스트</S.Toast> - <Text variant="h5" mb={4}> - Secondary - </Text> - <S.Toast color="secondary">이삭 토스트</S.Toast> - <Text variant="h5" mb={4}> - Success - </Text> - <S.Toast color="success">이삭 토스트</S.Toast> - <Text variant="h5" mb={4}> - Warning - </Text> - <S.Toast color="warning">이삭 토스트</S.Toast> - <Text variant="h5" mb={4}> - Error - </Text> - <S.Toast color="error">이삭 토스트</S.Toast> - <Text variant="h5" mb={4}> - Info - </Text> - <S.Toast color="info">이삭 토스트</S.Toast> - <Text variant="h5" mb={4}> - Light - </Text> - <S.Toast color="light">이삭 토스트</S.Toast> - <Text variant="h5" mb={4}> - Dark - </Text> - <S.Toast color="dark">이삭 토스트</S.Toast> - </> - ); -}; - -const S = { - Toast: styled.div<{ color: Color }>` - width: max-content; - max-width: 40rem; - padding: 1.2rem 2.4rem; - font-size: 1.5rem; - text-align: center; - word-break: keep-all; - line-height: 1.5; - border-radius: 28px; - font-weight: 500; - color: #fff; - margin-bottom: 2rem; - - &:last-child { - margin-bottom: 0; - } - - ${({ color }) => getToastColor(color)} - `, -}; diff --git a/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarker.stories.tsx b/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarker.stories.tsx deleted file mode 100644 index 9f9614d0..00000000 --- a/src/components/google-maps/marker/MaxDeltaAreaMarkerContainer/components/RegionMarker.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import type { RegionMarkerProps } from './RegionMarker'; -import RegionMarker from './RegionMarker'; - -const meta = { - title: 'UI/RegionMarker', - component: RegionMarker, - tags: ['autodocs'], - args: { - regionName: '서울특별시', - count: 2, - }, - argTypes: { - regionName: { - description: '지역명 입니다.', - }, - count: { - description: '특정 지역의 충전소 갯수입니다.', - }, - }, -} satisfies Meta<typeof RegionMarker>; - -export default meta; - -export const Default = (args: RegionMarkerProps) => { - return ( - <div style={{ width: 'fit-content' }}> - <RegionMarker {...args} /> - </div> - ); -}; diff --git a/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeine.stories.tsx b/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeine.stories.tsx deleted file mode 100644 index 0ff8b09f..00000000 --- a/src/components/google-maps/marker/SmallMediumDeltaAreaMarkerContainer/components/CarFfeineMarker/CarFfeine.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import type { StationSummary } from '@type'; - -import CarFfeineMarker from './CarFfeineMarker'; - -const meta = { - title: 'UI/CarFfeineMarker', - component: CarFfeineMarker, - tags: ['autodocs'], - args: { - availableCount: 2, - stationName: '카페인 충전소', - }, - argTypes: { - availableCount: { - description: - '이용 가능한 충전기 개수를 변경할 수 있습니다. 0개를 입력할 경우 색상이 변합니다.', - }, - stationName: { - description: '마커 위에 마우스를 올렸을 때 나오는 충전소 이름을 변경할 수 있습니다.', - }, - }, -} satisfies Meta<typeof CarFfeineMarker>; - -export default meta; - -export const Default = (args: StationSummary) => { - return <CarFfeineMarker {...args} />; -}; diff --git a/src/components/ui/Loading/Loading.stories.tsx b/src/components/ui/Loading/Loading.stories.tsx deleted file mode 100644 index 267a9cd4..00000000 --- a/src/components/ui/Loading/Loading.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import Loading from './Loading'; - -const meta = { - component: Loading, - tags: ['autodocs'], -} satisfies Meta<typeof Loading>; - -export default meta; - -export const Default = () => { - return <Loading />; -}; diff --git a/src/components/ui/Navigator/NavigationBar/NavigationBar.stories.tsx b/src/components/ui/Navigator/NavigationBar/NavigationBar.stories.tsx deleted file mode 100644 index 8d2e05c6..00000000 --- a/src/components/ui/Navigator/NavigationBar/NavigationBar.stories.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { ChevronLeftIcon } from '@heroicons/react/24/solid'; -import type { Meta } from '@storybook/react'; -import { styled } from 'styled-components'; - -import type { ReactElement } from 'react'; -import { useState } from 'react'; - -import Button from '@common/Button'; -import ButtonNext from '@common/ButtonNext'; - -import NavigationBar from './NavigationBar'; - -const meta = { - title: 'UI/NavigationBar', - component: NavigationBar, - tags: ['autodocs'], -} satisfies Meta<typeof NavigationBar>; - -export default meta; - -export const Default = () => { - const [basePanel, setBasePanel] = useState<ReactElement | null>(null); - const [lastPanel, setLastPanel] = useState<ReactElement | null>(null); - - const handleClosePanel = () => { - if (lastPanel !== null) { - setLastPanel(null); - } else { - setBasePanel(null); - } - }; - - return ( - <> - <NavigationBar> - <NavigationBar.Menu /> - <NavigationBar.BasePanel component={basePanel} /> - <NavigationBar.LastPanel component={lastPanel} /> - <Button variant="label" aria-label="검색창 닫기" onClick={handleClosePanel}> - <ChevronLeftIcon width="2.4rem" stroke="#9c9fa7" /> - </Button> - </NavigationBar> - <div style={{ display: 'flex', flexDirection: 'column', position: 'fixed', right: 0 }}> - <ButtonNext onClick={() => setBasePanel(<BaseContainerBlue />)}> - openBlueBasePanel - </ButtonNext> - <ButtonNext onClick={() => setBasePanel(<BaseContainerRed />)}>openRedBasePanel</ButtonNext> - <ButtonNext onClick={() => setBasePanel(<BaseContainer />)}>openWhiteBasePanel</ButtonNext> - <ButtonNext onClick={() => setLastPanel(<LastContainer />)}>openLastPanel</ButtonNext> - </div> - </> - ); -}; - -const BaseContainer = styled.div` - width: 34rem; - height: 100vh; - border: 1px solid lightgrey; - border-radius: 0; -`; - -const BaseContainerBlue = styled.div` - width: 34rem; - height: 100vh; - border: 1px solid lightgrey; - background-color: blue; -`; - -const BaseContainerRed = styled.div` - width: 34rem; - height: 100vh; - border: 1px solid lightgrey; - background-color: red; -`; - -const LastContainer = styled.div` - width: 34rem; - height: 100vh; - border: 1px solid lightgrey; - border-left: none; -`; diff --git a/src/components/ui/Navigator/ProfileMenu/ProfileMenu.stories.tsx b/src/components/ui/Navigator/ProfileMenu/ProfileMenu.stories.tsx deleted file mode 100644 index 4701a9b0..00000000 --- a/src/components/ui/Navigator/ProfileMenu/ProfileMenu.stories.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { ArrowRightOnRectangleIcon, PencilSquareIcon } from '@heroicons/react/24/outline'; -import type { Meta } from '@storybook/react'; -import { styled } from 'styled-components'; - -import Menus from './Menus'; -import ProfileMenu from './ProfileMenu'; - -const meta = { - title: 'UI/ProfileMenu', - component: Menus, - tags: ['autodocs'], -} satisfies Meta<typeof Menus>; - -export default meta; - -export const Default = () => { - return ( - <ProfileMenu - menus={[ - { - children: ( - <> - <PencilSquareIcon width="1.8rem" color="#333" /> 차량등록 - </> - ), - onClick: () => alert('차량등록'), - }, - { - children: ( - <> - <ArrowRightOnRectangleIcon width="1.8rem" color="#333" /> 로그아웃 - </> - ), - onClick: () => alert('로그아웃'), - }, - ]} - /> - ); -}; - -export const BigTrigger = () => { - return ( - <ProfileMenu - menus={[ - { - children: ( - <> - <PencilSquareIcon width="1.8rem" color="#333" /> 차량등록 - </> - ), - onClick: () => alert('차량등록'), - }, - { - children: ( - <> - <ArrowRightOnRectangleIcon width="1.8rem" color="#333" /> 로그아웃 - </> - ), - onClick: () => alert('로그아웃'), - }, - ]} - /> - ); -}; diff --git a/src/components/ui/modal/CarModal/CarModal.stories.tsx b/src/components/ui/modal/CarModal/CarModal.stories.tsx deleted file mode 100644 index 555e7978..00000000 --- a/src/components/ui/modal/CarModal/CarModal.stories.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import CarModal from './CarModal'; - -const meta = { - title: 'UI/CarModal', - component: CarModal, - tags: ['autodocs'], -} satisfies Meta<typeof CarModal>; - -export default meta; - -export const Default = () => { - return <CarModal />; -}; diff --git a/src/components/ui/modal/LoginModal/LoginModal.stories.tsx b/src/components/ui/modal/LoginModal/LoginModal.stories.tsx deleted file mode 100644 index bce55e4c..00000000 --- a/src/components/ui/modal/LoginModal/LoginModal.stories.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import LoginModal from './LoginModal'; - -const meta = { - title: 'UI/LoginModal', - component: LoginModal, - tags: ['autodocs'], -} satisfies Meta<typeof LoginModal>; - -export default meta; - -export const Default = () => { - return <LoginModal />; -}; From 8e747081bec6a8343dc25316f4c3e42ce111f8c1 Mon Sep 17 00:00:00 2001 From: gabrielyoon7 <gabrielyoon7@gmail.com> Date: Tue, 10 Oct 2023 16:53:12 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/modal/LoginModal/LoginModal.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/ui/modal/LoginModal/LoginModal.tsx b/src/components/ui/modal/LoginModal/LoginModal.tsx index 305041df..97ffa774 100644 --- a/src/components/ui/modal/LoginModal/LoginModal.tsx +++ b/src/components/ui/modal/LoginModal/LoginModal.tsx @@ -11,6 +11,7 @@ import Text from '@common/Text'; import GoogleLogo from '@assets/google-logo.svg'; import Logo from '@assets/logo-md.svg'; +import Image from 'next/image'; const LoginModal = () => { const handleLogin = () => { @@ -32,13 +33,13 @@ const LoginModal = () => { <XMarkIcon width={28} /> </Button> - <Logo /> + <Image src={Logo} alt="로고"/> <Text tag="h2" variant="h5" weight="regular" color="#333" mt={2}> 카페인 </Text> <GoogleLogin onClick={handleLogin}> - <GoogleLogo width="24" /> + <Image src={GoogleLogo} alt="구글 로고" width={24}/> <Text variant="label" weight="regular" color="#666"> 구글 로그인 </Text> From df447733963e8e463536f52914861cf425f76700 Mon Sep 17 00:00:00 2001 From: gabrielyoon7 <gabrielyoon7@gmail.com> Date: Tue, 10 Oct 2023 17:06:24 +0900 Subject: [PATCH 09/13] =?UTF-8?q?refactor:=20msw=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mocks/browser.ts | 5 - src/mocks/data.ts | 444 ------------------ src/mocks/handlers/car/carHandler.ts | 18 - src/mocks/handlers/index.ts | 27 -- src/mocks/handlers/login/loginHandlers.ts | 19 - src/mocks/handlers/memberHandlers.ts | 48 -- .../reports/stationReportHandlers.ts | 41 -- .../reviews/stationReviewHandlers.ts | 131 ------ .../station-details/stationDetailHandlers.ts | 18 - .../station-details/statisticsHandlers.ts | 36 -- .../station-filters/memberFilterHandlers.ts | 46 -- .../station-filters/serverFilterHandlers.ts | 19 - .../station-markers/stationHandlers.ts | 60 --- .../station-markers/stationMarkerHandlers.ts | 120 ----- src/mocks/handlers/stationSearchHandlers.ts | 24 - src/mocks/node.ts | 8 - 16 files changed, 1064 deletions(-) delete mode 100644 src/mocks/browser.ts delete mode 100644 src/mocks/data.ts delete mode 100644 src/mocks/handlers/car/carHandler.ts delete mode 100644 src/mocks/handlers/index.ts delete mode 100644 src/mocks/handlers/login/loginHandlers.ts delete mode 100644 src/mocks/handlers/memberHandlers.ts delete mode 100644 src/mocks/handlers/station-details/reports/stationReportHandlers.ts delete mode 100644 src/mocks/handlers/station-details/reviews/stationReviewHandlers.ts delete mode 100644 src/mocks/handlers/station-details/stationDetailHandlers.ts delete mode 100644 src/mocks/handlers/station-details/statisticsHandlers.ts delete mode 100644 src/mocks/handlers/station-filters/memberFilterHandlers.ts delete mode 100644 src/mocks/handlers/station-filters/serverFilterHandlers.ts delete mode 100644 src/mocks/handlers/station-markers/stationHandlers.ts delete mode 100644 src/mocks/handlers/station-markers/stationMarkerHandlers.ts delete mode 100644 src/mocks/handlers/stationSearchHandlers.ts delete mode 100644 src/mocks/node.ts diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts deleted file mode 100644 index 9c10cad9..00000000 --- a/src/mocks/browser.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { setupWorker } from 'msw'; - -import { handlers } from './handlers'; - -export const worker = setupWorker(...handlers); diff --git a/src/mocks/data.ts b/src/mocks/data.ts deleted file mode 100644 index 4e197891..00000000 --- a/src/mocks/data.ts +++ /dev/null @@ -1,444 +0,0 @@ -import type { Region, RegionName } from '@marker/MaxDeltaAreaMarkerContainer/types'; - -import { getTypedObjectFromEntries } from '@utils/getTypedObjectFromEntries'; -import { getTypedObjectKeys } from '@utils/getTypedObjectKeys'; -import { generateRandomData, generateRandomToken, getRandomTime } from '@utils/randomDataGenerator'; - -import { - CAPACITIES, - COMPANIES, - CONNECTOR_TYPES, - QUICK_CHARGER_CAPACITY_THRESHOLD, -} from '@constants/chargers'; -import { NO_RATIO, SHORT_ENGLISH_DAYS_OF_WEEK } from '@constants/congestion'; -import { MAX_SEARCH_RESULTS } from '@constants/stationSearch'; - -import type { Car } from '@type/cars'; -import type { Capacity, ChargerDetails } from '@type/chargers'; -import type { Congestion, ShortEnglishDaysOfWeek } from '@type/congestion'; -import type { CapaCityBigDecimal, ConnectorTypeKey } from '@type/serverStationFilter'; -import type { CompanyName, Reply, Review, Station, StationFilters } from '@type/stations'; - -export const generateRandomChargers = () => { - const length = Math.floor(Math.random() * 10) + 1; - const chargers: ChargerDetails[] = Array.from({ length }, () => ({ - type: generateRandomData<ConnectorTypeKey>(getTypedObjectKeys(CONNECTOR_TYPES)), - price: generateRandomData([200, 250, 300, 350, 400]), - capacity: generateRandomData<Capacity>([3, 7, 50, 100, 200]), - latestUpdateTime: getRandomTime(), - state: generateRandomData([ - 'COMMUNICATION_ERROR', - 'STANDBY', - 'CHARGING_IN_PROGRESS', - 'OPERATION_SUSPENDED', - 'UNDER_INSPECTION', - 'STATUS_UNKNOWN', - ]), - method: generateRandomData(['단독', '동시']), - })); - - return chargers; -}; - -const generateRandomStationId = () => { - const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - const numbers = '0123456789'; - - const randomChar = (source: string) => source[Math.floor(Math.random() * source.length)]; - - const randomLetter1 = randomChar(letters); - const randomLetter2 = randomChar(letters); - const randomNumber = Array.from({ length: 6 }, () => randomChar(numbers)).join(''); - - return `${randomLetter1}${randomLetter2}${randomNumber}`; -}; - -export const stations: Station[] = Array.from({ length: 60000 }, () => { - const randomStationId = generateRandomStationId(); - const chargers = generateRandomChargers(); - const totalCount = chargers.length; - const availableCount = chargers.filter(({ state }) => state === 'STANDBY').length; - const quickChargerCount = chargers.filter( - ({ capacity }) => capacity >= QUICK_CHARGER_CAPACITY_THRESHOLD - ).length; - - return { - stationId: randomStationId, - stationName: `충전소 ${randomStationId}`, - companyName: generateRandomData<CompanyName>(Object.values(COMPANIES)), - contact: generateRandomData(['', '010-1234-5678', '02-000-0000']), - chargers: chargers, - isParkingFree: generateRandomData<boolean>([true, false]), - operatingTime: generateRandomData<string>([ - '24시간', - '09:00 ~ 19:00', - '평일 09:00~19:00 / 주말 미운영', - ]), - address: generateRandomData([ - '서울시 송파구 신천동 7-22', - '서울시 강남구 테헤란로 411', - '서울시 종로구 관철동 13-22', - 'null', - ]), - detailLocation: generateRandomData<string>(['지상 1층', '지하 1층', '지하 2층', '']), - latitude: 37 + 0.25 + 9999 * Math.random() * 0.00005, - longitude: 127 - 0.25 + 9999 * Math.random() * 0.00005, - isPrivate: generateRandomData<boolean>([true, false]), - totalCount, - availableCount, - quickChargerCount, - stationState: generateRandomData(['yyyy-mm-dd일부터 충전소 공사합니다.', 'null', null]), - privateReason: generateRandomData(['아파트', 'null', null]), - reportCount: generateRandomData([0, 0, Math.floor(Math.random() * 99)]), - }; -}); - -export const getSearchedStations = (searchWord: string) => { - const searchApiStations = stations.map((station) => { - const { stationId, stationName, chargers, address, latitude, longitude } = station; - - const onlyCapacity = chargers.map(({ capacity }) => capacity); - const speed = onlyCapacity.map((num) => - num >= QUICK_CHARGER_CAPACITY_THRESHOLD ? 'QUICK' : 'STANDARD' - ); - - return { stationId, stationName, speed, address, latitude, longitude }; - }); - - return searchApiStations - .filter( - (station) => station.stationName.includes(searchWord) || station.address.includes(searchWord) - ) - .slice(0, MAX_SEARCH_RESULTS); -}; - -interface CongestionStatisticsMockData { - stationId: string; - congestion: { - standard: Record<ShortEnglishDaysOfWeek, Congestion[]>; - quick: Record<ShortEnglishDaysOfWeek, Congestion[]>; - }; -} - -export const getCongestionStatistics = (stationId: string): CongestionStatisticsMockData => { - const foundStation = stations.find((station) => station.stationId === stationId); - const hasOnlyStandardChargers = foundStation.quickChargerCount === 0; - const hasOnlyQuickChargers = foundStation.chargers.every( - ({ capacity }) => capacity >= QUICK_CHARGER_CAPACITY_THRESHOLD - ); - - return { - stationId: foundStation.stationId, - congestion: { - quick: getCongestions(hasOnlyStandardChargers), - standard: getCongestions(hasOnlyQuickChargers), - }, - }; -}; - -const getCongestions = ( - hasOnlyOneChargerType: boolean -): Record<ShortEnglishDaysOfWeek, Congestion[]> => { - return getTypedObjectFromEntries( - SHORT_ENGLISH_DAYS_OF_WEEK, - SHORT_ENGLISH_DAYS_OF_WEEK.map(() => - Array.from({ length: 24 }, (_, index) => { - return { - hour: index, - ratio: hasOnlyOneChargerType || Math.random() > 0.95 ? NO_RATIO : Math.random(), - }; - }) - ) - ); -}; - -export const generateReviews = (): Review[] => { - return Array.from({ length: 10 }, (_, index) => { - return { - reviewId: index, - memberId: generateRandomToken(), - latestUpdateDate: getRandomTime(), - ratings: Math.floor(Math.random() * 5) + 1, - content: generateRandomData([ - '정말 멋진 충전소네요.', - '고장이 잘나요', - '주차 공간이 너무 좁아요', - '후면 주차가 어려워요', - '손잡이가 드러워요', - '비매너 사용자들이 많아요', - '자리가 넉넉해요', - '비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요', - ]), - isUpdated: generateRandomData([true, false]), - isDeleted: generateRandomData([true, false]), - replySize: generateRandomData([0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7]), - }; - }); -}; - -export const generateReplies = (): Reply[] => { - return Array.from({ length: 10 }, (_, index) => { - return { - replyId: index, - reviewId: generateRandomToken(), - memberId: generateRandomToken(), - latestUpdateDate: getRandomTime(), - content: generateRandomData([ - '정말 멋진 충전소네요.', - '고장이 잘나요', - '주차 공간이 너무 좁아요', - '후면 주차가 어려워요', - '손잡이가 드러워요', - '비매너 사용자들이 많아요', - '자리가 넉넉해요', - '비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요비매너 사용자들이 많아요', - ]), - isUpdated: generateRandomData([true, false]), - isDeleted: generateRandomData([true, false]), - }; - }); -}; - -export const generateCars = (): Car[] => { - const name = Array.from({ length: 6 }).map((_, i) => `아이오닉${i + 1}`); - const vintage = Array.from({ length: 5 }).map((_, i) => `${2019 + i}`); - - const car = name - .map((n) => { - const randomLength = Math.floor(Math.random() * 4) + 1; - - const randomYear = vintage.slice(0, randomLength); - return randomYear.map((rV) => ({ - carId: Math.random(), - name: n, - vintage: rV, - })); - }) - .reduce((acc, curr) => [...acc, ...curr], []); - - return car; -}; - -export const generateCarFilters = (): StationFilters => { - const randomSortedCapacities = ( - [...CAPACITIES.map((capacity) => `${capacity}.00`)] as CapaCityBigDecimal[] - ).sort(() => (Math.random() - 0.5 > 0 ? 1 : -1)); - const randomSortedConnectorTypes = [...getTypedObjectKeys(CONNECTOR_TYPES)].sort(() => - Math.random() - 0.5 > 0 ? 1 : -1 - ); - - const capacities = randomSortedCapacities.slice( - 0, - Math.floor(Math.random() * (randomSortedCapacities.length - 1) + 1) - ); - const connectorTypes = randomSortedConnectorTypes.slice(0, 3); - - return { - companies: [], - capacities, - connectorTypes, - }; -}; - -export const regions: Region[] = [ - { - regionName: '서울특별시', - latitude: 37.540705, - longitude: 126.956764, - count: 8128, - }, - { - regionName: '인천광역시', - latitude: 37.469221, - longitude: 126.573234, - count: 2665, - }, - { - regionName: '광주광역시', - latitude: 35.126033, - longitude: 126.831302, - count: 2155, - }, - { - regionName: '대구광역시', - latitude: 35.798838, - longitude: 128.583052, - count: 2871, - }, - { - regionName: '울산광역시', - latitude: 35.519301, - longitude: 129.239078, - count: 1238, - }, - { - regionName: '대전광역시', - latitude: 36.321655, - longitude: 127.378953, - count: 1783, - }, - { - regionName: '부산광역시', - latitude: 35.198362, - longitude: 129.053922, - count: 3337, - }, - { - regionName: '경기도', - latitude: 37.567167, - longitude: 127.190292, - count: 14710, - }, - { - regionName: '강원특별자치도', - latitude: 37.555837, - longitude: 128.209315, - count: 2918, - }, - { - regionName: '충청남도', - latitude: 36.557229, - longitude: 126.779757, - count: 3191, - }, - { - regionName: '충청북도', - latitude: 36.628503, - longitude: 127.929344, - count: 2283, - }, - { - regionName: '경상북도', - latitude: 36.248647, - longitude: 128.664734, - count: 3805, - }, - { - regionName: '경상남도', - latitude: 35.259787, - longitude: 128.664734, - count: 3869, - }, - { - regionName: '전라북도', - latitude: 35.716705, - longitude: 127.144185, - count: 2938, - }, - { - regionName: '전라남도', - latitude: 34.8194, - longitude: 126.893113, - count: 2873, - }, - { - regionName: '제주특별자치도', - latitude: 33.364805, - longitude: 126.542671, - count: 2942, - }, -]; - -export const getRegionName = (regionName: string): RegionName | undefined => { - switch (regionName) { - case 'SEOUL': - return '서울특별시'; - case 'INCHEON': - return '인천광역시'; - case 'GWANGJU': - return '광주광역시'; - case 'DAEGU': - return '대구광역시'; - case 'ULSAN': - return '울산광역시'; - case 'DAEJEON': - return '대전광역시'; - case 'BUSAN': - return '부산광역시'; - case 'GYEONGGI': - return '경기도'; - case 'GANGWON': - return '강원특별자치도'; - case 'CHUNGNAM': - return '충청남도'; - case 'CHUNGBUK': - return '충청북도'; - case 'GYEONGBUK': - return '경상북도'; - case 'GYEONGNAM': - return '경상남도'; - case 'JEONBUK': - return '전라북도'; - case 'JEONNAM': - return '전라남도'; - case 'JEJU': - return '제주특별자치도'; - default: - return undefined; - } -}; - -export const getCities = () => { - return [ - { - cityName: '서울특별시', - latitude: 37.5666103, - longitude: 126.9783882, - }, - { - cityName: '서울특별시 강동구', - latitude: 37.530126, - longitude: 127.1237708, - }, - { - cityName: '서울특별시 강동구 천호동', - latitude: 37.5450159, - longitude: 127.1368066, - }, - { - cityName: '경기도 하남시 미사동', - latitude: 37.560359, - longitude: 127.1888042, - }, - { - cityName: '경기도 하남시 망월동', - latitude: 37.5696083, - longitude: 127.1880625, - }, - { - cityName: '경상남도 진주시 신안동', - latitude: 35.1844853, - longitude: 128.0689824, - }, - { - cityName: '경상남도 진주시', - latitude: 35.180325, - longitude: 128.107646, - }, - { - cityName: '경기도 안산시 단원구 선부동', - latitude: 37.3342173, - longitude: 126.8044133, - }, - { - cityName: '경기도 오산시 부산동', - latitude: 37.1527237, - longitude: 127.088125, - }, - { - cityName: '부산광역시', - latitude: 35.179816, - longitude: 129.0750223, - }, - { - cityName: '부산광역시 기장군', - latitude: 35.244498, - longitude: 129.222312, - }, - { - cityName: '부산광역시 기장군 철마면', - latitude: 35.2752833, - longitude: 129.1497125, - }, - ]; -}; diff --git a/src/mocks/handlers/car/carHandler.ts b/src/mocks/handlers/car/carHandler.ts deleted file mode 100644 index 25574b15..00000000 --- a/src/mocks/handlers/car/carHandler.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { generateCars, generateCarFilters } from '@mocks/data'; -import { rest } from 'msw'; - -import { DEVELOP_SERVER_URL } from '@constants/server'; - -export const carHandler = [ - rest.get(`${DEVELOP_SERVER_URL}/cars`, (_, res, ctx) => { - const cars = generateCars(); - - return res(ctx.json({ cars }), ctx.delay(200), ctx.status(200)); - }), - - rest.get(`${DEVELOP_SERVER_URL}/cars/:carId/filters`, (_, res, ctx) => { - const carFilters = generateCarFilters(); - - return res(ctx.json(carFilters), ctx.delay(200), ctx.status(200)); - }), -]; diff --git a/src/mocks/handlers/index.ts b/src/mocks/handlers/index.ts deleted file mode 100644 index bdb95c82..00000000 --- a/src/mocks/handlers/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { carHandler } from './car/carHandler'; -import { loginHandlers } from './login/loginHandlers'; -import { memberHandlers } from './memberHandlers'; -import { stationReportHandlers } from './station-details/reports/stationReportHandlers'; -import { stationReviewHandlers } from './station-details/reviews/stationReviewHandlers'; -import { stationDetailHandlers } from './station-details/stationDetailHandlers'; -import { statisticsHandlers } from './station-details/statisticsHandlers'; -import { memberFilterHandlers } from './station-filters/memberFilterHandlers'; -import { serverFilterHandlers } from './station-filters/serverFilterHandlers'; -import { stationHandlers } from './station-markers/stationHandlers'; -import { stationMarkerHandlers } from './station-markers/stationMarkerHandlers'; -import { stationSearchHandlers } from './stationSearchHandlers'; - -export const handlers = [ - ...stationMarkerHandlers, // stationHandlers의 stations/:id에 의해 방해받는 메서드가 있기에 stationMarkerHandlers를 먼저 선언해야 함 - ...memberHandlers, - ...stationSearchHandlers, - ...stationHandlers, - ...stationDetailHandlers, - ...statisticsHandlers, - ...memberFilterHandlers, - ...serverFilterHandlers, - ...stationReportHandlers, - ...stationReviewHandlers, - ...loginHandlers, - ...carHandler, -]; diff --git a/src/mocks/handlers/login/loginHandlers.ts b/src/mocks/handlers/login/loginHandlers.ts deleted file mode 100644 index 0f77f3c3..00000000 --- a/src/mocks/handlers/login/loginHandlers.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { rest } from 'msw'; - -import { DEVELOP_SERVER_URL } from '@constants/server'; - -export const loginHandlers = [ - rest.get(`${DEVELOP_SERVER_URL}/oauth/google/login-uri`, (_, res, ctx) => { - return res( - ctx.json({ - loginUri: 'http://localhost:3000/google?code=mock-data-code', - }), - ctx.delay(1000), - ctx.status(200) - ); - }), - - rest.post(`${DEVELOP_SERVER_URL}/oauth/google/login`, (_, res, ctx) => { - return res(ctx.json({ token: 'mock-token' }), ctx.delay(1000), ctx.status(200)); - }), -]; diff --git a/src/mocks/handlers/memberHandlers.ts b/src/mocks/handlers/memberHandlers.ts deleted file mode 100644 index 87800e42..00000000 --- a/src/mocks/handlers/memberHandlers.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { rest } from 'msw'; - -import { EMPTY_MEMBER_TOKEN } from '@constants'; -import { DEVELOP_SERVER_URL } from '@constants/server'; - -export const memberHandlers = [ - rest.get(`${DEVELOP_SERVER_URL}/members/me`, (req, res, ctx) => { - const memberToken = req.headers.get('Authorization'); - - if (memberToken === undefined || memberToken.replace('Bearer', '') === EMPTY_MEMBER_TOKEN) { - return res(ctx.status(401), ctx.json('unauthorized error')); - } - - return res( - ctx.status(200), - ctx.json({ - memberId: Math.random(), - car: { - carId: Math.random(), - name: '아이오닉3', - vintage: '2019', - }, - }) - ); - }), - - rest.post(`${DEVELOP_SERVER_URL}/members/:memberId/cars`, async (req, res, ctx) => { - const memberToken = req.headers.get('Authorization'); - - if (memberToken === undefined || memberToken.replace('Bearer', '') === EMPTY_MEMBER_TOKEN) { - return res(ctx.status(401), ctx.json('unauthorized error')); - } - - const carInfo = await req.json(); - const name = carInfo.name; - const vintage = carInfo.vintage; - - return res( - ctx.status(200), - ctx.delay(200), - ctx.json({ - carId: 1, - name, - vintage, - }) - ); - }), -]; diff --git a/src/mocks/handlers/station-details/reports/stationReportHandlers.ts b/src/mocks/handlers/station-details/reports/stationReportHandlers.ts deleted file mode 100644 index 957b7049..00000000 --- a/src/mocks/handlers/station-details/reports/stationReportHandlers.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { rest } from 'msw'; - -import { getSessionStorage, setSessionStorage } from '@utils/storage'; - -import { DEVELOP_SERVER_URL } from '@constants/server'; -import { SESSION_KEY_REPORTED_STATIONS } from '@constants/storageKeys'; - -export const stationReportHandlers = [ - rest.post(`${DEVELOP_SERVER_URL}/stations/:stationId/reports`, (req, res, ctx) => { - const stationId = req.params.stationId as string; - const prevReportedStations = getSessionStorage<string[]>(SESSION_KEY_REPORTED_STATIONS, []); - - setSessionStorage<string[]>(SESSION_KEY_REPORTED_STATIONS, [ - ...new Set([...prevReportedStations, stationId]), - ]); - - return res(ctx.delay(200), ctx.status(204)); - }), - - rest.get(`${DEVELOP_SERVER_URL}/stations/:stationId/reports/me`, (req, res, ctx) => { - console.log(req.headers.get('Authorization')); // TODO: 이후에 비로그인 기능도 구현할 때 활용해야함 - const stationId = req.params.stationId as string; - const reportedStations = getSessionStorage<string[]>(SESSION_KEY_REPORTED_STATIONS, []); - - return res( - ctx.delay(200), - ctx.status(200), - ctx.json({ isReported: reportedStations.includes(stationId) }) - ); - }), - - rest.post( - `${DEVELOP_SERVER_URL}/stations/:stationId/misinformation-reports`, - async (req, res, ctx) => { - const body = await req.json(); - console.log(JSON.stringify(body.stationDetailsToUpdate)); - - return res(ctx.delay(200), ctx.status(204)); - } - ), -]; diff --git a/src/mocks/handlers/station-details/reviews/stationReviewHandlers.ts b/src/mocks/handlers/station-details/reviews/stationReviewHandlers.ts deleted file mode 100644 index e06dd24d..00000000 --- a/src/mocks/handlers/station-details/reviews/stationReviewHandlers.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { generateReplies, generateReviews } from '@mocks/data'; -import { rest } from 'msw'; - -import { DEVELOP_SERVER_URL } from '@constants/server'; - -export const stationReviewHandlers = [ - rest.get(`${DEVELOP_SERVER_URL}/stations/:stationId/total-ratings`, (req, res, ctx) => { - const reviews = generateReviews(); - const validReviews = reviews.filter((review) => !review.isDeleted); - const min = 1; - const max = 2000; - return res( - ctx.json({ - totalRatings: parseFloat( - (validReviews.reduce((a, b) => a + b.ratings, 0) / reviews.length).toFixed(2) - ), - totalCount: Math.floor(Math.random() * (max - min + 1)) + min, - }), - ctx.delay(1000), - ctx.status(200) - ); - }), - - rest.get(`${DEVELOP_SERVER_URL}/stations/:stationId/reviews`, (req, res, ctx) => { - const reviews = generateReviews(); - const { searchParams } = req.url; - const page = Number(searchParams.get('page')); - console.log(`충전소 후기 조회 page=${page}`); - - if (page === 5) { - return res( - ctx.json({ - reviews: reviews.slice(0, 3), - currentPage: page, - nextPage: -1, - }), - ctx.delay(1000), - ctx.status(200) - ); - } else if (page > 5) { - return res( - ctx.json({ - reviews: [], - currentPage: page, - nextPage: -1, - }), - ctx.delay(1000), - ctx.status(200) - ); - } else { - return res( - ctx.json({ - reviews, - currentPage: page, - nextPage: page + 1, - }), - ctx.delay(1000), - ctx.status(200) - ); - } - }), - - rest.post(`${DEVELOP_SERVER_URL}/stations/:stationId/reviews`, async (req, res, ctx) => { - const body = await req.json(); - console.log(`충전소 후기 작성 :${JSON.stringify(body)}`); - return res(ctx.delay(200), ctx.status(204)); - }), - rest.patch(`${DEVELOP_SERVER_URL}/reviews/:reviewId`, async (req, res, ctx) => { - const body = await req.json(); - console.log(`충전소 후기 수정 :${JSON.stringify(body)}`); - return res(ctx.delay(200), ctx.status(204)); - }), - rest.delete(`${DEVELOP_SERVER_URL}/reviews/:reviewId`, async (req, res, ctx) => { - console.log(`충전소 후기 삭제`); - return res(ctx.delay(200), ctx.status(204)); - }), - - rest.get(`${DEVELOP_SERVER_URL}/reviews/:reviewId/replies`, (req, res, ctx) => { - const replies = generateReplies(); - const { searchParams } = req.url; - const page = Number(searchParams.get('page')); - console.log(`충전소 답글 조회 page=${page}`); - - if (page === 2) { - return res( - ctx.json({ - replies: replies.slice(0, 3), - currentPage: page, - nextPage: -1, - }), - ctx.delay(1000), - ctx.status(200) - ); - } else if (page > 2) { - return res( - ctx.json({ - replies: [], - currentPage: page, - nextPage: -1, - }), - ctx.delay(1000), - ctx.status(200) - ); - } else { - return res( - ctx.json({ - replies, - currentPage: page, - nextPage: page + 1, - }), - ctx.delay(1000), - ctx.status(200) - ); - } - }), - - rest.post(`${DEVELOP_SERVER_URL}/reviews/:reviewId/replies`, async (req, res, ctx) => { - const body = await req.json(); - console.log(`충전소 후기 답글 작성 :${JSON.stringify(body)}`); - return res(ctx.delay(200), ctx.status(204)); - }), - rest.patch(`${DEVELOP_SERVER_URL}/reviews/:reviewId/replies/:replyId`, async (req, res, ctx) => { - const body = await req.json(); - console.log(`충전소 후기 답글 수정 :${JSON.stringify(body)}`); - return res(ctx.delay(200), ctx.status(204)); - }), - rest.delete(`${DEVELOP_SERVER_URL}/reviews/:reviewId/replies/:replyId`, (req, res, ctx) => { - console.log(`충전소 후기 답글 삭제`); - return res(ctx.delay(200), ctx.status(204)); - }), -]; diff --git a/src/mocks/handlers/station-details/stationDetailHandlers.ts b/src/mocks/handlers/station-details/stationDetailHandlers.ts deleted file mode 100644 index f7f3ec6b..00000000 --- a/src/mocks/handlers/station-details/stationDetailHandlers.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { stations } from '@mocks/data'; -import { rest } from 'msw'; - -import { ERROR_MESSAGES } from '@constants/errorMessages'; -import { DEVELOP_SERVER_URL } from '@constants/server'; - -export const stationDetailHandlers = [ - rest.get(`${DEVELOP_SERVER_URL}/stations/:id`, async (req, res, ctx) => { - const stationId = req.params.id; - const selectedStation = stations.find((station) => station.stationId === stationId); - - if (!selectedStation) { - return res(ctx.status(404), ctx.json({ message: ERROR_MESSAGES.NO_STATION_FOUND })); - } - - return res(ctx.delay(200), ctx.status(200), ctx.json(selectedStation)); - }), -]; diff --git a/src/mocks/handlers/station-details/statisticsHandlers.ts b/src/mocks/handlers/station-details/statisticsHandlers.ts deleted file mode 100644 index 4570a118..00000000 --- a/src/mocks/handlers/station-details/statisticsHandlers.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { getCongestionStatistics } from '@mocks/data'; -import { rest } from 'msw'; - -import { ENGLISH_DAYS_OF_WEEK_FULL_TO_SHORT } from '@constants/congestion'; -import { DEVELOP_SERVER_URL } from '@constants/server'; - -import type { ShortEnglishDaysOfWeek } from '@type'; - -export const statisticsHandlers = [ - rest.get(`${DEVELOP_SERVER_URL}/stations/:stationId/statistics`, (req, res, ctx) => { - const stationId = req.url.pathname - .split('?')[0] - .replace(/\/api\/stations\//, '') - .replace(/\/statistics/, ''); - const dayOfWeek = req.url.searchParams.get('dayOfWeek') as ShortEnglishDaysOfWeek; - - const fullCongestionStatistics = getCongestionStatistics(stationId); - const congestionStatistics = { - ...fullCongestionStatistics, - congestion: { - standard: [ - ...fullCongestionStatistics['congestion']['standard'][ - ENGLISH_DAYS_OF_WEEK_FULL_TO_SHORT[dayOfWeek] - ], - ], - quick: [ - ...fullCongestionStatistics['congestion']['quick'][ - ENGLISH_DAYS_OF_WEEK_FULL_TO_SHORT[dayOfWeek] - ], - ], - }, - }; - - return res(ctx.json(congestionStatistics), ctx.delay(1000), ctx.status(200)); - }), -]; diff --git a/src/mocks/handlers/station-filters/memberFilterHandlers.ts b/src/mocks/handlers/station-filters/memberFilterHandlers.ts deleted file mode 100644 index b57d0e0d..00000000 --- a/src/mocks/handlers/station-filters/memberFilterHandlers.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { rest } from 'msw'; - -import { EMPTY_MEMBER_TOKEN } from '@constants'; -import { DEVELOP_SERVER_URL } from '@constants/server'; - -export const memberFilterHandlers = [ - rest.post(`${DEVELOP_SERVER_URL}/members/:memberId/filters`, async (req, res, ctx) => { - const memberToken = req.headers.get('Authorization'); - const requestBody = await req.json(); - - const connectorTypes = requestBody.filters - .filter( - (filterOption: { type: string; name: string }) => filterOption.type === 'connectorType' - ) - .map((filterOption: { type: string; name: string }) => filterOption.name); - const capacities = requestBody.filters - .filter((filterOption: { type: string; name: string }) => filterOption.type === 'capacity') - .map((filterOption: { type: string; name: string }) => filterOption.name); - const companies = requestBody.filters - .filter((filterOption: { type: string; name: string }) => filterOption.type === 'company') - .map((filterOption: { type: string; name: string }) => filterOption.name); - - if (memberToken === undefined || memberToken.replace('Bearer', '') === EMPTY_MEMBER_TOKEN) { - return res(ctx.status(401), ctx.json('unauthorized error')); - } - - return res(ctx.status(200), ctx.json({ connectorTypes, capacities, companies })); - }), - - rest.get(`${DEVELOP_SERVER_URL}/members/:memberId/filters`, (req, res, ctx) => { - const memberToken = req.headers.get('Authorization'); - - if (memberToken === undefined || memberToken.replace('Bearer', '') === EMPTY_MEMBER_TOKEN) { - return res(ctx.status(401), ctx.json('unauthorized error')); - } - - return res( - ctx.status(200), - ctx.json({ - companies: ['AM', 'BA', 'BG', 'BK'], - capacities: ['3.00', '7.00'], - connectorTypes: ['DC_COMBO'], - }) - ); - }), -]; diff --git a/src/mocks/handlers/station-filters/serverFilterHandlers.ts b/src/mocks/handlers/station-filters/serverFilterHandlers.ts deleted file mode 100644 index d1e9a4ab..00000000 --- a/src/mocks/handlers/station-filters/serverFilterHandlers.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { rest } from 'msw'; - -import { DEVELOP_SERVER_URL } from '@constants/server'; - -import { CAPACITIES, CONNECTOR_TYPES, COMPANIES } from '../../../constants/chargers'; - -export const serverFilterHandlers = [ - rest.get(`${DEVELOP_SERVER_URL}/filters`, (_, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - connectorTypes: Object.keys(CONNECTOR_TYPES), - capacities: CAPACITIES.map((capacity) => `${capacity}.00`), - companies: Object.keys(COMPANIES), - }), - ctx.delay(1000) - ); - }), -]; diff --git a/src/mocks/handlers/station-markers/stationHandlers.ts b/src/mocks/handlers/station-markers/stationHandlers.ts deleted file mode 100644 index 85b023ab..00000000 --- a/src/mocks/handlers/station-markers/stationHandlers.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { stations } from '@mocks/data'; -import { rest } from 'msw'; - -import { DELIMITER } from '@constants'; -import { DEVELOP_SERVER_URL } from '@constants/server'; - -import type { StationSummary } from '@type'; - -export const stationHandlers = [ - rest.get(`${DEVELOP_SERVER_URL}/stations/summary`, async (req, res, ctx) => { - const { searchParams } = req.url; - // ?stationIds=PE123456,PE123457,PE123465 ==> 대략 10개, 검사는 안함 - const stationIdsParam = searchParams.get('stationIds'); - if (stationIdsParam === undefined) { - return res(ctx.delay(1000), ctx.status(200), ctx.json([])); - } - const stationIds = stationIdsParam.split(DELIMITER); - const foundStations = stations.filter((station) => stationIds.includes(station.stationId)); - const stationSummaries: StationSummary[] = foundStations.map((station) => { - const { - address, - availableCount, - companyName, - detailLocation, - isParkingFree, - isPrivate, - latitude, - longitude, - operatingTime, - stationId, - stationName, - totalCount, - quickChargerCount, - }: StationSummary = station; - - return { - address, - availableCount, - companyName, - detailLocation, - isParkingFree, - isPrivate, - latitude, - longitude, - operatingTime, - stationId, - stationName, - totalCount, - quickChargerCount, - }; - }); - return res( - ctx.delay(1000), - ctx.status(200), - ctx.json({ - stations: stationSummaries, - }) - ); - }), -]; diff --git a/src/mocks/handlers/station-markers/stationMarkerHandlers.ts b/src/mocks/handlers/station-markers/stationMarkerHandlers.ts deleted file mode 100644 index 9679d5fa..00000000 --- a/src/mocks/handlers/station-markers/stationMarkerHandlers.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { getRegionName, regions, stations } from '@mocks/data'; -import { rest } from 'msw'; - -import { DELIMITER } from '@constants'; -import { COMPANIES } from '@constants/chargers'; -import { DEVELOP_SERVER_URL } from '@constants/server'; - -import type { StationMarker, StationSummary } from '@type'; -import type { CompanyKey } from '@type/serverStationFilter'; - -export const stationMarkerHandlers = [ - rest.get(`${DEVELOP_SERVER_URL}/stations`, async (req, res, ctx) => { - const { searchParams } = req.url; - - const latitude = Number(searchParams.get('latitude')); - const longitude = Number(searchParams.get('longitude')); - const latitudeDelta = Number(searchParams.get('latitudeDelta')); - const longitudeDelta = Number(searchParams.get('longitudeDelta')); - - const isChargerTypeFilterSelected = searchParams.get('chargerTypes') !== null; - const isCapacityFilterSelected = searchParams.get('capacities') !== null; - const isCompanyNameFilterSelected = searchParams.get('companyNames') !== null; - - const selectedChargerTypes = searchParams.get('chargerTypes')?.split(DELIMITER); - const selectedCapacities = searchParams.get('capacities')?.split(DELIMITER)?.map(Number); - const selectedCompanies = searchParams.get('companyNames')?.split(DELIMITER); - - const northEastBoundary = { - latitude: latitude + latitudeDelta, - longitude: longitude + longitudeDelta, - }; - - const southWestBoundary = { - latitude: latitude - latitudeDelta, - longitude: longitude - longitudeDelta, - }; - - const isStationLatitudeWithinBounds = (station: StationSummary) => { - return ( - station.latitude > southWestBoundary.latitude && - station.latitude < northEastBoundary.latitude - ); - }; - - const isStationLongitudeWithinBounds = (station: StationSummary) => { - return ( - station.longitude > southWestBoundary.longitude && - station.longitude < northEastBoundary.longitude - ); - }; - - const foundStations: StationMarker[] = stations - .filter( - (station) => - isStationLatitudeWithinBounds(station) && isStationLongitudeWithinBounds(station) - ) - .filter((station) => { - const isChargerTypeFilterInvalid = - isChargerTypeFilterSelected && - !station.chargers.some((charger) => selectedChargerTypes.includes(charger.type)); - const isCapacityFilterInvalid = - isCapacityFilterSelected && - !station.chargers.some((charger) => selectedCapacities.includes(charger.capacity)); - const isCompanyNameFilterInvalid = - isCompanyNameFilterSelected && - !selectedCompanies - .map((companyId) => COMPANIES[companyId as CompanyKey]) - .includes(station.companyName as (typeof COMPANIES)[CompanyKey]); - - if (isChargerTypeFilterInvalid || isCapacityFilterInvalid || isCompanyNameFilterInvalid) { - return false; - } - return true; - }) - .map((station: StationMarker) => { - const { - stationId, - latitude, - longitude, - stationName, - availableCount, - isParkingFree, - isPrivate, - quickChargerCount, - } = station; - - return { - stationId, - latitude, - longitude, - stationName, - availableCount, - isParkingFree, - isPrivate, - quickChargerCount, - }; - }); - - console.log('찾은 충전소 갯수: ' + foundStations.length); - - return res( - ctx.delay(1000), - ctx.status(200), - ctx.json({ - stations: foundStations, - }) - ); - }), - rest.get(`${DEVELOP_SERVER_URL}/stations/regions`, async (req, res, ctx) => { - const { searchParams } = req.url; - - const region = searchParams.get('regions'); - const regionName = getRegionName(region); - const foundRegions = - regionName === undefined - ? regions - : regions.filter((region) => region.regionName === regionName); - return res(ctx.delay(1000), ctx.status(200), ctx.json(foundRegions)); - }), -]; diff --git a/src/mocks/handlers/stationSearchHandlers.ts b/src/mocks/handlers/stationSearchHandlers.ts deleted file mode 100644 index b9a902dd..00000000 --- a/src/mocks/handlers/stationSearchHandlers.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { getCities, getSearchedStations, stations } from '@mocks/data'; -import { rest } from 'msw'; - -import { ERROR_MESSAGES } from '@constants/errorMessages'; -import { DEVELOP_SERVER_URL } from '@constants/server'; - -export const stationSearchHandlers = [ - rest.get(`${DEVELOP_SERVER_URL}/stations/search`, async (req, res, ctx) => { - const searchWord = req.url.searchParams.get('q'); - - if (!stations.length) { - return res(ctx.status(404), ctx.json({ message: ERROR_MESSAGES.NO_SEARCH_RESULT })); - } - - const searchResult = { - cities: getCities() - .filter((city) => city.cityName.includes(searchWord)) - .slice(0, 3), - stations: getSearchedStations(searchWord), - }; - - return res(ctx.delay(200), ctx.status(200), ctx.json(searchResult)); - }), -]; diff --git a/src/mocks/node.ts b/src/mocks/node.ts deleted file mode 100644 index 1ada4078..00000000 --- a/src/mocks/node.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { setupServer } from 'msw/node'; - -import { handlers } from './handlers'; - -export const server = setupServer(...handlers); - -beforeAll(() => server.listen()); -afterAll(() => server.close()); From f9c79b772a7221f89b19c9d4120cc3dcbfb3ff2e Mon Sep 17 00:00:00 2001 From: gabrielyoon7 <gabrielyoon7@gmail.com> Date: Tue, 10 Oct 2023 17:23:49 +0900 Subject: [PATCH 10/13] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/page.module.css | 229 ---------------------------------------- 1 file changed, 229 deletions(-) delete mode 100644 src/app/page.module.css diff --git a/src/app/page.module.css b/src/app/page.module.css deleted file mode 100644 index 6676d2c6..00000000 --- a/src/app/page.module.css +++ /dev/null @@ -1,229 +0,0 @@ -.main { - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: center; - padding: 6rem; - min-height: 100vh; -} - -.description { - display: inherit; - justify-content: inherit; - align-items: inherit; - font-size: 0.85rem; - max-width: var(--max-width); - width: 100%; - z-index: 2; - font-family: var(--font-mono); -} - -.description a { - display: flex; - justify-content: center; - align-items: center; - gap: 0.5rem; -} - -.description p { - position: relative; - margin: 0; - padding: 1rem; - background-color: rgba(var(--callout-rgb), 0.5); - border: 1px solid rgba(var(--callout-border-rgb), 0.3); - border-radius: var(--border-radius); -} - -.code { - font-weight: 700; - font-family: var(--font-mono); -} - -.grid { - display: grid; - grid-template-columns: repeat(4, minmax(25%, auto)); - max-width: 100%; - width: var(--max-width); -} - -.card { - padding: 1rem 1.2rem; - border-radius: var(--border-radius); - background: rgba(var(--card-rgb), 0); - border: 1px solid rgba(var(--card-border-rgb), 0); - transition: background 200ms, border 200ms; -} - -.card span { - display: inline-block; - transition: transform 200ms; -} - -.card h2 { - font-weight: 600; - margin-bottom: 0.7rem; -} - -.card p { - margin: 0; - opacity: 0.6; - font-size: 0.9rem; - line-height: 1.5; - max-width: 30ch; -} - -.center { - display: flex; - justify-content: center; - align-items: center; - position: relative; - padding: 4rem 0; -} - -.center::before { - background: var(--secondary-glow); - border-radius: 50%; - width: 480px; - height: 360px; - margin-left: -400px; -} - -.center::after { - background: var(--primary-glow); - width: 240px; - height: 180px; - z-index: -1; -} - -.center::before, -.center::after { - content: ''; - left: 50%; - position: absolute; - filter: blur(45px); - transform: translateZ(0); -} - -.logo { - position: relative; -} -/* Enable hover only on non-touch devices */ -@media (hover: hover) and (pointer: fine) { - .card:hover { - background: rgba(var(--card-rgb), 0.1); - border: 1px solid rgba(var(--card-border-rgb), 0.15); - } - - .card:hover span { - transform: translateX(4px); - } -} - -@media (prefers-reduced-motion) { - .card:hover span { - transform: none; - } -} - -/* Mobile */ -@media (max-width: 700px) { - .content { - padding: 4rem; - } - - .grid { - grid-template-columns: 1fr; - margin-bottom: 120px; - max-width: 320px; - text-align: center; - } - - .card { - padding: 1rem 2.5rem; - } - - .card h2 { - margin-bottom: 0.5rem; - } - - .center { - padding: 8rem 0 6rem; - } - - .center::before { - transform: none; - height: 300px; - } - - .description { - font-size: 0.8rem; - } - - .description a { - padding: 1rem; - } - - .description p, - .description div { - display: flex; - justify-content: center; - position: fixed; - width: 100%; - } - - .description p { - align-items: center; - inset: 0 0 auto; - padding: 2rem 1rem 1.4rem; - border-radius: 0; - border: none; - border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); - background: linear-gradient( - to bottom, - rgba(var(--background-start-rgb), 1), - rgba(var(--callout-rgb), 0.5) - ); - background-clip: padding-box; - backdrop-filter: blur(24px); - } - - .description div { - align-items: flex-end; - pointer-events: none; - inset: auto 0 0; - padding: 2rem; - height: 200px; - background: linear-gradient( - to bottom, - transparent 0%, - rgb(var(--background-end-rgb)) 40% - ); - z-index: 1; - } -} - -/* Tablet and Smaller Desktop */ -@media (min-width: 701px) and (max-width: 1120px) { - .grid { - grid-template-columns: repeat(2, 50%); - } -} - -@media (prefers-color-scheme: dark) { - .vercelLogo { - filter: invert(1); - } - - .logo { - filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); - } -} - -@keyframes rotate { - from { - transform: rotate(360deg); - } - to { - transform: rotate(0deg); - } -} From 9aecc2f29ce2adf52db3a0f03deb051019d03707 Mon Sep 17 00:00:00 2001 From: gabrielyoon7 <gabrielyoon7@gmail.com> Date: Wed, 11 Oct 2023 14:40:51 +0900 Subject: [PATCH 11/13] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc.json | 6 +- src/app/page.tsx | 7 +- src/components/login-page/GoogleLogin.tsx | 2 +- src/components/ui/Loading/Loading.tsx | 3 +- src/components/ui/Star/Star.stories.tsx | 60 ------ .../ui/StarRatings/StarRatings.stories.tsx | 61 ------ .../StationDetailsView.stories.tsx | 113 ----------- .../chargers/ChargerCard.stories.tsx | 31 ---- .../congestion/Help.stories.tsx | 19 -- .../reviews/previews/UserRatings.stories.tsx | 25 --- .../reviews/reviews/ReviewCard.stories.tsx | 55 ------ .../StationInfoWindow/StationInfo.stories.tsx | 78 -------- .../StationSummaryCardSkeleton.stories.tsx | 33 ---- .../SearchResult.stories.tsx | 127 ------------- .../StationSearchBar.stories.tsx | 175 ------------------ .../StationSearchWindow.stories.tsx | 47 ----- .../ui/StatisticsGraph/Graph/Bar.stories.tsx | 27 --- .../StatisticsGraph.stories.tsx | 42 ----- src/components/ui/StatisticsGraph/index.tsx | 2 +- src/router/index.tsx | 16 -- src/types/index.ts | 2 +- tsconfig.json | 87 ++++++--- 22 files changed, 80 insertions(+), 938 deletions(-) delete mode 100644 src/components/ui/Star/Star.stories.tsx delete mode 100644 src/components/ui/StarRatings/StarRatings.stories.tsx delete mode 100644 src/components/ui/StationDetailsWindow/StationDetailsView.stories.tsx delete mode 100644 src/components/ui/StationDetailsWindow/chargers/ChargerCard.stories.tsx delete mode 100644 src/components/ui/StationDetailsWindow/congestion/Help.stories.tsx delete mode 100644 src/components/ui/StationDetailsWindow/reviews/previews/UserRatings.stories.tsx delete mode 100644 src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCard.stories.tsx delete mode 100644 src/components/ui/StationInfoWindow/StationInfo.stories.tsx delete mode 100644 src/components/ui/StationListWindow/fallbacks/StationSummaryCardSkeleton.stories.tsx delete mode 100644 src/components/ui/StationSearchWindow/SearchResult.stories.tsx delete mode 100644 src/components/ui/StationSearchWindow/StationSearchBar.stories.tsx delete mode 100644 src/components/ui/StationSearchWindow/StationSearchWindow.stories.tsx delete mode 100644 src/components/ui/StatisticsGraph/Graph/Bar.stories.tsx delete mode 100644 src/components/ui/StatisticsGraph/StatisticsGraph.stories.tsx delete mode 100644 src/router/index.tsx diff --git a/.eslintrc.json b/.eslintrc.json index bffb357a..5ad64cff 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,3 +1,7 @@ { - "extends": "next/core-web-vitals" + "extends": "next/core-web-vitals", + "rules": { + "react/no-unescaped-entities": "off", + "@next/next/no-page-custom-font": "off" + } } diff --git a/src/app/page.tsx b/src/app/page.tsx index 15d27bc5..0099d139 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,17 +2,18 @@ import { Status, Wrapper } from '@googlemaps/react-wrapper'; import CarFfeineMap from '@/components/google-maps/map/CarFfeineMap'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import Loading from '@ui/Loading'; const queryClient = new QueryClient(); const render = (status: Status) => { switch (status) { + case Status.LOADING: + return <Loading />; case Status.FAILURE: return <>에러 발생</>; - case Status.LOADING: - return <div>로딩중</div>; case Status.SUCCESS: - return <CarFfeineMap/>; + return <CarFfeineMap />; } }; diff --git a/src/components/login-page/GoogleLogin.tsx b/src/components/login-page/GoogleLogin.tsx index 1fc7a851..f9c2afd5 100644 --- a/src/components/login-page/GoogleLogin.tsx +++ b/src/components/login-page/GoogleLogin.tsx @@ -1,4 +1,4 @@ -import Loading from 'components/ui/Loading'; +import Loading from '@components/ui/Loading'; import { useEffect, useState } from 'react'; diff --git a/src/components/ui/Loading/Loading.tsx b/src/components/ui/Loading/Loading.tsx index 1eaf5221..b959be0b 100644 --- a/src/components/ui/Loading/Loading.tsx +++ b/src/components/ui/Loading/Loading.tsx @@ -1,6 +1,7 @@ import { StyledLoadingSvgContainer, StyledMessage } from '@ui/Loading/Loading.style'; import LoadingSvg from '@assets/loading.svg'; +import Image from 'next/image'; const Loading = () => { return ( @@ -9,7 +10,7 @@ const Loading = () => { 열심히 로딩하고 있어요<span>잠시만 기다려 주세요...</span> </StyledMessage> <StyledLoadingSvgContainer> - <LoadingSvg /> + <Image src={LoadingSvg} alt="로딩중..."/> </StyledLoadingSvgContainer> </> ); diff --git a/src/components/ui/Star/Star.stories.tsx b/src/components/ui/Star/Star.stories.tsx deleted file mode 100644 index 4abb0780..00000000 --- a/src/components/ui/Star/Star.stories.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import { useState } from 'react'; - -import Text from '../../common/Text'; -import type { StarProps } from './Star'; -import Star from './Star'; - -const meta = { - title: 'UI/Star', - component: Star, - tags: ['autodocs'], - args: { - isSelected: false, - onClick: () => { - alert('제가 눌렸어요!!!'); - }, - size: 'md', - }, - argTypes: { - isSelected: { - description: '별에 불을 들어오게 하는 역할을 합니다.', - }, - onClick: { - description: '눌렀을 때 반응하도록 할 수 있습니다.', - }, - size: { - description: '크기를 지정할 수 있습니다.', - }, - }, -} satisfies Meta<typeof Star>; - -export default meta; - -export const Default = (args: StarProps) => { - return <Star {...args} />; -}; - -export const Controllable = () => { - const [isSelected, setIsSelected] = useState(false); - return ( - <div> - <Text variant="h1">현재 상태: {isSelected ? '반짝반짝 작은별' : '죽은별'}</Text> - <Star isSelected={isSelected} onClick={() => setIsSelected(!isSelected)} /> - </div> - ); -}; -export const Sizes = () => { - const [isSelected, setIsSelected] = useState(false); - return ( - <div> - <Star isSelected={isSelected} onClick={() => setIsSelected(!isSelected)} size="xs" /> - <Star isSelected={isSelected} onClick={() => setIsSelected(!isSelected)} size="sm" /> - <Star isSelected={isSelected} onClick={() => setIsSelected(!isSelected)} size="md" /> - <Star isSelected={isSelected} onClick={() => setIsSelected(!isSelected)} size="lg" /> - <Star isSelected={isSelected} onClick={() => setIsSelected(!isSelected)} size="xl" /> - <Star isSelected={isSelected} onClick={() => setIsSelected(!isSelected)} size="xxl" /> - </div> - ); -}; diff --git a/src/components/ui/StarRatings/StarRatings.stories.tsx b/src/components/ui/StarRatings/StarRatings.stories.tsx deleted file mode 100644 index 73f30405..00000000 --- a/src/components/ui/StarRatings/StarRatings.stories.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import { useState } from 'react'; - -import Text from '../../common/Text'; -import type { StarRatingsProps } from './StarRatings'; -import StarRatings from './index'; - -const meta = { - title: 'UI/StarRatings', - component: StarRatings, - tags: ['autodocs'], - args: { - stars: 3, - setStars: () => { - alert('제가 눌렸어요!!!'); - }, - size: 'md', - }, - argTypes: { - stars: { - description: '0~5의 숫자를 줄 수 있습니다. ', - }, - setStars: { - description: '별의 갯수롤 조절할 수 있습니다.', - }, - size: { - description: '사이즈를 조절할 수 있습니다.', - }, - }, -} satisfies Meta<typeof StarRatings>; - -export default meta; - -export const Default = (args: StarRatingsProps) => { - return <StarRatings {...args} />; -}; - -export const Controllable = () => { - const [stars, setStars] = useState(0); - return ( - <div> - <Text variant="h1">Rating: {stars}</Text> - <StarRatings stars={stars} setStars={setStars} /> - </div> - ); -}; -export const Sizes = () => { - const [stars, setStars] = useState(0); - return ( - <div> - <Text variant="h1">Rating: {stars}</Text> - <StarRatings stars={stars} setStars={setStars} size="xs" /> - <StarRatings stars={stars} setStars={setStars} size="sm" /> - <StarRatings stars={stars} setStars={setStars} size="md" /> - <StarRatings stars={stars} setStars={setStars} size="lg" /> - <StarRatings stars={stars} setStars={setStars} size="xl" /> - <StarRatings stars={stars} setStars={setStars} size="xxl" /> - </div> - ); -}; diff --git a/src/components/ui/StationDetailsWindow/StationDetailsView.stories.tsx b/src/components/ui/StationDetailsWindow/StationDetailsView.stories.tsx deleted file mode 100644 index 9cd4ee66..00000000 --- a/src/components/ui/StationDetailsWindow/StationDetailsView.stories.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import type { Meta } from '@storybook/react'; -import { css } from 'styled-components'; - -import Box from '../../common/Box'; -import FlexBox from '../../common/FlexBox'; -import type { StationDetailsViewProps } from './StationDetailsView'; -import StationDetailsView from './StationDetailsView'; -import StationDetailsViewSkeleton from './StationDetailsViewSkeleton'; -import CongestionBarContainerSkeleton from './congestion/CongestionBarContainerSkeleton'; - -const meta = { - title: 'UI/StationDetailsView', - component: StationDetailsView, - tags: ['autodocs'], - args: { - station: { - stationId: '99', - stationName: '박스터 충전소', - companyName: 'CARffeine', - contact: '02-1234-5678', - chargers: [ - { - type: 'DC_AC_3PHASE', - price: 200, - capacity: 3, - latestUpdateTime: '2023-07-18T15:11:40.000Z', - state: 'STANDBY', - method: '단독', - }, - { - type: 'DC_COMBO', - price: 300, - capacity: 200, - latestUpdateTime: '2023-07-30T03:21:40.000Z', - state: 'UNDER_INSPECTION', - method: '단독', - }, - { - type: 'DC_AC_3PHASE', - price: 350, - capacity: 50, - latestUpdateTime: '2023-07-01T03:21:40.000Z', - state: 'CHARGING_IN_PROGRESS', - method: '동시', - }, - { - type: 'AC_SLOW', - price: 350, - capacity: 3, - latestUpdateTime: '2023-07-01T03:21:40.000Z', - state: 'CHARGING_IN_PROGRESS', - method: '동시', - }, - { - type: 'DC_FAST', - price: 450, - capacity: 100, - latestUpdateTime: '2023-07-01T03:21:40.000Z', - state: 'STATUS_UNKNOWN', - method: '동시', - }, - ], - isParkingFree: true, - operatingTime: '평일 09:00~19:00 / 주말 미운영', - address: '서울 송파구 올림픽로35다길 42', - detailLocation: '지하 1층 구석탱이 어딘가', - latitude: 37.599295930415195, - longitude: 127.45404683387704, - isPrivate: true, - stationState: '2023-08-04일부터 충전소 공사합니다.', - privateReason: '박스터 차주만 충전 가능함', - reportCount: 1, - }, - }, - argTypes: { - station: { - description: '충전소 데이터를 수정할 수 있습니다.', - }, - }, -} satisfies Meta<typeof StationDetailsView>; - -export default meta; - -export const Default = (args: StationDetailsViewProps) => { - return <StationDetailsView {...args} />; -}; - -export const Skeleton = (args: StationDetailsViewProps) => { - return ( - <FlexBox> - <StationDetailsView {...args} /> - <StationDetailsViewSkeleton /> - </FlexBox> - ); -}; - -export const StatisticsSkeleton = () => { - return ( - <Box css={containerCss}> - <CongestionBarContainerSkeleton /> - </Box> - ); -}; - -const containerCss = css` - width: 36rem; - height: 100vh; - background-color: white; - box-shadow: 1px 1px 2px gray; - border-left: 0.5px solid #e1e4eb; - border-right: 0.5px solid #e1e4eb; - padding: 2rem; -`; diff --git a/src/components/ui/StationDetailsWindow/chargers/ChargerCard.stories.tsx b/src/components/ui/StationDetailsWindow/chargers/ChargerCard.stories.tsx deleted file mode 100644 index e95bc188..00000000 --- a/src/components/ui/StationDetailsWindow/chargers/ChargerCard.stories.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import type { ChargerCardProps } from './ChargerCard'; -import ChargerCard from './ChargerCard'; - -const meta = { - title: 'UI/ChargerCard', - component: ChargerCard, - tags: ['autodocs'], - args: { - charger: { - type: 'DC_AC_3PHASE', - price: 200, - capacity: 3, - latestUpdateTime: '2023-07-18T15:11:40.000Z', - state: 'STANDBY', - method: '단독', - }, - }, - argTypes: { - charger: { - description: '충전기 데이터를 수정할 수 있습니다.', - }, - }, -} satisfies Meta<typeof ChargerCard>; - -export default meta; - -export const Default = (args: ChargerCardProps) => { - return <ChargerCard {...args} />; -}; diff --git a/src/components/ui/StationDetailsWindow/congestion/Help.stories.tsx b/src/components/ui/StationDetailsWindow/congestion/Help.stories.tsx deleted file mode 100644 index cf9eab44..00000000 --- a/src/components/ui/StationDetailsWindow/congestion/Help.stories.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import Help from '../congestion/Help'; -import Box from './../../../common/Box'; - -const meta = { - title: 'UI/Help', - component: Help, -} satisfies Meta<typeof Help>; - -export default meta; - -export const Default = () => { - return ( - <Box width="fit-content" border> - <Help /> - </Box> - ); -}; diff --git a/src/components/ui/StationDetailsWindow/reviews/previews/UserRatings.stories.tsx b/src/components/ui/StationDetailsWindow/reviews/previews/UserRatings.stories.tsx deleted file mode 100644 index 65346599..00000000 --- a/src/components/ui/StationDetailsWindow/reviews/previews/UserRatings.stories.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import Box from '../../../../common/Box'; -import type { UserRatingsProps } from './UserRatings'; -import UserRatings from './UserRatings'; - -const meta = { - title: 'UI/UserRatings', - component: UserRatings, - tags: ['autodocs'], - args: { - stationId: '', - }, - argTypes: {}, -} satisfies Meta<typeof UserRatings>; - -export default meta; - -export const Default = (args: UserRatingsProps) => { - return ( - <Box width={80} border p={4}> - <UserRatings {...args} /> - </Box> - ); -}; diff --git a/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCard.stories.tsx b/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCard.stories.tsx deleted file mode 100644 index a84a5098..00000000 --- a/src/components/ui/StationDetailsWindow/reviews/reviews/ReviewCard.stories.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import Box from '../../../../common/Box'; -import type { ReviewCardProps } from './ReviewCard'; -import ReviewCard from './ReviewCard'; -import ReviewCardSkeleton from './ReviewCardSkeleton'; - -const meta = { - title: 'UI/ReviewCard', - component: ReviewCard, - tags: ['autodocs'], - args: { - review: { - content: - '후면 주차가 어려운 충전소에요. 후면 주차가 어려운 충전소에요. 후면 주차가 어려운 충전소에요. ', - isDeleted: false, - isUpdated: false, - latestUpdateDate: '2023-07-30T15:11:40+00:00', - ratings: 4, - replySize: 3, - reviewId: 0, - memberId: 23884823, - }, - previewMode: false, - }, - argTypes: { - review: { - description: 'Review 객체를 전달하면 카드를 만듭니다.', - }, - previewMode: { - description: '수정 및 삭제 컨트롤러를 제거할 수 있습니다.', - }, - }, -} satisfies Meta<typeof ReviewCard>; - -export default meta; - -export const Default = (args: ReviewCardProps) => { - return ( - <Box width={80}> - <ReviewCard {...args} /> - <ReviewCard {...args} /> - <ReviewCard {...args} /> - </Box> - ); -}; - -export const Skeleton = (args: ReviewCardProps) => { - return ( - <Box width={80}> - <ReviewCard {...args} /> - <ReviewCardSkeleton /> - </Box> - ); -}; diff --git a/src/components/ui/StationInfoWindow/StationInfo.stories.tsx b/src/components/ui/StationInfoWindow/StationInfo.stories.tsx deleted file mode 100644 index 8242615c..00000000 --- a/src/components/ui/StationInfoWindow/StationInfo.stories.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type { Meta } from '@storybook/react'; -import styled from 'styled-components'; - -import Text from '@common/Text'; - -import type { Charger, StationDetails } from '@type'; - -import type { StationInfoProps } from './StationInfo'; -import StationInfo from './StationInfo'; - -const meta = { - title: 'UI/StationInfo', - component: StationInfo, - args: { - stationDetails: { - address: '서울특별시 강남구 테헤란로87길 22', - chargers: [ - { - capacity: 50, - latestUpdateTime: '2021-08-01T00:00:00.000Z', - method: '단독', - price: 0, - state: 'STANDBY', - type: 'DC_FAST', - }, - ] as Charger[], - companyName: '에스트래픽', - contact: '1566-1704', - detailLocation: '지하 4층 08번 기둥', - isParkingFree: false, - isPrivate: true, - latitude: 0, - longitude: 0, - operatingTime: '24시간 이용가능', - privateReason: '', - reportCount: 0, - stationId: 'ab12345', - stationName: '한국도심공항', - stationState: '내일부터 공사합니다.', - } as StationDetails, - handleCloseStationWindow: () => { - alert('마커 위의 충전소 정보창이 닫혔습니다.'); - }, - handleOpenStationDetail: () => { - alert('충전소 상세 정보창이 열렸습니다.'); - }, - }, - argTypes: { - handleCloseStationWindow: { - description: '마커 위의 충전소 정보창을 닫을 수 있습니다.', - }, - handleOpenStationDetail: { - description: '충전소 상세 정보창을 열 수 있습니다. 모바일에서만 보이는 버튼입니다.', - }, - }, -} satisfies Meta<typeof StationInfo>; - -export default meta; - -export const Default = (args: StationInfoProps) => { - return ( - <> - <Container> - <StationInfo {...args} /> - </Container> - <Text mt={5}>위 컨테이너는 실제 구글 api 디자인을 가져온 것입니다.</Text> - </> - ); -}; - -const Container = styled.div` - width: 32rem; - overflow: hidden; - background-color: white; - border-radius: 8px; - box-shadow: 0 2px 7px 1px rgba(0, 0, 0, 0.3); - line-height: normal; -`; diff --git a/src/components/ui/StationListWindow/fallbacks/StationSummaryCardSkeleton.stories.tsx b/src/components/ui/StationListWindow/fallbacks/StationSummaryCardSkeleton.stories.tsx deleted file mode 100644 index 3148d627..00000000 --- a/src/components/ui/StationListWindow/fallbacks/StationSummaryCardSkeleton.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { Meta } from '@storybook/react'; -import { css } from 'styled-components'; - -import List from '@common/List'; - -import StationSummaryCardSkeleton from './StationSummaryCardSkeleton'; - -const meta = { - title: 'UI/StationSummaryCardSkeleton', - component: StationSummaryCardSkeleton, -} satisfies Meta<typeof StationSummaryCardSkeleton>; - -export default meta; - -export const Default = () => { - return ( - <List css={listCss}> - <StationSummaryCardSkeleton /> - </List> - ); -}; - -const listCss = css` - left: 7rem; - bottom: 0; - width: 34rem; - border-top: 1.8rem solid var(--lighter-color); - border-bottom: 4rem solid var(--lighter-color); - border-top-left-radius: 30px; - border-top-right-radius: 30px; - background: var(--lighter-color); - overflow: auto; -`; diff --git a/src/components/ui/StationSearchWindow/SearchResult.stories.tsx b/src/components/ui/StationSearchWindow/SearchResult.stories.tsx deleted file mode 100644 index 906666e0..00000000 --- a/src/components/ui/StationSearchWindow/SearchResult.stories.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import type { Meta } from '@storybook/react'; -import { styled } from 'styled-components'; - -import type { SearchResultProps } from './SearchResult'; -import SearchResult from './SearchResult'; - -const meta = { - title: 'UI/SearchResult', - tags: ['autodocs'], - component: SearchResult, - args: { - cities: [ - { - cityName: '서울특별시 강동구 천호동', - latitude: 1, - longitude: 1, - }, - { - cityName: '서울특별시 강동구 명일동', - latitude: 1, - longitude: 1, - }, - { - cityName: '서울특별시 강동구 명일동 413-12번지 카페인 빌딩', - latitude: 1, - longitude: 1, - }, - ], - stations: [ - { - stationId: '0', - stationName: '충전소 이름이라네', - speed: 'quick', - address: '서울시 강남구 테헤란로 411', - latitude: 1, - longitude: 1, - }, - { - stationId: '1', - stationName: '허허', - speed: 'quick', - address: '서울시 강남구 테헤란로 411', - latitude: 1, - longitude: 1, - }, - { - stationId: '2', - stationName: '완전 엄청나게 이름이 긴 충전소를 테스트', - speed: 'standard', - address: '서울시 강남구 테헤란로 411 천호빌딩 지하 14층', - latitude: 1, - longitude: 1, - }, - ], - isLoading: false, - isError: false, - showStationDetails: () => { - (''); - }, - }, - argTypes: { - stations: { - description: - '검색된 충전소들입니다.<br /> 검색된 충전소의 개수가 0일 때는 검색 결과가 없습니다.', - }, - isLoading: { - description: 'true: 검색 결과를 가져오고 있습니다.<br /> false: 검색 결과를 가져왔습니다.', - }, - isError: { - description: 'true: 에러가 발생했습니다.<br /> false: 에러가 발생하지 않았습니다.', - }, - showStationDetails: { - description: '검색된 충전소를 클릭하면 해당 충전소로 이동하고, 상세정보가 나타납니다.', - }, - }, -} satisfies Meta<typeof SearchResult>; - -export default meta; - -// TODO: 스토리북 빌드 실패로 임시로 조치해뒀으니 수정 바랍니다. - -export const Default = ({ ...args }: SearchResultProps) => { - return ( - <Container> - <SearchResult {...args} /> - </Container> - ); -}; - -export const NoResult = () => { - return ( - <SubContainer> - <SearchResult - cities={[]} - stations={[]} - closeResult={() => null} - isError={false} - isLoading={false} - showStationDetails={() => null} - /> - </SubContainer> - ); -}; - -export const Error = () => { - return ( - <Container> - <SearchResult - cities={[]} - stations={[]} - closeResult={() => null} - isError={true} - isLoading={false} - showStationDetails={() => null} - /> - </Container> - ); -}; - -const Container = styled.div` - width: 34rem; - height: 16rem; -`; - -const SubContainer = styled(Container)` - height: 24rem; -`; diff --git a/src/components/ui/StationSearchWindow/StationSearchBar.stories.tsx b/src/components/ui/StationSearchWindow/StationSearchBar.stories.tsx deleted file mode 100644 index a07d0d68..00000000 --- a/src/components/ui/StationSearchWindow/StationSearchBar.stories.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; -import type { Meta } from '@storybook/react'; -import { styled } from 'styled-components'; - -import type { ChangeEvent, FocusEvent, FormEvent, MouseEvent } from 'react'; -import { useState } from 'react'; - -import { useQueryClient } from '@tanstack/react-query'; - -import { fetchSearchedStations, useSearchStations } from '@hooks/tanstack-query/useSearchStations'; -import { useDebounce } from '@hooks/useDebounce'; - -import Loader from '@common/Loader'; - -import { useNavigationBar } from '@ui/Navigator/NavigationBar/hooks/useNavigationBar'; -import StationDetailsWindow from '@ui/StationDetailsWindow'; - -import { MOBILE_BREAKPOINT } from '@constants'; -import { QUERY_KEY_SEARCHED_STATION, QUERY_KEY_STATION_MARKERS } from '@constants/queryKeys'; - -import { pillStyle } from '../../../style'; -import type { StationPosition } from '../../../types'; -import Button from '../../common/Button'; -import SearchResult from './SearchResult'; - -const meta = { - title: 'UI/StationSearchBar', - decorators: [ - (Story) => ( - <S.Container> - <Story /> - </S.Container> - ), - ], -} satisfies Meta; - -export default meta; - -// TODO: addon으로 googleMap 관련 함수 제외하기 -export const Default = () => { - const [isFocused, setIsFocused] = useState(false); - - const [searchWord, setSearchWord] = useState(''); - const [debouncedSearchWord, setDebouncedSearchWord] = useState(searchWord); - const queryClient = useQueryClient(); - const { openLastPanel } = useNavigationBar(); - - useDebounce( - () => { - setDebouncedSearchWord(searchWord); - }, - [searchWord], - 400 - ); - - const { - data: searchResult, - isLoading, - isError, - isFetching, - } = useSearchStations(debouncedSearchWord); - - const handleOpenResult = (event: MouseEvent<HTMLInputElement> | FocusEvent<HTMLInputElement>) => { - event.stopPropagation(); - setIsFocused(true); - }; - - const handleCloseResult = () => { - setIsFocused(false); - }; - - const handleSubmitSearchWord = async (event: FormEvent<HTMLFormElement>) => { - event.preventDefault(); - handleCloseResult(); - - const { stations } = await fetchSearchedStations(searchWord); - - if (stations !== undefined && stations.length > 0) { - const [{ stationId, latitude, longitude }] = stations; - showStationDetails({ stationId, latitude, longitude }); - } - - queryClient.invalidateQueries({ queryKey: [QUERY_KEY_SEARCHED_STATION] }); - }; - - const showStationDetails = ({ stationId, latitude, longitude }: StationPosition) => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY_STATION_MARKERS] }); - openLastPanel(<StationDetailsWindow stationId={stationId} />); - }; - - const handleChangeSearchWord = ({ target: { value } }: ChangeEvent<HTMLInputElement>) => { - const searchWord = encodeURIComponent(value); - - setIsFocused(true); - setSearchWord(searchWord); - }; - - return ( - <S.Container> - <S.Form role="search" onSubmit={handleSubmitSearchWord}> - <label htmlFor="station-search-bar" aria-hidden> - <S.Search - id="station-search-bar" - type="search" - role="searchbox" - placeholder="충전소명 또는 지역명을 입력해 주세요" - autoComplete="off" - onChange={handleChangeSearchWord} - onFocus={handleOpenResult} - onClick={handleOpenResult} - /> - <Button type="submit" aria-label="검색하기"> - {isFetching ? ( - <Loader size="md" /> - ) : ( - <MagnifyingGlassIcon width="2.4rem" stroke="#767676" /> - )} - </Button> - </label> - </S.Form> - {isFocused && searchResult && ( - <SearchResult - cities={searchResult.cities} - stations={searchResult.stations} - isLoading={isLoading} - isError={isError} - showStationDetails={showStationDetails} - closeResult={handleCloseResult} - /> - )} - </S.Container> - ); -}; - -const S = { - Container: styled.div` - width: 30rem; - - @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { - width: 100%; - } - `, - - Form: styled.form` - position: relative; - min-width: 30rem; - - @media screen and (max-width: ${MOBILE_BREAKPOINT}px) { - min-width: 100%; - } - `, - - Search: styled.input` - ${pillStyle}; - - background: #fcfcfc; - border: 1px solid #d0d2d8; - - width: 100%; - padding: 1.9rem 4.6rem 2rem 1.8rem; - font-size: 1.3rem; - - & + button { - position: absolute; - right: 2rem; - top: 50%; - transform: translateY(-50%); - } - - &:focus { - box-shadow: 0 1px 8px 0 rgba(0, 0, 0, 0.2); - outline: 0; - } - `, -}; diff --git a/src/components/ui/StationSearchWindow/StationSearchWindow.stories.tsx b/src/components/ui/StationSearchWindow/StationSearchWindow.stories.tsx deleted file mode 100644 index 32836a66..00000000 --- a/src/components/ui/StationSearchWindow/StationSearchWindow.stories.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { ChevronLeftIcon } from '@heroicons/react/24/outline'; -import type { Meta } from '@storybook/react'; -import { css, styled } from 'styled-components'; - -import Button from '../../common/Button'; -import Text from '../../common/Text'; -import Navigator from '../Navigator'; -import { Default as StationSearchBar } from './StationSearchBar.stories'; -import StationSearchWindow from './StationSearchWindow'; - -const meta = { - title: 'UI/StationSearchWindow', - component: StationSearchWindow, -} satisfies Meta<typeof StationSearchWindow>; - -export default meta; - -export const Default = () => { - return ( - <> - <Navigator /> - <S.Container> - <Button variant="label" aria-label="검색창 닫기"> - <ChevronLeftIcon width="2.4rem" stroke="#9c9fa7" /> - </Button> - <StationSearchBar /> - <Text tag="h2" fontSize={1.7} weight="bold" css={labelText}> - 주변 충전소 - </Text> - </S.Container> - </> - ); -}; - -const S = { - Container: styled.section` - width: 34rem; - height: 100vh; - background: #fcfcfc; - outline: 1.5px solid #e1e4eb; - padding: 2.8rem 2.2rem 5.2rem; - `, -}; - -const labelText = css` - padding: 3.6rem 0 2.2rem; -`; diff --git a/src/components/ui/StatisticsGraph/Graph/Bar.stories.tsx b/src/components/ui/StatisticsGraph/Graph/Bar.stories.tsx deleted file mode 100644 index 7040142a..00000000 --- a/src/components/ui/StatisticsGraph/Graph/Bar.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import Box from '@common/Box'; - -import { NO_RATIO } from '@constants/congestion'; - -import Bar from './Bar'; - -const meta = { - title: 'UI/Bar', - component: Bar, - tags: ['autodocs'], -} satisfies Meta<typeof Bar>; - -export default meta; - -export const Default = () => { - return Array.from({ length: 25 }, (_, index) => ( - <Box key={index} my={1}> - <Bar align="column" ratio={(index / 24) * 100} hour={String(index)} /> - </Box> - )); -}; - -export const NoRatio = () => { - return <Bar align="column" ratio={NO_RATIO} hour="1" />; -}; diff --git a/src/components/ui/StatisticsGraph/StatisticsGraph.stories.tsx b/src/components/ui/StatisticsGraph/StatisticsGraph.stories.tsx deleted file mode 100644 index 1507a30d..00000000 --- a/src/components/ui/StatisticsGraph/StatisticsGraph.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { Meta } from '@storybook/react'; - -import { ENGLISH_DAYS_OF_WEEK } from '@constants/congestion'; - -import StatisticsGraph from '.'; -import { getCongestionStatistics } from '../../../mocks/data'; - -const meta = { - title: 'UI/StatisticsGraph', - component: StatisticsGraph, - tags: ['autodocs'], -} satisfies Meta<typeof StatisticsGraph>; - -export default meta; - -// TODO: 스토리북 빌드 실패로 임시로 조치해뒀으니 수정 바랍니다. - -export const Column = () => { - return ( - <StatisticsGraph - statistics={getCongestionStatistics('1').congestion.quick.FRI} - align="column" - menus={[...ENGLISH_DAYS_OF_WEEK]} - dayOfWeek={'friday'} - isLoading={false} - onChangeDayOfWeek={() => null} - /> - ); -}; - -export const Row = () => { - return ( - <StatisticsGraph - statistics={getCongestionStatistics('1').congestion.quick.FRI} - align="row" - menus={[...ENGLISH_DAYS_OF_WEEK]} - dayOfWeek={'friday'} - isLoading={false} - onChangeDayOfWeek={() => null} - /> - ); -}; diff --git a/src/components/ui/StatisticsGraph/index.tsx b/src/components/ui/StatisticsGraph/index.tsx index 54370671..4b446e0f 100644 --- a/src/components/ui/StatisticsGraph/index.tsx +++ b/src/components/ui/StatisticsGraph/index.tsx @@ -1,4 +1,4 @@ -import type { GraphProps } from 'components/ui/StatisticsGraph/Graph'; +import type { GraphProps } from '@components/ui/StatisticsGraph/Graph'; import type { DayMenusProps } from '@ui/StatisticsGraph/Graph/DayMenus'; diff --git a/src/router/index.tsx b/src/router/index.tsx deleted file mode 100644 index af075673..00000000 --- a/src/router/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { createBrowserRouter } from 'react-router-dom'; - -import GoogleLogin from '@components/login-page/GoogleLogin'; - -import App from '../App'; - -export const router = createBrowserRouter([ - { - path: '/', - element: <App />, - }, - { - path: '/google', - element: <GoogleLogin />, - }, -]); diff --git a/src/types/index.ts b/src/types/index.ts index 3179a767..62b7b4f9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,4 +2,4 @@ export * from './stations'; export * from './chargers'; export * from './congestion'; export * from './style'; -export { DisplayPosition } from '@type/map'; +export type { DisplayPosition } from '@type/map'; diff --git a/tsconfig.json b/tsconfig.json index 60abaa14..a319ff5d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { - "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "target": "ES2022", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": false, @@ -19,25 +23,66 @@ } ], "paths": { - "@*": ["./src/*"], - "@mocks/*": ["./src/mocks/*"], - "@utils/*": ["./src/utils/*"], - "@map/*": ["./src/components/google-maps/map/*"], - "@marker/*": ["./src/components/google-maps/marker/*"], - "@ui/*": ["./src/components/ui/*"], - "@common/*": ["./src/components/common/*"], - "@components/*": ["./src/components/*"], - "@hooks/*": ["./src/hooks/*"], - "@stores/*": ["./src/stores/*"], - "@constants": ["./src/constants/index"], - "@constants/*": ["./src/constants/*"], - "@style": ["./src/style/index"], - "@style/*": ["./src/style/*"], - "@assets/*": ["./src/assets/*"], - "@type": ["./src/types/index"], - "@type/*": ["./src/types/*"] + "@*": [ + "./src/*" + ], + "@mocks/*": [ + "./src/mocks/*" + ], + "@utils/*": [ + "./src/utils/*" + ], + "@map/*": [ + "./src/components/google-maps/map/*" + ], + "@marker/*": [ + "./src/components/google-maps/marker/*" + ], + "@ui/*": [ + "./src/components/ui/*" + ], + "@common/*": [ + "./src/components/common/*" + ], + "@components/*": [ + "./src/components/*" + ], + "@hooks/*": [ + "./src/hooks/*" + ], + "@stores/*": [ + "./src/stores/*" + ], + "@constants": [ + "./src/constants/index" + ], + "@constants/*": [ + "./src/constants/*" + ], + "@style": [ + "./src/style/index" + ], + "@style/*": [ + "./src/style/*" + ], + "@assets/*": [ + "./src/assets/*" + ], + "@type": [ + "./src/types/index" + ], + "@type/*": [ + "./src/types/*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } From 48f0bdca79ccb112e2a7b002c2164075821fc6f8 Mon Sep 17 00:00:00 2001 From: gabrielyoon7 <gabrielyoon7@gmail.com> Date: Mon, 16 Oct 2023 15:12:19 +0900 Subject: [PATCH 12/13] =?UTF-8?q?chore:=20package-lock.json=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 219 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 209 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index e0351678..da5b6718 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,15 @@ "version": "0.1.0", "dependencies": { "@googlemaps/react-wrapper": "^1.1.35", + "@heroicons/react": "^2.0.18", + "@tanstack/react-query": "^4.29.25", + "@types/google.maps": "^3.53.4", + "@types/styled-components": "^5.1.26", "next": "13.5.4", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "react-icons": "^4.11.0", + "styled-components": "^6.0.4" }, "devDependencies": { "@types/node": "^20", @@ -19,6 +25,7 @@ "@types/react-dom": "^18", "eslint": "^8", "eslint-config-next": "13.5.4", + "prettier": "^3.0.1", "typescript": "^5" } }, @@ -43,6 +50,24 @@ "node": ">=6.9.0" } }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", + "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -118,6 +143,14 @@ "react": ">=16.8.0" } }, + "node_modules/@heroicons/react": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.0.18.tgz", + "integrity": "sha512-7TyMjRrZZMBPa+/5Y8lN0iyvUU/01PeMGX2+RE7cQWpEUIcb4QotzUObFkJDejj/HUH4qjP/eQ0gzzKs2f+6Yw==", + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz", @@ -349,6 +382,55 @@ "tslib": "^2.4.0" } }, + "node_modules/@tanstack/query-core": { + "version": "4.36.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.36.1.tgz", + "integrity": "sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "4.36.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.36.1.tgz", + "integrity": "sha512-y7ySVHFyyQblPl3J3eQBWpXZkliroki3ARnBKsdJchlgt7yJLRDUcf4B8soufgiYt3pEQIkBWBx1N9/ZPIeUWw==", + "dependencies": { + "@tanstack/query-core": "4.36.1", + "use-sync-external-store": "^1.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@types/google.maps": { + "version": "3.54.3", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.54.3.tgz", + "integrity": "sha512-Civz4GMtuzGQbic9J9OSpzNHaVA+YxwvBPfB98GXa2YwH/6Zb99+PPSqKQVniHIB4hOzPl46hmZiFpsckHkGIQ==" + }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.3.tgz", + "integrity": "sha512-Wny3a2UXn5FEA1l7gc6BbpoV5mD1XijZqgkp4TRgDCDL5r3B5ieOFGUX5h3n78Tr1MEG7BfvoM8qeztdvNU0fw==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -364,14 +446,12 @@ "node_modules/@types/prop-types": { "version": "15.7.8", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.8.tgz", - "integrity": "sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ==", - "dev": true + "integrity": "sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ==" }, "node_modules/@types/react": { "version": "18.2.25", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.25.tgz", "integrity": "sha512-24xqse6+VByVLIr+xWaQ9muX1B4bXJKXBbjszbld/UEDslGLY53+ZucF44HCmLbMPejTzGG9XgR+3m2/Wqu1kw==", - "dev": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -390,8 +470,22 @@ "node_modules/@types/scheduler": { "version": "0.16.4", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.4.tgz", - "integrity": "sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ==", - "dev": true + "integrity": "sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ==" + }, + "node_modules/@types/styled-components": { + "version": "5.1.28", + "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.28.tgz", + "integrity": "sha512-nu0VKNybkjvUqJAXWtRqKd7j3iRUl8GbYSTvZNuIBJcw/HUp1Y4QUXNLlj7gcnRV/t784JnHAlvRnSnE3nPbJA==", + "dependencies": { + "@types/hoist-non-react-statics": "*", + "@types/react": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/stylis": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.1.tgz", + "integrity": "sha512-OSaMrXUKxVigGlKRrET39V2xdhzlztQ9Aqumn1WbCBKHOi9ry7jKSd7rkyj0GzmWaU960Rd+LpOFpLfx5bMQAg==" }, "node_modules/@typescript-eslint/parser": { "version": "6.7.4", @@ -807,6 +901,14 @@ "node": ">=6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001546", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001546.tgz", @@ -885,11 +987,28 @@ "node": ">= 8" } }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/csstype": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "dev": true + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -1941,6 +2060,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -2876,6 +3003,11 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2885,6 +3017,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz", + "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -2948,11 +3095,18 @@ "react": "^18.2.0" } }, + "node_modules/react-icons": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.11.0.tgz", + "integrity": "sha512-V+4khzYcE5EBk/BvcuYRq6V/osf11ODUM2J8hg2FDSswRrGvqiYUYPRy4OdrWaQOBj4NcpJfmHZLNaD+VH0TyA==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", @@ -3149,6 +3303,11 @@ "node": ">= 0.4" } }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3307,6 +3466,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/styled-components": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.0.tgz", + "integrity": "sha512-VWNfYYBuXzuLS/QYEeoPgMErP26WL+dX9//rEh80B2mmlS1yRxRxuL5eax4m6ybYEUoHWlTy2XOU32767mlMkg==", + "dependencies": { + "@emotion/is-prop-valid": "^1.2.1", + "@emotion/unitless": "^0.8.0", + "@types/stylis": "^4.0.2", + "css-to-react-native": "^3.2.0", + "csstype": "^3.1.2", + "postcss": "^8.4.31", + "shallowequal": "^1.1.0", + "stylis": "^4.3.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + } + }, "node_modules/styled-jsx": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", @@ -3329,6 +3515,11 @@ } } }, + "node_modules/stylis": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.0.tgz", + "integrity": "sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ==" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3535,6 +3726,14 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", From 32a1271379bb53b377c8b724d45ed8580c9897ce Mon Sep 17 00:00:00 2001 From: gabrielyoon7 <gabrielyoon7@gmail.com> Date: Mon, 16 Oct 2023 15:30:03 +0900 Subject: [PATCH 13/13] =?UTF-8?q?refactor:=20=EB=A9=94=ED=83=80=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ae845621..e878b671 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,8 +5,8 @@ import { Inter } from 'next/font/google' const inter = Inter({ subsets: ['latin'] }) export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: '카페인', + description: '편리한 전기차 충전소 지도', } export default function RootLayout({ @@ -15,7 +15,7 @@ export default function RootLayout({ children: React.ReactNode }) { return ( - <html lang="en"> + <html lang="ko"> <body className={inter.className}>{children}</body> </html> )