diff --git a/.env b/.env new file mode 100644 index 00000000..5e4542e7 --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +# .env.local (또는 .env) +NEXT_PUBLIC_API_URL=https://panda-market-api.vercel.app +# NEXT_PUBLIC_MAPBOX_KEY=abc123xyz +# SECRET_JWT_KEY=super_secret_key \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..03025a5e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "cSpell.words": [ + "bootcamp", + "choicenews", + "chosun", + "codeit", + "Favorited", + "gstatic", + "hanatour", + "pinimg", + "pixabay", + "Pretendard", + "updatedtag", + "wccftech" + ] +} \ No newline at end of file diff --git a/next.config.js b/next.config.js index a843cbee..6fb7ac30 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,61 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, -} + experimental: { + appDir: true, // ✅ App Router 활성화 (Next 13 이상) + }, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "cdn.wccftech.com", + }, + { + protocol: "https", + hostname: "example.com", + }, + { + protocol: "https", + hostname: "image.hanatour.com", + }, + { + protocol: "https", + hostname: "cdn.choicenews.co.kr", + }, + { + protocol: "https", + hostname: "sprint-fe-project.s3.ap-northeast-2.amazonaws.com", + }, + { + protocol: "https", + hostname: "cdn.pixabay.com", + }, + { + protocol: "https", + hostname: "i.pinimg.com", + }, + { + protocol: "https", + hostname: "upload.wikimedia.org", + }, + { + protocol: "https", + hostname: "encrypted-tbn0.gstatic.com", + }, + { + protocol: "https", + hostname: "health.chosun.com", + }, + { + protocol: "https", + hostname: "via.placeholder.com", + }, + { + protocol: "https", + hostname: "images.unsplash.com", + }, + ], + }, +}; -module.exports = nextConfig +module.exports = nextConfig; diff --git a/package-lock.json b/package-lock.json index baa2b665..a7b7efcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,16 +8,23 @@ "name": "fe-weekly-mission", "version": "0.1.0", "dependencies": { - "next": "13.5.6", - "react": "^18", - "react-dom": "^18" + "@tanstack/react-query": "^5.74.4", + "axios": "^1.8.4", + "clsx": "^2.1.1", + "framer-motion": "^12.7.4", + "next": "^15.3.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { - "@types/node": "^20", - "@types/react": "^18", - "@types/react-dom": "^18", + "@types/node": "^20.17.30", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "autoprefixer": "^10.4.21", "eslint": "^8", "eslint-config-next": "13.5.6", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", "typescript": "^5" } }, @@ -30,6 +37,19 @@ "node": ">=0.10.0" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@babel/runtime": { "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.4.tgz", @@ -42,6 +62,16 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.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", @@ -131,10 +161,488 @@ "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", "dev": true }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.1.tgz", + "integrity": "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.1.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.1.tgz", + "integrity": "sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.1.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz", + "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz", + "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz", + "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", + "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz", + "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz", + "integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz", + "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz", + "integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.1.tgz", + "integrity": "sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.1.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.1.tgz", + "integrity": "sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.1.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.1.tgz", + "integrity": "sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.1.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.1.tgz", + "integrity": "sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.1.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.1.tgz", + "integrity": "sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.1.tgz", + "integrity": "sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.1.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.1.tgz", + "integrity": "sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.1.tgz", + "integrity": "sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.1.tgz", + "integrity": "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@next/env": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.6.tgz", - "integrity": "sha512-Yac/bV5sBGkkEXmAX5FWPS9Mmo2rthrOPRQQNfycJPkjUAUclomCPH7QFVCDQ4Mp2k2K1SSM6m0zrxYrOwtFQw==" + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.1.tgz", + "integrity": "sha512-cwK27QdzrMblHSn9DZRV+DQscHXRuJv6MydlJRpFSqJWZrTYMLzKDeyueJNN9MGd8NNiUKzDQADAf+dMLXX7YQ==", + "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { "version": "13.5.6", @@ -146,12 +654,13 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.6.tgz", - "integrity": "sha512-5nvXMzKtZfvcu4BhtV0KH1oGv4XEW+B+jOfmBdpFI3C7FrB/MfujRpWYSBBO64+qbW8pkZiSyQv9eiwnn5VIQA==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.1.tgz", + "integrity": "sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -161,12 +670,13 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.6.tgz", - "integrity": "sha512-6cgBfxg98oOCSr4BckWjLLgiVwlL3vlLj8hXg2b+nDgm4bC/qVXXLfpLB9FHdoDu4057hzywbxKvmYGmi7yUzA==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.1.tgz", + "integrity": "sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -176,12 +686,13 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.6.tgz", - "integrity": "sha512-txagBbj1e1w47YQjcKgSU4rRVQ7uF29YpnlHV5xuVUsgCUf2FmyfJ3CPjZUvpIeXCJAoMCFAoGnbtX86BK7+sg==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.1.tgz", + "integrity": "sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -191,12 +702,13 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.6.tgz", - "integrity": "sha512-cGd+H8amifT86ZldVJtAKDxUqeFyLWW+v2NlBULnLAdWsiuuN8TuhVBt8ZNpCqcAuoruoSWynvMWixTFcroq+Q==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.1.tgz", + "integrity": "sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -206,12 +718,13 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.6.tgz", - "integrity": "sha512-Mc2b4xiIWKXIhBy2NBTwOxGD3nHLmq4keFk+d4/WL5fMsB8XdJRdtUlL87SqVCTSaf1BRuQQf1HvXZcy+rq3Nw==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.1.tgz", + "integrity": "sha512-bfI4AMhySJbyXQIKH5rmLJ5/BP7bPwuxauTvVEiJ/ADoddaA9fgyNNCcsbu9SlqfHDoZmfI6g2EjzLwbsVTr5A==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -221,12 +734,13 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.6.tgz", - "integrity": "sha512-CFHvP9Qz98NruJiUnCe61O6GveKKHpJLloXbDSWRhqhkJdZD2zU5hG+gtVJR//tyW897izuHpM6Gtf6+sNgJPQ==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.1.tgz", + "integrity": "sha512-FeAbR7FYMWR+Z+M5iSGytVryKHiAsc0x3Nc3J+FD5NVbD5Mqz7fTSy8CYliXinn7T26nDMbpExRUI/4ekTvoiA==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -236,27 +750,13 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.6.tgz", - "integrity": "sha512-aFv1ejfkbS7PUa1qVPwzDHjQWQtknzAZWGTKYIAaS4NMtBlk3VyA6AYn593pqNanlicewqyl2jUhQAaFV/qXsg==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.1.tgz", + "integrity": "sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==", "cpu": [ "arm64" ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.6.tgz", - "integrity": "sha512-XqqpHgEIlBHvzwG8sp/JXMFkLAfGLqkbVsyN+/Ih1mR8INb6YCc2x/Mbwi6hsAgUnqQztz8cvEbHJUbSl7RHDg==", - "cpu": [ - "ia32" - ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -266,12 +766,13 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.6.tgz", - "integrity": "sha512-Cqfe1YmOS7k+5mGu92nl5ULkzpKuxJrP3+4AEuPmrpFZ3BHxTY3TnHmU1On3bFmFFs6FbTcdF58CCUProGpIGQ==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.1.tgz", + "integrity": "sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -312,7 +813,18 @@ "fastq": "^1.6.0" }, "engines": { - "node": ">= 8" + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" } }, "node_modules/@rushstack/eslint-patch": { @@ -321,12 +833,45 @@ "integrity": "sha512-2/U3GXA6YiPYQDLGwtGlnNgKYBSwCFIHf8Y9LUY5VATHdtbLlU0Y1R3QoBnT0aB4qv/BEiVVsj7LJXoQCgJ2vA==", "dev": true }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, "node_modules/@swc/helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", - "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", "dependencies": { - "tslib": "^2.4.0" + "tslib": "^2.8.0" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.74.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.74.4.tgz", + "integrity": "sha512-YuG0A0+3i9b2Gfo9fkmNnkUWh5+5cFhWBN0pJAHkHilTx6A0nv8kepkk4T4GRt4e5ahbtFj2eTtkiPcVU1xO4A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.74.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.74.4.tgz", + "integrity": "sha512-mAbxw60d4ffQ4qmRYfkO1xzRBPUEf/72Dgo3qqea0J66nIKuDTLEqQt0ku++SDFlMGMnB6uKDnEG1xD/TDse4Q==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.74.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" } }, "node_modules/@types/json5": { @@ -336,12 +881,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.9.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.4.tgz", - "integrity": "sha512-wmyg8HUhcn6ACjsn8oKYjkN/zUzQeNtMy44weTJSM6p4MMzEOuKbA3OjJ267uPCOW7Xex9dyrNTful8XTQYoDA==", + "version": "20.17.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", + "integrity": "sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/prop-types": { @@ -351,10 +897,11 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.2.38", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.38.tgz", - "integrity": "sha512-cBBXHzuPtQK6wNthuVMV6IjHAFkdl/FOPFIlkd81/Cd1+IqkHu/A+w4g43kaQQoYHik/ruaQBDL72HyCy1vuMw==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.0.tgz", + "integrity": "sha512-0FLj93y5USLHdnhIhABk83rm8XEGA7kH3cr+YUlvxoUGp1xNt/DINUMvqPxLyOQMzLmZe8i4RTHbvb8MC7NmrA==", "dev": true, + "license": "MIT", "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -362,10 +909,11 @@ } }, "node_modules/@types/react-dom": { - "version": "18.2.17", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.17.tgz", - "integrity": "sha512-rvrT/M7Df5eykWFxn6MYt5Pem/Dbyc1N8Y0S9Mrkw2WFCRiqUgw9P7ul2NpwsXCSM1DVdENzdG9J5SreqfAIWg==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-8yQrvS6sMpSwIovhPOwfyNf2Wz6v/B62LFSVYQ85+Rq3tLsBIG7rP5geMxaijTUxSkrO6RzN/IRuIAADYQsleA==", "dev": true, + "license": "MIT", "dependencies": { "@types/react": "*" } @@ -545,6 +1093,34 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -705,6 +1281,50 @@ "has-symbols": "^1.0.3" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -726,6 +1346,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", + "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", @@ -741,6 +1372,19 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -752,17 +1396,51 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -788,6 +1466,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -797,10 +1488,20 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { - "version": "1.0.30001564", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001564.tgz", - "integrity": "sha512-DqAOf+rhof+6GVx1y+xzbFPeOumfQnhYzVnZD6LAXijR77yPtm9mfOcqOnT3mpnJiZVT+kwLAFnRlZcIz+c6bg==", + "version": "1.0.30001715", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz", + "integrity": "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==", "funding": [ { "type": "opencollective", @@ -814,7 +1515,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "4.1.2", @@ -832,16 +1534,78 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/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, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "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==" + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } }, "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, + "devOptional": true, "dependencies": { "color-name": "~1.1.4" }, @@ -853,7 +1617,40 @@ "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 + "devOptional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } }, "node_modules/concat-map": { "version": "0.0.1", @@ -862,10 +1659,11 @@ "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==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -875,6 +1673,19 @@ "node": ">= 8" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", @@ -941,6 +1752,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -950,6 +1770,23 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -962,6 +1799,13 @@ "node": ">=8" } }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -974,6 +1818,34 @@ "node": ">=6.0.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.139", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.139.tgz", + "integrity": "sha512-GGnRYOTdN5LYpwbIr0rwP/ZHOQSvAF6TG0LSzp28uCBb9JiXHJGmaaKw29qjNJc5bGnnp6kXJqRnGMQoELwi5w==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -1046,6 +1918,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-iterator-helpers": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", @@ -1068,15 +1958,28 @@ "safe-array-concat": "^1.0.1" } }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-set-tostringtag": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", - "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", - "dev": true, + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.2", - "has-tostringtag": "^1.0.0", - "hasown": "^2.0.0" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -1108,6 +2011,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1598,10 +2511,11 @@ } }, "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==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -1645,13 +2559,106 @@ "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==", + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.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/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.7.4", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.7.4.tgz", + "integrity": "sha512-jX0bPsTmU0oPZTYz/dVyD0dmOyEOEJvdn0TaZBE5I8g2GvVnnQnW9f65cJnoVfUkY3WZWNXGXnPbVA9YnaIfVA==", + "license": "MIT", "dependencies": { - "is-callable": "^1.1.3" + "motion-dom": "^12.7.4", + "motion-utils": "^12.7.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } } }, "node_modules/fs.realpath": { @@ -1660,11 +2667,25 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -1697,20 +2718,42 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", - "dev": true, - "dependencies": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-symbol-description": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", @@ -1771,11 +2814,6 @@ "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", @@ -1827,12 +2865,12 @@ } }, "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" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -1841,7 +2879,8 @@ "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==" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true }, "node_modules/graphemer": { "version": "1.4.0", @@ -1892,10 +2931,10 @@ } }, "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, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -1904,12 +2943,12 @@ } }, "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, + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", "dependencies": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -1919,10 +2958,10 @@ } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", - "dev": true, + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -2008,6 +3047,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT", + "optional": true + }, "node_modules/is-async-function": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", @@ -2035,6 +3081,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", @@ -2111,6 +3170,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", @@ -2164,6 +3233,7 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -2333,6 +3403,32 @@ "set-function-name": "^2.0.1" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2435,6 +3531,26 @@ "node": ">= 0.8.0" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2468,15 +3584,19 @@ } }, "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==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 0.4" } }, "node_modules/merge2": { @@ -2489,18 +3609,40 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2522,22 +3664,60 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/motion-dom": { + "version": "12.7.4", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.7.4.tgz", + "integrity": "sha512-1ZUHAoSUMMxP6jPqyxlk9XUfb6NxMsnWPnH2YGhrOhTURLcXWbETi6eemoKb60Pe32NVJYduL4B62VQSO5Jq8Q==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.7.2" + } + }, + "node_modules/motion-utils": { + "version": "12.7.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.7.2.tgz", + "integrity": "sha512-XhZwqctxyJs89oX00zn3OGCuIIpVevbTa+u82usWBC6pSHUd2AoNWiYa7Du8tJxJy9TFbZ82pcn5t7NOm1PHAw==", + "license": "MIT" + }, "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/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -2552,50 +3732,114 @@ "dev": true }, "node_modules/next": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/next/-/next-13.5.6.tgz", - "integrity": "sha512-Y2wTcTbO4WwEsVb4A8VSnOsG1I9ok+h74q0ZdxkwM3EODqrs4pasq7O0iUxbcS9VtWMicG7f3+HAj0r1+NtKSw==", - "dependencies": { - "@next/env": "13.5.6", - "@swc/helpers": "0.5.2", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/next/-/next-15.3.1.tgz", + "integrity": "sha512-8+dDV0xNLOgHlyBxP1GwHGVaNXsmp+2NhZEYrXr24GWLHtt27YrBPbPuHvzlhi7kZNYjeJNR93IF5zfFu5UL0g==", + "license": "MIT", + "dependencies": { + "@next/env": "15.3.1", + "@swc/counter": "0.1.3", + "@swc/helpers": "0.5.15", "busboy": "1.6.0", - "caniuse-lite": "^1.0.30001406", + "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", - "styled-jsx": "5.1.1", - "watchpack": "2.4.0" + "styled-jsx": "5.1.6" }, "bin": { "next": "dist/bin/next" }, "engines": { - "node": ">=16.14.0" + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "13.5.6", - "@next/swc-darwin-x64": "13.5.6", - "@next/swc-linux-arm64-gnu": "13.5.6", - "@next/swc-linux-arm64-musl": "13.5.6", - "@next/swc-linux-x64-gnu": "13.5.6", - "@next/swc-linux-x64-musl": "13.5.6", - "@next/swc-win32-arm64-msvc": "13.5.6", - "@next/swc-win32-ia32-msvc": "13.5.6", - "@next/swc-win32-x64-msvc": "13.5.6" + "@next/swc-darwin-arm64": "15.3.1", + "@next/swc-darwin-x64": "15.3.1", + "@next/swc-linux-arm64-gnu": "15.3.1", + "@next/swc-linux-arm64-musl": "15.3.1", + "@next/swc-linux-x64-gnu": "15.3.1", + "@next/swc-linux-x64-musl": "15.3.1", + "@next/swc-win32-arm64-msvc": "15.3.1", + "@next/swc-win32-x64-msvc": "15.3.1", + "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "@playwright/test": "^1.41.2", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "peerDependenciesMeta": { "@opentelemetry/api": { "optional": true }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, "sass": { "optional": true } } }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2605,6 +3849,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -2770,6 +4024,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2815,6 +4076,23 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -2825,9 +4103,10 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -2841,10 +4120,31 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, "funding": [ { "type": "opencollective", @@ -2859,15 +4159,101 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=4" } }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2888,6 +4274,12 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2921,6 +4313,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" }, @@ -2932,6 +4325,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -2946,6 +4340,29 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -3113,13 +4530,11 @@ } }, "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" - }, + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "devOptional": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -3156,6 +4571,47 @@ "node": ">= 0.4" } }, + "node_modules/sharp": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.1.tgz", + "integrity": "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.7.1" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.1", + "@img/sharp-darwin-x64": "0.34.1", + "@img/sharp-libvips-darwin-arm64": "1.1.0", + "@img/sharp-libvips-darwin-x64": "1.1.0", + "@img/sharp-libvips-linux-arm": "1.1.0", + "@img/sharp-libvips-linux-arm64": "1.1.0", + "@img/sharp-libvips-linux-ppc64": "1.1.0", + "@img/sharp-libvips-linux-s390x": "1.1.0", + "@img/sharp-libvips-linux-x64": "1.1.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", + "@img/sharp-libvips-linuxmusl-x64": "1.1.0", + "@img/sharp-linux-arm": "0.34.1", + "@img/sharp-linux-arm64": "0.34.1", + "@img/sharp-linux-s390x": "0.34.1", + "@img/sharp-linux-x64": "0.34.1", + "@img/sharp-linuxmusl-arm64": "0.34.1", + "@img/sharp-linuxmusl-x64": "0.34.1", + "@img/sharp-wasm32": "0.34.1", + "@img/sharp-win32-ia32": "0.34.1", + "@img/sharp-win32-x64": "0.34.1" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3191,6 +4647,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -3201,9 +4680,10 @@ } }, "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==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -3216,6 +4696,76 @@ "node": ">=10.0.0" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz", @@ -3293,6 +4843,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "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, + "license": "MIT", + "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", @@ -3315,9 +4879,10 @@ } }, "node_modules/styled-jsx": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", - "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", "dependencies": { "client-only": "0.0.1" }, @@ -3325,7 +4890,7 @@ "node": ">= 12.0.0" }, "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "peerDependenciesMeta": { "@babel/core": { @@ -3336,6 +4901,76 @@ } } }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3360,6 +4995,80 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -3375,11 +5084,35 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "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, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -3399,6 +5132,13 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tsconfig-paths": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", @@ -3412,9 +5152,10 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -3534,10 +5275,42 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } }, "node_modules/uri-js": { "version": "4.4.1", @@ -3548,17 +5321,12 @@ "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" }, "node_modules/which": { "version": "2.0.2", @@ -3651,17 +5419,125 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "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/yaml": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } }, "node_modules/yocto-queue": { "version": "0.1.0", diff --git a/package.json b/package.json index 1ce24924..d53c60e7 100644 --- a/package.json +++ b/package.json @@ -9,16 +9,23 @@ "lint": "next lint" }, "dependencies": { - "react": "^18", - "react-dom": "^18", - "next": "13.5.6" + "@tanstack/react-query": "^5.74.4", + "axios": "^1.8.4", + "clsx": "^2.1.1", + "framer-motion": "^12.7.4", + "next": "^15.3.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { - "typescript": "^5", - "@types/node": "^20", - "@types/react": "^18", - "@types/react-dom": "^18", + "@types/node": "^20.17.30", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "autoprefixer": "^10.4.21", "eslint": "^8", - "eslint-config-next": "13.5.6" + "eslint-config-next": "13.5.6", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", + "typescript": "^5" } } diff --git a/pages/_app.tsx b/pages/_app.tsx deleted file mode 100644 index 021681f4..00000000 --- a/pages/_app.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import '@/styles/globals.css' -import type { AppProps } from 'next/app' - -export default function App({ Component, pageProps }: AppProps) { - return -} diff --git a/pages/_document.tsx b/pages/_document.tsx deleted file mode 100644 index 54e8bf3e..00000000 --- a/pages/_document.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Html, Head, Main, NextScript } from 'next/document' - -export default function Document() { - return ( - - - -
- - - - ) -} diff --git a/pages/api/hello.ts b/pages/api/hello.ts deleted file mode 100644 index f8bcc7e5..00000000 --- a/pages/api/hello.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Next.js API route support: https://nextjs.org/docs/api-routes/introduction -import type { NextApiRequest, NextApiResponse } from 'next' - -type Data = { - name: string -} - -export default function handler( - req: NextApiRequest, - res: NextApiResponse -) { - res.status(200).json({ name: 'John Doe' }) -} diff --git a/pages/index.tsx b/pages/index.tsx deleted file mode 100644 index 02c4dee0..00000000 --- a/pages/index.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import Head from 'next/head' -import Image from 'next/image' -import { Inter } from 'next/font/google' -import styles from '@/styles/Home.module.css' - -const inter = Inter({ subsets: ['latin'] }) - -export default function Home() { - return ( - <> - - Create Next App - - - - -
-
-

- Get started by editing  - pages/index.tsx -

- -
- -
- Next.js Logo -
- - -
- - ) -} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 00000000..33ad091d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/assets/6176.png b/public/assets/6176.png new file mode 100644 index 00000000..16a36b74 Binary files /dev/null and b/public/assets/6176.png differ diff --git a/public/assets/Editor_Icon.png b/public/assets/Editor_Icon.png new file mode 100644 index 00000000..c4ffa747 Binary files /dev/null and b/public/assets/Editor_Icon.png differ diff --git a/public/assets/Group_2.png b/public/assets/Group_2.png new file mode 100644 index 00000000..0b8595f8 Binary files /dev/null and b/public/assets/Group_2.png differ diff --git a/public/assets/Group_3.png b/public/assets/Group_3.png new file mode 100644 index 00000000..393b7754 Binary files /dev/null and b/public/assets/Group_3.png differ diff --git a/public/assets/Img_home_01.png b/public/assets/Img_home_01.png new file mode 100644 index 00000000..b6495aa8 Binary files /dev/null and b/public/assets/Img_home_01.png differ diff --git a/public/assets/Img_home_02.png b/public/assets/Img_home_02.png new file mode 100644 index 00000000..eda9ee15 Binary files /dev/null and b/public/assets/Img_home_02.png differ diff --git a/public/assets/Img_home_03.png b/public/assets/Img_home_03.png new file mode 100644 index 00000000..ded1b586 Binary files /dev/null and b/public/assets/Img_home_03.png differ diff --git a/public/assets/Img_home_bottom.png b/public/assets/Img_home_bottom.png new file mode 100644 index 00000000..f8b16d55 Binary files /dev/null and b/public/assets/Img_home_bottom.png differ diff --git a/public/assets/Img_home_top.png b/public/assets/Img_home_top.png new file mode 100644 index 00000000..dcb2304e Binary files /dev/null and b/public/assets/Img_home_top.png differ diff --git a/public/assets/Vector.png b/public/assets/Vector.png new file mode 100644 index 00000000..fd11075b Binary files /dev/null and b/public/assets/Vector.png differ diff --git a/public/assets/eye_1.svg b/public/assets/eye_1.svg new file mode 100644 index 00000000..43a5af17 --- /dev/null +++ b/public/assets/eye_1.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/eye_2.svg b/public/assets/eye_2.svg new file mode 100644 index 00000000..43cfd033 --- /dev/null +++ b/public/assets/eye_2.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/assets/gg_icon.png b/public/assets/gg_icon.png new file mode 100644 index 00000000..f75dc761 Binary files /dev/null and b/public/assets/gg_icon.png differ diff --git a/public/assets/google.png b/public/assets/google.png new file mode 100644 index 00000000..0c4b3886 Binary files /dev/null and b/public/assets/google.png differ diff --git a/public/assets/heart_1.svg b/public/assets/heart_1.svg new file mode 100644 index 00000000..7b994fbe --- /dev/null +++ b/public/assets/heart_1.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/heart_2.svg b/public/assets/heart_2.svg new file mode 100644 index 00000000..a896bef5 --- /dev/null +++ b/public/assets/heart_2.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/ic_3_01.png b/public/assets/ic_3_01.png new file mode 100644 index 00000000..4c16d540 Binary files /dev/null and b/public/assets/ic_3_01.png differ diff --git a/public/assets/ic_X.svg b/public/assets/ic_X.svg new file mode 100644 index 00000000..f6674f7f --- /dev/null +++ b/public/assets/ic_X.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/assets/ic_arrow_down.svg b/public/assets/ic_arrow_down.svg new file mode 100644 index 00000000..8308690f --- /dev/null +++ b/public/assets/ic_arrow_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/ic_back.svg b/public/assets/ic_back.svg new file mode 100644 index 00000000..8db5377e --- /dev/null +++ b/public/assets/ic_back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/assets/ic_check.svg b/public/assets/ic_check.svg new file mode 100644 index 00000000..baa4aa3b --- /dev/null +++ b/public/assets/ic_check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/assets/ic_facebook.svg b/public/assets/ic_facebook.svg new file mode 100644 index 00000000..b9c9d493 --- /dev/null +++ b/public/assets/ic_facebook.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/ic_instagram.svg b/public/assets/ic_instagram.svg new file mode 100644 index 00000000..0b9337b0 --- /dev/null +++ b/public/assets/ic_instagram.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/ic_kebab.svg b/public/assets/ic_kebab.svg new file mode 100644 index 00000000..dd7ed7f5 --- /dev/null +++ b/public/assets/ic_kebab.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/assets/ic_plus.svg b/public/assets/ic_plus.svg new file mode 100644 index 00000000..5bb9abf5 --- /dev/null +++ b/public/assets/ic_plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/assets/ic_search.svg b/public/assets/ic_search.svg new file mode 100644 index 00000000..52241e6d --- /dev/null +++ b/public/assets/ic_search.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/ic_search_darker.svg b/public/assets/ic_search_darker.svg new file mode 100644 index 00000000..6750ee04 --- /dev/null +++ b/public/assets/ic_search_darker.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/ic_sort.svg b/public/assets/ic_sort.svg new file mode 100644 index 00000000..657b44f9 --- /dev/null +++ b/public/assets/ic_sort.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/assets/ic_twitter.svg b/public/assets/ic_twitter.svg new file mode 100644 index 00000000..14a6069a --- /dev/null +++ b/public/assets/ic_twitter.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/ic_youtube.svg b/public/assets/ic_youtube.svg new file mode 100644 index 00000000..699b5380 --- /dev/null +++ b/public/assets/ic_youtube.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/assets/image_01.png b/public/assets/image_01.png new file mode 100644 index 00000000..2e6b3845 Binary files /dev/null and b/public/assets/image_01.png differ diff --git a/public/assets/image_02.png b/public/assets/image_02.png new file mode 100644 index 00000000..89cf79cc Binary files /dev/null and b/public/assets/image_02.png differ diff --git a/public/assets/image_03.png b/public/assets/image_03.png new file mode 100644 index 00000000..8e5b495a Binary files /dev/null and b/public/assets/image_03.png differ diff --git a/public/assets/image_04.png b/public/assets/image_04.png new file mode 100644 index 00000000..8a860c5b Binary files /dev/null and b/public/assets/image_04.png differ diff --git a/public/assets/image_05.png b/public/assets/image_05.png new file mode 100644 index 00000000..a84b2160 Binary files /dev/null and b/public/assets/image_05.png differ diff --git a/public/assets/img/Img_inquiry_empty.png b/public/assets/img/Img_inquiry_empty.png new file mode 100644 index 00000000..e947ab0b Binary files /dev/null and b/public/assets/img/Img_inquiry_empty.png differ diff --git a/public/assets/img/Img_inquiry_empty_2x.png b/public/assets/img/Img_inquiry_empty_2x.png new file mode 100644 index 00000000..7b7510c6 Binary files /dev/null and b/public/assets/img/Img_inquiry_empty_2x.png differ diff --git a/public/assets/img/img_1.jpg b/public/assets/img/img_1.jpg new file mode 100644 index 00000000..a038f9b7 Binary files /dev/null and b/public/assets/img/img_1.jpg differ diff --git a/public/assets/img/img_2.jpg b/public/assets/img/img_2.jpg new file mode 100644 index 00000000..c03b4e20 Binary files /dev/null and b/public/assets/img/img_2.jpg differ diff --git a/public/assets/img/img_3.jpg b/public/assets/img/img_3.jpg new file mode 100644 index 00000000..15bde269 Binary files /dev/null and b/public/assets/img/img_3.jpg differ diff --git a/public/assets/img/img_4.jpg b/public/assets/img/img_4.jpg new file mode 100644 index 00000000..5eb5f123 Binary files /dev/null and b/public/assets/img/img_4.jpg differ diff --git a/public/assets/img/img_default.png b/public/assets/img/img_default.png new file mode 100644 index 00000000..7b3ca0b2 Binary files /dev/null and b/public/assets/img/img_default.png differ diff --git a/public/assets/img/img_default.svg b/public/assets/img/img_default.svg new file mode 100644 index 00000000..67a2f224 --- /dev/null +++ b/public/assets/img/img_default.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/assets/img/img_default_2x.png b/public/assets/img/img_default_2x.png new file mode 100644 index 00000000..2c07c28d Binary files /dev/null and b/public/assets/img/img_default_2x.png differ diff --git a/public/assets/img/img_default_3x.png b/public/assets/img/img_default_3x.png new file mode 100644 index 00000000..879255da Binary files /dev/null and b/public/assets/img/img_default_3x.png differ diff --git a/public/assets/img_badge.svg b/public/assets/img_badge.svg new file mode 100644 index 00000000..8470f48f --- /dev/null +++ b/public/assets/img_badge.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/assets/kakao_icon.png b/public/assets/kakao_icon.png new file mode 100644 index 00000000..bd767800 Binary files /dev/null and b/public/assets/kakao_icon.png differ diff --git a/public/assets/logo_01.svg b/public/assets/logo_01.svg new file mode 100644 index 00000000..f290e713 --- /dev/null +++ b/public/assets/logo_01.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/assets/logo_02.ico b/public/assets/logo_02.ico new file mode 100644 index 00000000..65e81f0f Binary files /dev/null and b/public/assets/logo_02.ico differ diff --git a/public/assets/logo_03.png b/public/assets/logo_03.png new file mode 100644 index 00000000..66bb5d0e Binary files /dev/null and b/public/assets/logo_03.png differ diff --git a/public/assets/logo_03.svg b/public/assets/logo_03.svg new file mode 100644 index 00000000..43590bb7 --- /dev/null +++ b/public/assets/logo_03.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/logo_04.png b/public/assets/logo_04.png new file mode 100644 index 00000000..8c27efed Binary files /dev/null and b/public/assets/logo_04.png differ diff --git a/public/assets/status_active-1.svg b/public/assets/status_active-1.svg new file mode 100644 index 00000000..0ad718ef --- /dev/null +++ b/public/assets/status_active-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/status_active.svg b/public/assets/status_active.svg new file mode 100644 index 00000000..4b110c20 --- /dev/null +++ b/public/assets/status_active.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/status_inactive-1.svg b/public/assets/status_inactive-1.svg new file mode 100644 index 00000000..764302b6 --- /dev/null +++ b/public/assets/status_inactive-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/status_inactive.svg b/public/assets/status_inactive.svg new file mode 100644 index 00000000..1daeca5c --- /dev/null +++ b/public/assets/status_inactive.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/status_white-1.svg b/public/assets/status_white-1.svg new file mode 100644 index 00000000..c3d3f7d4 --- /dev/null +++ b/public/assets/status_white-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/status_white.svg b/public/assets/status_white.svg new file mode 100644 index 00000000..3ec2fc77 --- /dev/null +++ b/public/assets/status_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index 718d6fea..00000000 Binary files a/public/favicon.ico and /dev/null differ diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28c..00000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index d2f84222..00000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/app/Privacy/page.tsx b/src/app/Privacy/page.tsx new file mode 100644 index 00000000..053ca9f0 --- /dev/null +++ b/src/app/Privacy/page.tsx @@ -0,0 +1,12 @@ +'use client'; +import React from 'react'; + +function Privacy() { + return ( + <> +

privacy

+ + ); +} + +export default Privacy; \ No newline at end of file diff --git a/src/app/boards/page.tsx b/src/app/boards/page.tsx new file mode 100644 index 00000000..69b3cc18 --- /dev/null +++ b/src/app/boards/page.tsx @@ -0,0 +1,14 @@ + + + +import React from 'react'; + +function Boards() { + return ( + <> +

Boards

+ + ); +} + +export default Boards; \ No newline at end of file diff --git a/src/app/faq/page.tsx b/src/app/faq/page.tsx new file mode 100644 index 00000000..ae9957dd --- /dev/null +++ b/src/app/faq/page.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +function Faq() { + return ( + <> +

faq

+ + ); +} + +export default Faq; \ No newline at end of file diff --git a/src/app/global.css b/src/app/global.css new file mode 100644 index 00000000..27bab466 --- /dev/null +++ b/src/app/global.css @@ -0,0 +1,181 @@ +@font-face { + font-family: "Pretendard-Regular"; + src: url("https://fastly.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-Regular.woff") + format("woff"); + font-weight: 400; + font-style: normal; +} +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + box-sizing: border-box; + margin: 0; +} + +body { + font-family: "Pretendard-Regular"; + font-size: 16px; + min-width: 355px; + min-height: 100vh; + font-weight: 400; + overflow-x: hidden; + color: var(--Secondary); +} +a { + text-decoration: none; +} + +input, +textarea, +textarea::placeholder { + font-size: 16px; + font-weight: 400; + font-family: "Pretendard-Regular"; +} +input::placeholder, +textarea::placeholder { + color: #9ca3af; +} + +input:hover, +input:focus { + outline: 1px solid #3692ff; +} +input::placeholder { + color: #9ca3af; +} +input[type="submit"] { + outline: 0; +} + +ul { + list-style: none; + padding-left: 0; +} + +:root { + --Secondary: #374151; + --Secondary_200: #e5e7eb; + + --Primary: #3692ff; + --Primary_200: #1967d6; + --Primary_300: #1251aa; + --Cool_Gray_900: #111827; + --Cool_Gray_800: #1f2937; + --Cool_Gray_700: #374151; + --Cool_Gray_600: #4b5563; + --Cool_Gray_500: #6b7280; + --Cool_Gray_400: #9ca3af; + --Cool_Gray_200: #e5e7eb; + --Cool_Gray_100: #f3f4f6; + --Cool_Gray_50: #f9fafb; + --Error_red_50: #f74747; + + --padding-24: 24px; + --padding-16: 16px; + + --min-width: 325px; + --max-width: 1200px; +} + +/* Background color classes */ +.bg-secondary { + background-color: var(--Secondary); +} +.bg-secondary-200 { + background-color: var(--Secondary_200); +} + +.bg-primary { + background-color: var(--Primary); +} +.bg-primary-200 { + background-color: var(--Primary_200); +} +.bg-primary-300 { + background-color: var(--Primary_300); +} + +.bg-gray-900 { + background-color: var(--Cool_Gray_900); +} +.bg-gray-800 { + background-color: var(--Cool_Gray_800); +} +.bg-gray-700 { + background-color: var(--Cool_Gray_700); +} +.bg-gray-600 { + background-color: var(--Cool_Gray_600); +} +.bg-gray-500 { + background-color: var(--Cool_Gray_500); +} +.bg-gray-400 { + background-color: var(--Cool_Gray_400); +} +.bg-gray-200 { + background-color: var(--Cool_Gray_200); +} +.bg-gray-100 { + background-color: var(--Cool_Gray_100); +} +.bg-gray-50 { + background-color: var(--Cool_Gray_50); +} + +.bg-error-red { + background-color: var(--Error_red_50); +} + +/* Text color classes */ +.text-secondary { + color: var(--Secondary); +} +.text-secondary-200 { + color: var(--Secondary_200); +} + +.text-primary { + color: var(--Primary); +} +.text-primary-200 { + color: var(--Primary_200); +} +.text-primary-300 { + color: var(--Primary_300); +} + +.text-gray-900 { + color: var(--Cool_Gray_900); +} +.text-gray-800 { + color: var(--Cool_Gray_800); +} +.text-gray-700 { + color: var(--Cool_Gray_700); +} +.text-gray-600 { + color: var(--Cool_Gray_600); +} +.text-gray-500 { + color: var(--Cool_Gray_500); +} +.text-gray-400 { + color: var(--Cool_Gray_400); +} +.text-gray-200 { + color: var(--Cool_Gray_200); +} +.text-gray-100 { + color: var(--Cool_Gray_100); +} +.text-gray-50 { + color: var(--Cool_Gray_50); +} + +.text-error-red { + color: var(--Error_red_50); +} diff --git a/src/app/items/ItemsBox.module.css b/src/app/items/ItemsBox.module.css new file mode 100644 index 00000000..4f60c230 --- /dev/null +++ b/src/app/items/ItemsBox.module.css @@ -0,0 +1,84 @@ +.title { + font-size: 20px; + line-height: 42px; + font-weight: 700; +} +.bestProdListTitle { + margin-top: 24px; + font-size: 20px; + margin-bottom: 16px; + font-weight: 700; +} +.prodListTitle { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 40px; + margin-bottom: 24px; + z-index: 5; +} + +.prodListTitle > div { + display: flex; + gap: 12px; +} + +.prodSearchWrap { + position: relative; +} +.prodSearch input[type="text"] { + border: 0; + line-height: 26px; + height: 42px; + width: 325px; + background-color: #f3f4f6; + padding: 9px 24px; + padding-left: 44px; + font-size: 16px; + border-radius: 12px; + border: 0; +} +.prodSearch input[type="text"]:focus, +.prodSearch input[type="text"]:hover, +.prodSearch input[type="text"]:active { + background-color: #eaedf1; + border: 0; + outline: 0; +} + +.prodSearch img { + position: absolute; + left: 16px; + top: 10px; + width: 24px; +} + +/* tablet */ +@media (max-width: 1199px) { +} +/* Mobile */ +@media (max-width: 767px) { + .prodListTitle { + position: relative; + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + .prodListTitle > div { + width: 100%; + } + .prodSearch { + width: 100%; + } + .prodSearchWrap { + width: 100%; + } + .prodAddBtn { + position: absolute; + top: 0; + right: 0; + } + .prodSearch input[type="text"] { + width: 100%; + } +} diff --git a/src/app/items/[id]/ItemsDetail.module.css b/src/app/items/[id]/ItemsDetail.module.css new file mode 100644 index 00000000..c75d5e57 --- /dev/null +++ b/src/app/items/[id]/ItemsDetail.module.css @@ -0,0 +1,3 @@ +.items_detail { + margin-bottom: 150px; +} diff --git a/src/app/items/[id]/page.tsx b/src/app/items/[id]/page.tsx new file mode 100644 index 00000000..237bb5e4 --- /dev/null +++ b/src/app/items/[id]/page.tsx @@ -0,0 +1,17 @@ +'use client'; +import React from 'react'; +import styles from './ItemsDetail.module.css'; +import ProductDetails from '@/components/ItemsDetail/ProductDetails'; + +function ItemsDetail() { + + return ( + <> +
+ +
+ + ); +} + +export default ItemsDetail; \ No newline at end of file diff --git a/src/app/items/apply/Additem.module.css b/src/app/items/apply/Additem.module.css new file mode 100644 index 00000000..fb6edd0b --- /dev/null +++ b/src/app/items/apply/Additem.module.css @@ -0,0 +1,6 @@ +.formBox { + display: flex; + flex-direction: column; + gap: 32px; + margin-bottom: 69px; +} diff --git a/src/app/items/apply/page.tsx b/src/app/items/apply/page.tsx new file mode 100644 index 00000000..04609a53 --- /dev/null +++ b/src/app/items/apply/page.tsx @@ -0,0 +1,67 @@ +'use client'; + +import React, { useEffect } from 'react'; +import { useState } from 'react'; +import styles from './Additem.module.css'; +import Container from 'components/layout/Container'; +import Button from 'components/ui/Button'; +import Title from 'components/ui/Title'; +import { InputField, TextAreaField } from '@/components/ui/form/InputBox'; +import TagBox from '@/components/ui/TagBox'; +import { CreateProductRequest, ProductSummary, usePostProduct } from '@/hooks/useItems'; +import ImageFileBox from '@/components/ui/form/ImageFileBox'; +import { useConfirmModal } from '@/hooks/useModal'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import { useRouter } from 'next/navigation'; + +const INITIAL_PRODUCT: CreateProductRequest = { + images: [], + name: '', + description: '', + price: 0, + tags: [], +}; + + +function Additem() { + const router = useRouter(); + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + const [addProduct, setAddProduct] = useState(INITIAL_PRODUCT); + + const { mutate: postProduct} = usePostProduct(openConfirmModal,router); + + function handleInputBlur(e: React.FocusEvent){ + const value = e.target.value; + setAddProduct((prev) => ({ + ...prev, + [e.target.id]: value + })); + } + + const handleCreateProduct = () => { + postProduct(addProduct); + } + + return ( + + + <Button + onClick={handleCreateProduct} + variant="roundedSS" + disabled = { !addProduct.name || !addProduct.description || !addProduct.price } + >등록</Button> + + +
+ + + + + + + +
+ ); +} + +export default Additem; \ No newline at end of file diff --git a/src/app/items/page.tsx b/src/app/items/page.tsx new file mode 100644 index 00000000..8a49f4f1 --- /dev/null +++ b/src/app/items/page.tsx @@ -0,0 +1,23 @@ +'use client'; + +import React from 'react'; +import Container from 'components/layout/Container'; +import Title from 'components/ui/Title'; +import { BestItems } from '@/components/Product/BestItems'; +import { AllItems } from '@/components/Product/AllItems'; + + +function ItemsBox() { + + return ( + <> + + + </Container> + <BestItems /> + <AllItems /> + </> + ); +} + +export default ItemsBox; \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 00000000..936ff0d4 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,50 @@ + +'use client'; + +import './global.css'; +import { ReactNode, useEffect, useRef } from 'react'; +import QueryProvider from '@/components/layout/providers/query-provider'; +import Nav from '@/components/layout/Nav'; +import Footer from '@/components/layout/Footer'; +import { usePathname, useSelectedLayoutSegments } from 'next/navigation'; +import ProductNav from '@/components/layout/ProductNav'; + + +export default function RootLayout({ children }: { children: ReactNode }) { + + const segments = useSelectedLayoutSegments(); // ex: ['login'] + const isAuthPage = segments[0] === 'login' || segments[0] === 'signup'; + const isItemsPage = segments[0] === 'items' || segments[0] === 'boards'; + + const pathname = usePathname(); + const prevPathnameRef = useRef<string | null>(null); + + useEffect(() => { + if ( pathname !== '/login' && pathname !== prevPathnameRef.current) { + sessionStorage.setItem('redirectPath', pathname); + prevPathnameRef.current = pathname; + } + }, [pathname]); + + + return ( + <html lang="ko"> + <body> + <QueryProvider> + {!isAuthPage ? + !isItemsPage ? + <Nav /> : + <ProductNav /> + : null + } + <div className='min-h-[calc(100vh-234px)]'> + {children} + </div> + {!isAuthPage && <Footer />} + <div id="modal-root"></div> + </QueryProvider> + </body> + </html> + ); +} + diff --git a/src/app/login/Login.module.css b/src/app/login/Login.module.css new file mode 100644 index 00000000..ee77d19c --- /dev/null +++ b/src/app/login/Login.module.css @@ -0,0 +1,119 @@ +.login_body { + min-height: 100vh; + display: flex; + align-items: center; +} +.login_wrap { + width: 100%; + max-width: 640px; + margin: 0 auto; + text-align: center; +} + +.login_box { + text-align: left; +} +.login_box label { + position: relative; + display: block; + font-weight: 700; + font-size: 18px; + margin-bottom: 24px; +} +.login_box input[type="email"], +.login_box input[type="password"], +.login_box input[type="text"] { + display: block; + width: 100%; + margin-top: 16px; + padding: 15px 24px; + background-color: var(--Cool_Gray_100); + border-radius: 12px; + border: 0; +} + +/* error */ +.error_box input[type="email"], +.error_box input[type="password"], +.error_box input[type="text"] { + outline: 1px solid #f74747; +} + +.error { + display: block; + color: #f74747; + font-size: 14px; + font-weight: 500; + line-height: 24px; + margin-left: 16px; + margin-top: 8px; +} + +.login_box .eye { + position: absolute; + right: 18px; + top: 48px; + cursor: pointer; +} + +.login_box #submit { + display: block; + width: 100%; + border-radius: 9999px; + outline: 0; + padding: 12px 0; + color: var(--Cool_Gray_100); + font-size: 20px; + font-weight: 600; + line-height: 32px; + background-color: var(--Primary); + border: 0; + cursor: pointer; +} +.login_box #submit:disabled { + background-color: #9ca3af; +} + +.login_wrap .member_sub_box span { + font-size: 14px; +} +.login_wrap .member_sub_box a { + color: var(--Primary); + text-decoration: underline; +} +.submit { + display: block; + width: 100%; + border-radius: 9999px; + outline: 0; + padding: 12px 0; + color: var(--Cool_Gray_100); + font-size: 20px; + font-weight: 600; + line-height: 32px; + background-color: var(--Primary); + border: 0; + cursor: pointer; +} +.submit:disabled { + background-color: #9ca3af; +} + +/* Mobile */ +@media (max-width: 767px) { + .login_body { + padding: 0 16px; + } + .login_wrap { + width: 100%; + max-width: 400px; + } + + .login_box label { + font-size: 14px; + } + + .login_box .eye { + top: 42px; + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 00000000..a4a427f3 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,91 @@ + +'use client'; +import React from 'react'; +import { useState ,useEffect, useMemo } from 'react'; +import Link from 'next/link'; +import styles from './Login.module.css'; +import { memberCheck } from 'utils/auth'; +import Button from 'components/ui/Button'; +import MembersLogo from '@/components/members/MembersLogo'; +import SnsLogin from '@/components/members/SnsLogin'; +import { useLoginMutation } from '@/hooks/useAuth'; +import { useConfirmModal, useModal } from '@/hooks/useModal'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import FormField from '@/components/ui/form/FormField'; + +function Login() { + const [email, setEmail] = useState('user@mail.com'); + const [password, setPassword] = useState('12345678'); + const [passwordBoxType, setPasswordBoxType] = useState(true); + const [errorCase, setErrorCase] = useState({ email:'', password:'' }); + + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + const { mutate: login, isPending } = useLoginMutation(openConfirmModal); + + const handleLogin = () => { + login({ email, password }); + }; + + // input이 Blur될때 email,password state 변경 및 UserChecked state 표시 + const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => { + if(e.target.id === 'login_email') { + setEmail(e.target.value); + } else if(e.target.id === 'login_pwd') { + setPassword(e.target.value); + } + } + + const idCheck = useMemo(() => memberCheck.EmailChecked(email), [email]); + const passwordCheck = useMemo(() => memberCheck.passwordChecked(password), [password]); + + useEffect(() => { + setErrorCase({ + email: idCheck, + password: passwordCheck, + }); + }, [idCheck, passwordCheck]); + + const handleEyeClick = () => setPasswordBoxType(!passwordBoxType); + + const isFormValid = email && password && errorCase.email === '' && errorCase.password === ''; + + return ( + <div className={styles.login_body}> + <div className={styles.login_wrap}> + <MembersLogo /> + <div className={styles.login_box}> + + <FormField + id="login_email" + label="이메일" + type="email" + placeholder="이메일을 입력해주세요" + error={errorCase.email} + onBlur={handleInputBlur} + /> + + <FormField + id="login_pwd" + label="비밀번호" + type={passwordBoxType ? "password" : "text"} + placeholder="비밀번호를 입력해주세요" + error={errorCase.password} + onBlur={handleInputBlur} + withEyeToggle + eyeState={passwordBoxType} + onEyeToggle={handleEyeClick} + /> + + <Button variant='roundedXL' className={styles.submit} onClick={handleLogin} disabled={!isFormValid}>로그인</Button> + </div> + <SnsLogin /> + <div className={styles.member_sub_box}> + <span>판다마켓은 처음이신가요? <Link href="/signup">회원가입</Link></span> + </div> + </div> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> + </div> + ); +} + +export default Login; \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 00000000..e5ef4c96 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,77 @@ +'use client'; + +import React from 'react'; +import Button from '@/components/ui/Button'; +import Image from 'next/image'; +import { MotionSelection, VisualSelection } from '@/components/ui/mainSelection'; +import { imgHome1, imgHome2, imgHome3, imgHome_bottom, imgHome_top } from '@/lib/imageAssets'; + +function HomePage() { + + return ( + <> + <VisualSelection> + <div className=" + absolute bottom-0 right-0 w-[745px] + tablet:right-[50%] tablet:translate-x-[50%] tablet:w-[100%] + mobile:w-[110%] "> + <Image src={imgHome_top} width={745} height={345} className="w-full h-auto" alt="인트로 이미지" /> + </div> + <div className="absolute left-0 top-[240px] tablet:left-[50%] tablet:top-[84px] tablet:-translate-x-[50%] tablet:text-center mobile:top-[68px]"> + <h1 className="font-bold text-4xl mobile:text-3xl"> + 일상의 모든 물건을 <br className="tablet:hidden mobile:block"/>거래해 보세요 + </h1> + <Button link="/items" variant="roundedXL" className="mt-8" heightError={false}>구경하러 가기</Button> + </div> + </VisualSelection> + <MotionSelection> + <div className="relative w-[579px] tablet:w-[100%] "> + <Image src={imgHome1} width={579} height={444} className="w-full h-auto" alt='인기상품' /> + </div> + <div className="tablet:w-[100%]"> + <span className='font-bold text-primary-100 mb-3 text-lg mobile:text-base'>Hot item</span> + <h2 className='font-bold mb-6 text-3xl tablet:mb-4 mobile:text-2xl'>인기 상품을 <br className="tablet:hidden"/>확인해 보세요</h2> + <p className='desktop:text-xl'>가장 HOT한 중고거래 물품을<br/>판다 마켓에서 확인해 보세요</p> + </div> + </MotionSelection> + <MotionSelection className="flex-row-reverse"> + <div className="relative w-[579px] tablet:w-[100%] "> + <Image src={imgHome2} width={579} height={444} className="w-full h-auto" alt='상품검색' /> + </div> + <div className="tablet:w-[100%] text-end"> + <span className='font-bold text-primary-100 mb-3 text-lg mobile:text-base'>Search</span> + <h2 className='font-bold mb-6 text-3xl tablet:mb-4 mobile:text-2xl'>구매를 원하는<br className="tablet:hidden"/> 상품을 검색하세요</h2> + <p className='desktop:text-xl'>구매하고 싶은 물품은 검색해서<br/>쉽게 찾아보세요</p> + </div> + </MotionSelection> + <MotionSelection> + <div className="relative w-[579px] tablet:w-[100%] "> + <Image src={imgHome3} width={579} height={444} className="w-full h-auto" alt='상품등록' /> + </div> + <div className="tablet:w-[100%]"> + <span className='font-bold text-primary-100 mb-3 text-lg mobile:text-base'>Register</span> + <h2 className='font-bold mb-6 text-3xl tablet:mb-4 mobile:text-2xl'>판매를 원하는 <br className="tablet:hidden"/>상품을 등록하세요</h2> + <p className='desktop:text-xl'>어떤 물건이든 판매하고 싶은 상품을<br/>쉽게 등록하세요</p> + </div> + </MotionSelection> + <VisualSelection> + <div className=" + absolute bottom-0 right-0 w-[745px] + tablet:right-[50%] tablet:translate-x-[50%] tablet:w-[100%] "> + <Image src={imgHome_bottom} width={746} height={397} className="w-full h-auto" alt="아웃트로 이미지" /> + </div> + <div className=" + absolute left-0 top-[240px] + tablet:left-[50%] tablet:top-[201px] tablet:-translate-x-[50%] tablet:text-center + mobile:top-[120px]"> + <h3 className="font-bold text-4xl mobile:text-3xl"> + 믿을 수 있는<br/> + 판다마켓 중고 거래 + </h3> + </div> + </VisualSelection> + </> + ); +} + +export default HomePage; diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx new file mode 100644 index 00000000..8560e56b --- /dev/null +++ b/src/app/signup/page.tsx @@ -0,0 +1,142 @@ + +'use client'; +import React from 'react'; +import Link from 'next/link'; +import { useState ,useEffect,useMemo } from 'react'; +import styles from '../login/Login.module.css'; +import { memberCheck } from 'utils/auth'; +import Button from 'components/ui/Button'; +import MembersLogo from '@/components/members/MembersLogo'; +import SnsLogin from '@/components/members/SnsLogin'; +import FormField from '@/components/ui/form/FormField'; +import { useSignUp } from '@/hooks/useAuth'; +import { useConfirmModal, useModal } from '@/hooks/useModal'; +import ConfirmModal from '@/components/ui/ConfirmModal'; + +function Login() { + const [email, setEmail] = useState(''); + const [nickname, setNickname] = useState(''); + const [password, setPassword] = useState(''); + const [pwdCheck, setPwdCheck] = useState(''); + const [passwordBoxType, setPasswordBoxType] = useState(true); + const [pwdCheckBoxType, setPwdCheckBoxType] = useState(true); + const [errorCase, setErrorCase] = useState({ email:'', name:'', password:'',pwdCheck:'' }); + + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + const { mutate: signUp, isPending } = useSignUp(openConfirmModal); + +const handleSignUp = () => { + signUp({ + email, + nickname, + password, + passwordConfirmation: pwdCheck, + }); +}; + const setters: Record<string, React.Dispatch<React.SetStateAction<string>>> = { + login_email: setEmail, + login_name: setNickname, + login_pwd: setPassword, + login_pwd_check: setPwdCheck, + }; + + // input이 Blur될때 email,password state 변경 및 UserChecked state 표시 + const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => { + const { id, value } = e.target; + + const setter = setters[id]; + if (setter) setter(value); + + let error = ''; + if (id === 'login_email') error = memberCheck.EmailChecked(value); + else if (id === 'login_name') error = memberCheck.NameChecked(value); + else if (id === 'login_pwd') error = memberCheck.passwordChecked(value); + else if (id === 'login_pwd_check') error = memberCheck.passwordDoubleChecked(password, value); // password 상태 사용 + + setErrorCase(prev => ({ + ...prev, + [id.replace('login_', '')]: error, // email, name, password, pwdCheck에 매핑 + })); + }; + + + const handleEyeClick = () => setPasswordBoxType(!passwordBoxType); + const handleEyePwdCheck = () => setPwdCheckBoxType(!pwdCheckBoxType); + + const isFormValid = + email && + nickname && + password && + pwdCheck && + errorCase.email === '' && + errorCase.name === '' && + errorCase.password === '' && + errorCase.pwdCheck === ''; + + return ( + <div className={styles.login_body}> + <div className={styles.login_wrap}> + <MembersLogo /> + <div className={styles.login_box}> + <FormField + id="login_email" + label="이메일" + type="email" + placeholder="이메일을 입력해주세요" + error={errorCase.email} + onBlur={handleInputBlur} + /> + + <FormField + id="login_name" + label="닉네임" + type="text" + placeholder="닉네임을 입력해주세요" + error={errorCase.name} + onBlur={handleInputBlur} + /> + + <FormField + id="login_pwd" + label="비밀번호" + type={passwordBoxType ? "password" : "text"} + placeholder="비밀번호를 입력해주세요" + error={errorCase.password} + onBlur={handleInputBlur} + withEyeToggle + eyeState={passwordBoxType} + onEyeToggle={handleEyeClick} + /> + + <FormField + id="login_pwd_check" + label="비밀번호 확인" + type={pwdCheckBoxType ? "password" : "text"} + placeholder="비밀번호를 다시 입력해주세요" + error={errorCase.pwdCheck} + onBlur={handleInputBlur} + withEyeToggle + eyeState={pwdCheckBoxType} + onEyeToggle={handleEyePwdCheck} + /> + + <Button + variant='roundedXL' + className={styles.submit} + onClick={handleSignUp} + disabled={!isFormValid} + > + 회원가입 + </Button> + </div> + <SnsLogin /> + <div className={styles.member_sub_box}> + <span>이미 회원이신가요? <Link href="/login">로그인</Link></span> + </div> + </div> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> + </div> + ); +} + +export default Login; \ No newline at end of file diff --git a/src/components/FallbackImage/FallbackImage.tsx b/src/components/FallbackImage/FallbackImage.tsx new file mode 100644 index 00000000..b66e6c57 --- /dev/null +++ b/src/components/FallbackImage/FallbackImage.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from "react"; +import Image, { ImageProps } from "next/image"; +import { allowedImageDomains, defaultImg, imageExtensionRegex } from "@/lib/imageAssets"; + +interface ImageWithFadeProps extends ImageProps { + fallbackSrc?: string; + blurDataURL?: string; +} + +export const FallbackImage = ({ + src, + alt, + fallbackSrc = defaultImg, + blurDataURL = defaultImg, + className = "", + ...props +}: ImageWithFadeProps) => { + const [isLoaded, setIsLoaded] = useState(false); + const [hasError, setHasError] = useState(false); + + // console.log("src", src); + // string 타입 보장 + const url = typeof src === "string" ? src : ""; + + // 유효성 검사 + let isValid = false; + try { + const parsed = new URL(url); + const isExtensionOk = imageExtensionRegex.test(parsed.pathname); + const isDomainOk = allowedImageDomains.includes(parsed.hostname); + isValid = isExtensionOk && isDomainOk; + } catch { + isValid = false; + } + + // 최종 표시할 이미지 src + const finalSrc = hasError || !isValid ? defaultImg : src; + + return ( + <div className="relative w-full aspect-[1/1]"> + <Image + src={defaultImg} + alt={alt} + fill + priority + sizes="sm:100vw, 33vw" + className={`absolute inset-0 object-cover blur-sm scale-105 transition-opacity duration-300 ${ + isLoaded ? "opacity-0" : "opacity-100" + }`} + aria-hidden="true" + /> + <Image + src={finalSrc} + alt={alt} + fill + priority + unoptimized + onLoad={() => setIsLoaded(true)} + sizes="sm:100vw, 33vw" + className={`object-cover transition-opacity duration-700 ${ + isLoaded ? "opacity-100" : "opacity-0" + } ${className}`} + {...props} + /> + </div> + + // <Image + // src={finalSrc} + // alt={alt} + // onLoad={() => setIsLoaded(true)} + // onError={() => setHasError(true)} + // placeholder={blurDataURL ? "blur" : undefined} + // blurDataURL={blurDataURL} + // fill + // priority + // unoptimized + // sizes="(max-width: 768px) 100vw, 33vw" + // className={`transition-opacity duration-700 ease-in-out ${ + // isLoaded ? "opacity-100" : "opacity-0" + // } ${className}`} + // {...props} + // /> + ); +}; + diff --git a/src/components/ItemsDetail/ProductDescription.module.css b/src/components/ItemsDetail/ProductDescription.module.css new file mode 100644 index 00000000..c5270af1 --- /dev/null +++ b/src/components/ItemsDetail/ProductDescription.module.css @@ -0,0 +1,65 @@ +.description { + width: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; +} +.title { + padding-bottom: 16px; + margin-bottom: 24px; + border-bottom: 1px solid var(--Cool_Gray_200); +} + +.title h2 { + font-weight: 700; + margin-bottom: 16px; +} + +.title h3 { + font-weight: 700; +} +.description > div > ul { + display: flex; + flex-direction: column; + gap: 24px; +} + +.description > div > ul > li > h4 { + margin-bottom: 16px; + font-size: 16px; +} + +.description > div > ul > li > div { + display: flex; + gap: 8px; +} + +.description > div > ul > li > div p { + color: var(--Cool_Gray_800); + background-color: var(--Cool_Gray_100); + border-radius: 26px; + padding: 5px 16px; +} + +.description > div > ul > li > div p::before { + content: "#"; +} +.UserInfo { + display: flex; + justify-content: space-between; + align-items: center; +} +.likeBtnBox { + padding-left: 24px; + border-left: 1px solid var(--Cool_Gray_200); +} + +/* Tablet */ +@media (max-width: 1199px) { + .description > div > ul > li > div { + flex-wrap: wrap; + } +} +/* Mobile */ +@media (max-width: 767px) { +} diff --git a/src/components/ItemsDetail/ProductDescription.tsx b/src/components/ItemsDetail/ProductDescription.tsx new file mode 100644 index 00000000..72ef83c9 --- /dev/null +++ b/src/components/ItemsDetail/ProductDescription.tsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; +import styles from './ProductDescription.module.css'; +import UserInfo from 'components/ui/UserInfo'; +import { formatDate } from 'utils/date'; +import Icon from 'components/ui/Icon'; +import Button from 'components/ui/Button'; +import clsx from 'clsx'; +import { ProductDetail } from '@/hooks/useProductsDetail'; +import LikeButton from '../ui/LikeButton'; +import { useGetUserFavorites } from '@/hooks/useUser'; + +function ProductDescription(detailData:ProductDetail) { + + const { + createdAt, + description, + favoriteCount, + // isFavorite, + name, + ownerNickname, + price, + tags + } = detailData; + + const { data } = useGetUserFavorites({}); + const isFavorite = data?.list.some((item) => item.id === detailData.id) ?? false; + + // '2025-04-08T01:00:06+09:00' '2025-04-07T01:00:06+09:00' + const createdAtString = formatDate(createdAt); + // console.log(createdAtString); + + return ( + <div className={styles.description}> + <div className='mobile:mb-10'> + <div className={clsx(styles.title,'tablet:gap-2')}> + <h2 className='desktop:text-xl tablet:text-xl mobile:text-base '>{name}</h2> + <h3 className='desktop:text-4xl tablet:text-3xl mobile:text-2xl'>{price?.toLocaleString()}원</h3> + </div> + <ul> + <li> + <h4>상품 소개</h4> + <p>{description}</p> + </li> + {tags?.length > 0 && ( + <li> + <h4>상품 태그</h4> + <div> + {tags.map((tag, index) => ( + <p key={index}>{tag}</p> + ))} + </div> + </li> + )} + </ul> + </div> + <div className={styles.UserInfo}> + <UserInfo ownerNickname={ownerNickname} createdAtString={createdAtString}/> + <div className={styles.likeBtnBox}> + <LikeButton variant="btn-heart_L" productId={detailData.id} favoriteCount={detailData.favoriteCount} isFavorite={isFavorite} childrenClassName='gap-2' width="24" height="24"/> + </div> + </div> + </div> + ); +} + +export default ProductDescription; \ No newline at end of file diff --git a/src/components/ItemsDetail/ProductDetails.module.css b/src/components/ItemsDetail/ProductDetails.module.css new file mode 100644 index 00000000..219667a9 --- /dev/null +++ b/src/components/ItemsDetail/ProductDetails.module.css @@ -0,0 +1,8 @@ +.container { + display: flex; + gap: 24px; + margin-top: 29px; + border-bottom: 1px solid var(--Cool_Gray_200); + padding-bottom: 40px; + margin-bottom: 40px; +} diff --git a/src/components/ItemsDetail/ProductDetails.tsx b/src/components/ItemsDetail/ProductDetails.tsx new file mode 100644 index 00000000..20e8123e --- /dev/null +++ b/src/components/ItemsDetail/ProductDetails.tsx @@ -0,0 +1,36 @@ +'use client'; + +import React, { useState } from 'react'; +import ProductOverview from './ProductOverview'; +import ProductDescription from './ProductDescription'; +import Container from 'components/layout/Container'; +import CommentSection from './comment/CommentSection'; +import { useProductsDetails } from '@/hooks/useProductsDetail'; +import { useParams } from 'next/navigation'; + + +function ProductDetails( ) { + + const { id } = useParams(); // URL에서 [id] 추출 + const productId = Number(id); + + const { data, isLoading, isError } = useProductsDetails(productId); + + return ( + <div className='flex flex-col mt-8'> + <Container className='flex flex-row w-full gap-6 pb-[40px] mb-[40px] border-b border-b-[var(--Cool_Gray_200)] mobile:flex-col'> + { data && ( + <> + <ProductOverview img={data?.images}/> + <ProductDescription {...data} /> + </> + )} + </Container> + <Container> + <CommentSection productId={productId} /> + </Container> + </div> + ); +} + +export default ProductDetails; \ No newline at end of file diff --git a/src/components/ItemsDetail/ProductOverview.module.css b/src/components/ItemsDetail/ProductOverview.module.css new file mode 100644 index 00000000..739296e6 --- /dev/null +++ b/src/components/ItemsDetail/ProductOverview.module.css @@ -0,0 +1,18 @@ +.overview { + min-width: 486px; +} +.overview > div { + position: relative; + height: 0; + width: 100%; + padding-bottom: 100%; +} +.overview img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 16px; + object-fit: cover; +} diff --git a/src/components/ItemsDetail/ProductOverview.tsx b/src/components/ItemsDetail/ProductOverview.tsx new file mode 100644 index 00000000..f1c595a2 --- /dev/null +++ b/src/components/ItemsDetail/ProductOverview.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import styles from './ProductOverview.module.css'; +import Image from 'next/image'; +import { defaultImg } from '@/lib/imageAssets'; +import { FallbackImage } from '../FallbackImage/FallbackImage'; + + +interface ProductOverviewProps { + img: string[]; +} + +function ProductOverview({ img }: ProductOverviewProps) { + return ( + <div className={styles.overview}> + <div className='relative w-full aspect-[1/1] '> + <FallbackImage + src={img[0] || defaultImg} + fill + className='object-contain border border-[var(--Cool_Gray_200)]' + alt='상품이미지' + /> + </div> + </div> + ); +} + +export default ProductOverview; \ No newline at end of file diff --git a/src/components/ItemsDetail/comment/CommentForm.tsx b/src/components/ItemsDetail/comment/CommentForm.tsx new file mode 100644 index 00000000..4dc27869 --- /dev/null +++ b/src/components/ItemsDetail/comment/CommentForm.tsx @@ -0,0 +1,39 @@ + +import Button from 'components/ui/Button'; +import { TextAreaBox } from '@/components/ui/form/InputBox'; +import React, { useState } from 'react'; +import { usePostProductComment } from '@/hooks/useProductsComments'; +import { useConfirmModal, useModal } from '@/hooks/useModal'; +import ConfirmModal from '@/components/ui/ConfirmModal'; + +type CommentFormProps = { + productId: number; +}; + +function CommentForm({productId}: CommentFormProps) { + + const [requestCommentValue, setRequestCommentValue] = useState<string>(''); + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + const { mutate: postComment } = usePostProductComment(productId, openConfirmModal); + + const handleClick = () => { + postComment(requestCommentValue); + setRequestCommentValue(''); + }; + + return ( + <div className='w-full mb-6'> + <h5 className='text-cool-gray-900 mb-2 font-bold ml-2'>문의하기</h5> + <TextAreaBox + placeholder='개인정보를 공유 및 요청하거나, 명예 훼손, 무단 광고, 불법 정보 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은 게시자에게 있습니다.' + value={requestCommentValue} + onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setRequestCommentValue(e.target.value)} + /> + <div className='flex justify-end'> + <Button variant="roundedSS" disabled={!requestCommentValue} onClick={handleClick} >등록</Button> + </div> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> + </div> + ); +} +export default CommentForm; \ No newline at end of file diff --git a/src/components/ItemsDetail/comment/CommentItem.tsx b/src/components/ItemsDetail/comment/CommentItem.tsx new file mode 100644 index 00000000..df2e88ff --- /dev/null +++ b/src/components/ItemsDetail/comment/CommentItem.tsx @@ -0,0 +1,109 @@ + +import React, { useState } from 'react'; +import { formatDate } from 'utils/date'; +import UserInfo from 'components/ui/UserInfo'; +import clsx from 'clsx'; +import { TextAreaBox } from '@/components/ui/form/InputBox'; +import Button from 'components/ui/Button'; +import DropdownMenu from 'components/ui/DropdownMenu'; +import Modal from '@/components/ui/Modal'; +import { CommentItemUnit, useDeleteCommentMutation, usePatchProductComment } from '@/hooks/useProductsComments'; +import { useConfirmModal, useModal } from '@/hooks/useModal'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import { useAuth } from '@/contexts/AuthContext'; + +interface CommentItemProps { + productId: number; + commentItem: CommentItemUnit; +} + +function CommentItem({productId,commentItem}:CommentItemProps) { + const { + id:commentId, + content, + updatedAt, + } = commentItem; + + const createdAtString = formatDate(updatedAt); + + const [editMode, setEditMode] = useState(false); + const [requestCommentValue, setRequestCommentValue] = useState<string>(content); + + const { isModalOpen, modalMessage, openModal, closeModal } = useModal(); + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + const { mutate: deleteProduct } = useDeleteCommentMutation(productId,openConfirmModal); + const { mutate: patchComment } = usePatchProductComment(productId,openConfirmModal); + const { user } = useAuth(); + + const handleOpenModal = () =>{ + if(!user) { + openConfirmModal('로그인 후 이용 가능합니다.'); + return; + } + if(commentItem.writer.id !== user?.id) { + openConfirmModal('본인의 댓글만 삭제할 수 있습니다.'); + return; + } + openModal('정말 삭제하시겠습니까?'); + }; + const handleOpenEdit = () =>{ + if(!user) { + openConfirmModal('로그인 후 이용 가능합니다.'); + return; + } + if(commentItem.writer.id !== user?.id) { + openConfirmModal('본인의 댓글만 수정할 수 있습니다.'); + return; + } + setEditMode(true); + }; + + const handleConfirmDelete = () => { + deleteProduct(commentId); + closeConfirmModal(); + }; + const handleUpdate = () =>{ + patchComment({ commentId, requestCommentValue }); + setEditMode(false) + }; + + const dropdownActions = [ + { + label: '삭제하기', + onClick: handleOpenModal, + }, + { + label: '수정하기', + onClick:handleOpenEdit, + }, + ]; + + + return ( + <li className={clsx('flex gap-4 flex-col border-b border-b-[var(--Cool_Gray_200)] pb-3 relative fade-in' )}> + {editMode === true ? ( + <div> + <TextAreaBox + height='80px' + placeholder='내용을 입력해주세요' + value={requestCommentValue} + onChange={({ target }: React.ChangeEvent<HTMLTextAreaElement>) => setRequestCommentValue(target.value)} + /> + <div className='absolute bottom-4 right-0'> + <Button onClick={() => setEditMode(false)} variant="none">취소</Button> + <Button onClick={handleUpdate} variant="roundedSS">수정 완료</Button> + </div> + </div> + ):( + <div> + <span>{requestCommentValue}</span> + <DropdownMenu dropdownActions={dropdownActions} className='' /> + </div> + )} + <UserInfo userImg={commentItem.writer.image} ownerNickname={commentItem.writer.nickname} createdAtString={createdAtString} fontSize='12px'/> + <Modal isOpen={isModalOpen} closeModal={closeModal} onclick={handleConfirmDelete} message={modalMessage}/> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> + </li> + ); +} +export default CommentItem; \ No newline at end of file diff --git a/src/components/ItemsDetail/comment/CommentList.tsx b/src/components/ItemsDetail/comment/CommentList.tsx new file mode 100644 index 00000000..b0b58379 --- /dev/null +++ b/src/components/ItemsDetail/comment/CommentList.tsx @@ -0,0 +1,71 @@ +import React, { useEffect, useRef } from 'react'; +import { useInfiniteProductsComments } from '@/hooks/useProductsComments'; +import CommentItem from './CommentItem'; +import LoadingBox from '@/components/ui/LoadingBox'; +import EmptyBox from '@/components/ui/EmptyBox'; + + +interface CommentListProps { + productId: number; + className?: string; + [key: string]: any; +} + +function CommentList({ productId,className, ...rest }: CommentListProps) { + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useInfiniteProductsComments(productId); + + const loadMoreRef = useRef<HTMLDivElement>(null); + + + useEffect(() => { + if (!loadMoreRef.current || !hasNextPage) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage) { + fetchNextPage(); + } + }, + { threshold: 1.0 } + ); + + observer.observe(loadMoreRef.current); + + return () => { + if (loadMoreRef.current) observer.unobserve(loadMoreRef.current); + }; + }, [hasNextPage, fetchNextPage]); + + return ( + <> + {isLoading ? <LoadingBox className="h-[372px]"/> : data?.pages?.[0].list.length ? ( + <div className={`${className}`} {...rest}> + {data?.pages.map((page, i) => ( + <React.Fragment key={page.nextCursor}> + {page.list.map((comment) => ( + <div key={comment.id} className='mb-6'> + <CommentItem productId={productId} commentItem={comment}/> + </div> + ))} + </React.Fragment> + ))} + <div ref={loadMoreRef} style={{ height: '20px' }} /> + {isFetchingNextPage && <div>로딩 중...</div>} + </div> + ):( + <EmptyBox context="아직 문의가 없어요" className='h-[372px]' /> + ) + } + </> + + ); +} +export default CommentList; diff --git a/src/components/ItemsDetail/comment/CommentSection.tsx b/src/components/ItemsDetail/comment/CommentSection.tsx new file mode 100644 index 00000000..300a97de --- /dev/null +++ b/src/components/ItemsDetail/comment/CommentSection.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import Button from 'components/ui/Button'; +import Icon from 'components/ui/Icon'; +import CommentList from './CommentList'; +import CommentForm from './CommentForm'; +import { useRouter } from 'next/navigation'; + + +type CommentSectionProps = { + productId: number; +}; + +function CommentSection({productId}: CommentSectionProps) { + const router = useRouter(); + + const handleGoBack = () => { + router.back(); // ← 이전 페이지로 이동 + }; + + return ( + <> + <CommentForm productId={productId} /> + <CommentList productId={productId} className='w-full mb-16'/> + <div className='flex justify-center align-middle w-full '> + <Button onClick={handleGoBack} variant="roundedL" childrenClassName='gap-2'> + <span>목록으로 돌아가기</span> + <Icon iconName='back' alt='back icon'/> + </Button> + </div> + + </> + ); +} +export default CommentSection; diff --git a/src/components/Product/AllItems.module.css b/src/components/Product/AllItems.module.css new file mode 100644 index 00000000..caae54af --- /dev/null +++ b/src/components/Product/AllItems.module.css @@ -0,0 +1,85 @@ +.title { + font-size: 20px; + line-height: 42px; + font-weight: 700; +} +.bestProdListTitle { + margin-top: 24px; + font-size: 20px; + margin-bottom: 16px; + font-weight: 700; +} +.prodListTitle { + display: flex; + position: relative; + justify-content: space-between; + align-items: center; + margin-top: 40px; + margin-bottom: 24px; + z-index: 99; +} + +.prodListTitle > div { + display: flex; + gap: 12px; +} + +.prodSearchWrap { + position: relative; +} +.prodSearch input[type="text"] { + border: 0; + line-height: 26px; + height: 42px; + width: 325px; + background-color: #f3f4f6; + padding: 9px 24px; + padding-left: 44px; + font-size: 16px; + border-radius: 12px; + border: 0; +} +.prodSearch input[type="text"]:focus, +.prodSearch input[type="text"]:hover, +.prodSearch input[type="text"]:active { + background-color: #eaedf1; + border: 0; + outline: 0; +} + +.prodSearch img { + position: absolute; + left: 16px; + top: 10px; + width: 24px; +} + +/* tablet */ +@media (max-width: 1199px) { +} +/* Mobile */ +@media (max-width: 767px) { + .prodListTitle { + position: relative; + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + .prodListTitle > div { + width: 100%; + } + .prodSearch { + width: 100%; + } + .prodSearchWrap { + width: 100%; + } + .prodAddBtn { + position: absolute; + top: 0; + right: 0; + } + .prodSearch input[type="text"] { + width: 100%; + } +} diff --git a/src/components/Product/AllItems.tsx b/src/components/Product/AllItems.tsx new file mode 100644 index 00000000..ff4c6f06 --- /dev/null +++ b/src/components/Product/AllItems.tsx @@ -0,0 +1,172 @@ +'use client'; +import React, { useEffect, useLayoutEffect, useState } from "react"; +import styles from "./AllItems.module.css"; +import Container from "components/layout/Container"; +import Icon from "components/ui/Icon"; +import Button from "components/ui/Button"; +import SelectBox from "components/ui/SelectBox"; +import PageNation from "components/ui/PageNation"; +import LoadingBox from "../ui/LoadingBox"; +import { useItemService, useParsedItemQuery, useSetItemQuery } from "@/hooks/useItemQuery"; +import { useRouter, useSearchParams } from "next/navigation"; +import { ProductQuery } from "@/hooks/useItems"; +import { useScreenType } from "@/hooks/useScreenType"; +import { ProdListAll } from "./ProdListAll"; +import { useBreakpoint } from "@/hooks/useBreakpoint"; +import EmptyBox from "../ui/EmptyBox"; +import { useAuth } from "@/contexts/AuthContext"; +import { useConfirmModal } from "@/hooks/useModal"; +import ConfirmModal from "../ui/ConfirmModal"; + + +type orderByType = "recent" | "favorite"; + + +const ORDER_OPTIONS = [ + { value: 'recent', label: '최신순' }, + { value: 'favorite', label: '좋아요순' }, +]; + +export function AllItems() { + + + const searchParams = useSearchParams(); + const screenType = useScreenType(); // 0: 모바일, 1: 태블릿, 2: 데스크탑 + const breakpoint = useBreakpoint(); + + const VISIBLE_ITEMS = { + length: {mobile:4, tablet:6, desktop:10}, + column: {mobile:2, tablet:3, desktop:5}, + }; + + const INITIAL_QUERY : ProductQuery = { + page: 1, + pageSize: VISIBLE_ITEMS.length[breakpoint], + orderBy: 'recent', + keyword: '', + }; + + const setQueryToURL = useSetItemQuery(); + + const parsedQuery = useParsedItemQuery(INITIAL_QUERY, searchParams); + const [query, setQuery] = useState(parsedQuery); + const { data , isLoading } = useItemService( query , searchParams); + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + const { user } = useAuth(); + const router = useRouter(); + + // 페이지 반응형 달라질때마다 pageSize 수정 + useLayoutEffect(() => { + const pageSize = VISIBLE_ITEMS.length[breakpoint]; + if (query.pageSize === pageSize) return; + + const next = { + ...query, + page: query.page ?? 1, + pageSize, + }; + + setQuery(next); + setQueryToURL(next); + }, [breakpoint]); + + + // PageNation handle + const handlePageNationClick = (num: number) => { + const next = { ...query, page: (num) }; + setQuery(next); + setQueryToURL(next); + }; + + // SelectBox handle + const handleSelectBoxClick = (value: string) => { + const next = { ...query, orderBy: value as orderByType, page: 1 }; + setQuery(next); + setQueryToURL(next); + }; + + // Keyword handle + const handleKeywordChange = (keyword: string) => { + const next = { ...query, keyword,page: 1}; + setQuery(next); + setQueryToURL(next); + }; + + const handleApplyClick = () => { + if(!user) { + openConfirmModal('로그인 후 이용 가능합니다.'); + return; + } + router.push('items/apply'); + }; + + return ( + <> + <Container className='relative z-20'> + <div className={styles.prodListTitle}> + <div className="left"> + <div className={styles.title}>전체상품</div> + </div> + <div className="right"> + <form className={styles.prodSearch}> + <div className={styles.prodSearchWrap}> + <Icon iconName="search" alt="search box" /> + <input + name="keyword" + type="text" + placeholder="검색할 상품을 입력해주세요" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleKeywordChange((e.target as HTMLInputElement).value); + } + }} + /> + </div> + </form> + + <Button + variant="roundedSS" + className={styles.prodAddBtn} + heightError='true' + onClick={handleApplyClick} + > + 상품 등록하기 + </Button> + + <SelectBox + options={ORDER_OPTIONS} + screenType={Number(screenType)} + current={query.orderBy} + clickEvent={handleSelectBoxClick} + /> + </div> + </div> + </Container> + + {/* 🔹 로딩 중이면 LoadingBox 표시 */} + {isLoading ? ( + <LoadingBox className="h-[572px] mb-[141px]" /> + ) : data?.list.length ? ( + <ProdListAll + itemsData={data} + pageColumn={VISIBLE_ITEMS.column[breakpoint]} + className={Number(data?.list?.length) < Number(query.pageSize) ? 'mb-[141px]' : 'mb-0'} + /> + ) : ( + <EmptyBox context="아직 해당 상품이 없습니다." className="h-[572px] mb-[141px]" /> + )} + + {/* 🔹 페이지네이션 */} + <PageNation + current={query.page} + page={5} + totalNum={data?.totalCount} + size={query.pageSize} + clickEvent={handlePageNationClick} + /> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> + </> + ); +} + diff --git a/src/components/Product/BestItems.tsx b/src/components/Product/BestItems.tsx new file mode 100644 index 00000000..e6b1bd3e --- /dev/null +++ b/src/components/Product/BestItems.tsx @@ -0,0 +1,54 @@ +'use client'; +import React, { useEffect, useState } from 'react'; +import LoadingBox from '../ui/LoadingBox'; +import { ProductQuery, useItemList } from '@/hooks/useItems'; +import { useScreenType } from '@/hooks/useScreenType'; +import { ProdListAll } from './ProdListAll'; + +export function BestItems() { + +const screenType = useScreenType(); // 0: 모바일, 1: 태블릿, 2: 데스크탑 + +// 베스트 상품 반응형 width 기준값 +const VISIBLE_ITEMS = { + length: [1, 2, 4], // 상품 갯수 (mobile, tablet, desktop) + column: { mobile: 1, tablet: 2, desktop: 4 }, // 열 갯수 (mobile, tablet, desktop) +}; + +// 베스트 상품 쿼리 초기값 +const INITIAL_QUERY: ProductQuery = { + page: 1, + pageSize: VISIBLE_ITEMS.length[screenType], + orderBy: 'favorite', + keyword: '', +}; + + const [query, setQuery] = useState(INITIAL_QUERY); + + // 페이지 반응형 달라질때마다 pageSize 수정 + useEffect(() => { + setQuery((prev: typeof INITIAL_QUERY) => ({ + ...prev, + pageSize: VISIBLE_ITEMS.length[screenType], + })); + }, [screenType]); + + + const { data , isLoading} = useItemList(query); + + return ( + <> + {isLoading ? ( + <LoadingBox className="h-[378px]"/> + ) : ( + data && ( + <ProdListAll + itemsData = {data} + pageColumn={query.pageSize} + /> + ) + )} + </> + ); +} + diff --git a/src/components/Product/ProdListAll.module.css b/src/components/Product/ProdListAll.module.css new file mode 100644 index 00000000..8303a136 --- /dev/null +++ b/src/components/Product/ProdListAll.module.css @@ -0,0 +1,26 @@ +.prodList { + display: flex; + flex-wrap: wrap; + gap: 24px; + row-gap: 40px; +} + +.prodList li img { + width: 100%; +} + +.prodList.Column_1 li { + flex: 0 0 100%; +} +.prodList.Column_2 li { + flex: 0 0 calc(50% - 12px); +} +.prodList.Column_3 li { + flex: 0 0 calc(33.33% - 16px); +} +.prodList.Column_4 li { + flex: 0 0 calc(25% - 18px); +} +.prodList.Column_5 li { + flex: 0 0 calc(20% - 20px); +} diff --git a/src/components/Product/ProdListAll.tsx b/src/components/Product/ProdListAll.tsx new file mode 100644 index 00000000..dba93850 --- /dev/null +++ b/src/components/Product/ProdListAll.tsx @@ -0,0 +1,35 @@ + +'use client'; + +import React from 'react'; +import clsx from 'clsx'; +import ProductItem from './ProductItem' +import styles from './ProdListAll.module.css'; +import Container from 'components/layout/Container'; +import { ProductListResponse } from '@/hooks/useItems'; + +interface ProdListAllProps { + itemsData: ProductListResponse; + pageColumn: number; + className?: string; +} + +export function ProdListAll({ itemsData, pageColumn, className }: ProdListAllProps) { + + return ( + <> + <Container className={className}> + <ul className={clsx(styles.prodList, styles[`Column_${pageColumn}`])}> + { itemsData.totalCount !== 0 && ( + itemsData.list.map((item) => ( + <ProductItem + key={item.id} + productItem={item} + /> + )) + )} + </ul> + </Container> + </> + ); +} diff --git a/src/components/Product/ProductItem.module.css b/src/components/Product/ProductItem.module.css new file mode 100644 index 00000000..a44355fd --- /dev/null +++ b/src/components/Product/ProductItem.module.css @@ -0,0 +1,34 @@ +.imgBox { + width: 100%; + position: relative; + overflow: hidden; + border-radius: 16px; + border: 1px solid var(--Cool_Gray_200); +} +.imgBox img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + transition: transform 0.5s ease; +} +.imgBox:hover img { + transform: scale(1.1); +} +.description { + margin-top: 16px; +} + +.description .name { + font-size: 14px; + line-height: 24px; + margin-bottom: 6px; +} + +.description .price { + font-size: 16px; + line-height: 26px; + font-weight: 700; + margin-bottom: 6px; +} diff --git a/src/components/Product/ProductItem.tsx b/src/components/Product/ProductItem.tsx new file mode 100644 index 00000000..ecfd7aa3 --- /dev/null +++ b/src/components/Product/ProductItem.tsx @@ -0,0 +1,53 @@ +'use client'; + +import React, { useEffect, useMemo } from 'react'; +import Link from 'next/link'; +import { useState } from 'react'; +import styles from './ProductItem.module.css'; +import Icon from 'components/ui/Icon'; +import Button from 'components/ui/Button'; +import clsx from 'clsx'; +import { ProductSummary, useToggleProductFavorite } from '@/hooks/useItems'; +import { FallbackImage } from '../FallbackImage/FallbackImage'; +import { defaultImg } from '@/lib/imageAssets'; +import { useConfirmModal, useModal } from '@/hooks/useModal'; +import ConfirmModal from '../ui/ConfirmModal'; +import { useAuth } from '@/contexts/AuthContext'; +import { useGetUserFavorites } from '@/hooks/useUser'; +import LikeButton from '../ui/LikeButton'; + + +interface ProductItemProps { // ProductSummary 타입정의할때 옵셔널 방식을 사용함 | undefined 필요 + productItem: ProductSummary; +} + +function ProductItem({productItem}: ProductItemProps) { + // const randomNum = Math.floor(Math.random() * 4) + 1; + // const randomImg = `../img/img_1.jpg`; + + const productId = productItem.id ?? 0; + + const { data } = useGetUserFavorites({}); + const isFavorite = data?.list.some((item) => item.id === productId) ?? false; + + return ( + <li className={styles.listItem}> + <Link href={`items/${productId}`}> + <div className={clsx(styles.imgBox,'border border-[var(--Cool_Gray_200)]')}> + <FallbackImage + src={productItem.images?.[0] || defaultImg} + alt="ProductImg" + /> + </div> + </Link> + <div className={styles.description}> + <div className={styles.name}>{productItem.name}</div> + <div className={styles.price}>{productItem.price?.toLocaleString()}원</div> + + <LikeButton productId={productId} favoriteCount={productItem.favoriteCount} isFavorite={isFavorite} /> + </div> + </li> + ); +} + +export default ProductItem; diff --git a/src/components/layout/Container.tsx b/src/components/layout/Container.tsx new file mode 100644 index 00000000..d397b649 --- /dev/null +++ b/src/components/layout/Container.tsx @@ -0,0 +1,24 @@ +'use client'; + +import React from 'react'; +import clsx from 'clsx'; + +interface ContainerProps { + className?: string; + children: React.ReactNode; +} + +function Container({ className, children }: ContainerProps) { + + + return ( + <section className={clsx(`page`, + 'w-full min-w-base max-w-container mx-auto tablet:px-6 mobile:px-4', + className + )}> + {children} + </section> + ); +} + +export default Container; diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx new file mode 100644 index 00000000..aefa031a --- /dev/null +++ b/src/components/layout/Footer.tsx @@ -0,0 +1,43 @@ +'use client'; + +import React from 'react'; +import Container from './Container'; +import Link from 'next/link'; +import Image from 'next/image'; +import { facebookIcon, instagramIcon, twitterIcon, youtubeIcon } from '@/lib/imageAssets'; + + +function Footer() { + return ( + <div className='bg-secondary-900'> + <Container className='relative flex justify-between pt-[32px] pb-[108px]'> + <div className='text-secondary-400 mobile:absolute mobile:top-[76px] '> + <span>©codeit - 2024</span> + </div> + + <div className='flex gap-8 text-secondary-400'> + <Link href="/privacy">Privacy Policy</Link> + <Link href="/faq">FAQ</Link> + </div> + + <div className='flex gap-3'> + {/* 외부링크는 a로? */} + <a href="https://www.facebook.com/" target="_blank" rel="noopener noreferrer"> + <span><Image src={facebookIcon} width={20} height={20} alt="페이스북 바로가기" /></span> + </a> + <a href="https://x.com/" target="_blank" rel="noopener noreferrer"> + <span><Image src={twitterIcon} width={20} height={20} alt="트위터 바로가기" /></span> + </a> + <a href="https://www.youtube.com/" target="_blank" rel="noopener noreferrer"> + <span><Image src={youtubeIcon} width={20} height={20} alt="유튜브 바로가기" /></span> + </a> + <a href="https://www.instagram.com/" target="_blank" rel="noopener noreferrer"> + <span><Image src={instagramIcon} width={20} height={20} alt="인스타그램 바로가기" /></span> + </a> + </div> + </Container> + </div> + ); +} + +export default Footer; diff --git a/src/components/layout/Nav.tsx b/src/components/layout/Nav.tsx new file mode 100644 index 00000000..26b1e0b8 --- /dev/null +++ b/src/components/layout/Nav.tsx @@ -0,0 +1,51 @@ +'use client'; + +import React from 'react'; +import Container from './Container'; +import Button from '../ui/Button'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useAuth } from '@/contexts/AuthContext'; +import { logoImg1, logoImg2 } from '@/lib/imageAssets'; +import { useConfirmModal, useModal } from '@/hooks/useModal'; +import ConfirmModal from '../ui/ConfirmModal'; + + +function Nav() { + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + const { user, logout } = useAuth(); + + const handleLogout = () => { + logout(); + openConfirmModal('로그아웃 되었습니다.'); + }; + + return ( + <div className='sticky w-full top-0 z-[999] bg-white shadow-soft-xl'> + <Container className='flex justify-between items-center py-3'> + <div className='relative flex items-center'> + <Link href="/" className='gap-2 flex items-center'> + <span className='relative inline-flex h-[40px] mobile:hidden'> + <Image src={logoImg1} width={110} height={110} className="w-full h-auto" priority alt="로고이미지" /> + </span> + <span className='relative inline-flex h-[35px]'> + <Image src={logoImg2} width={266} height={90} className="w-full h-auto" priority alt="판다마켓" /> + </span> + </Link> + </div> + {user ? ( + <Button onClick={handleLogout} variant="roundedS"> + 로그아웃 + </Button> + ) : ( + <Button link="/login" variant="roundedS"> + 로그인 + </Button> + )} + </Container> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> + </div> + ); +} + +export default Nav; diff --git a/src/components/layout/ProductNav.module.css b/src/components/layout/ProductNav.module.css new file mode 100644 index 00000000..67f90ba3 --- /dev/null +++ b/src/components/layout/ProductNav.module.css @@ -0,0 +1,9 @@ +/* Mobile */ +@media (max-width: 767px) { + .category { + margin-left: 8px; + } + .category a { + padding: 0px 8px; + } +} diff --git a/src/components/layout/ProductNav.tsx b/src/components/layout/ProductNav.tsx new file mode 100644 index 00000000..0ab2b342 --- /dev/null +++ b/src/components/layout/ProductNav.tsx @@ -0,0 +1,60 @@ +'use client'; + +import React from 'react'; +import styles from './ProductNav.module.css'; +import Image from 'next/image'; +import Container from './Container'; +import Button from '../ui/Button'; +import Link from 'next/link'; +import { useSelectedLayoutSegments } from 'next/navigation'; +import { useAuth } from '@/contexts/AuthContext'; +import { logoImg1, logoImg2 } from '@/lib/imageAssets'; +import ConfirmModal from '../ui/ConfirmModal'; +import { useConfirmModal, useModal } from '@/hooks/useModal'; + +function ProductNav() { + const segments = useSelectedLayoutSegments(); + const isItems = segments[0] === 'items' ; + const isBoards = segments[0] === 'boards'; + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + const { user, logout } = useAuth(); + + const handleLogout = () => { + logout(); + openConfirmModal('로그아웃 되었습니다.'); + }; + + + return ( + <div className='sticky w-full top-0 z-[999] bg-white shadow-soft-xl'> + <Container className='flex justify-between items-center py-3 max-w-[1570px]'> + <div className='relative flex items-center'> + <Link href="/" className='gap-2 flex items-center'> + <span className='relative inline-flex h-[40px] mobile:hidden'> + <Image src={logoImg1} width={110} height={110} className="w-full h-auto" priority alt="로고이미지" /> + </span> + <span className='relative inline-flex h-[35px]'> + <Image src={logoImg2} width={266} height={90} className="w-full h-auto" priority alt="판다마켓" /> + </span> + </Link> + <div className='ml-12 flex gap-7 text-lg tablet:ml-8 mobile:ml-4 mobile:gap-2 mobile:text-base'> + <Link href="/boards" className={isBoards ? 'text-primary-100 font-bold' : undefined}>자유게시판</Link> + <Link href="/items" className={isItems ? 'text-primary-100 font-bold' : undefined}>중고마켓</Link> + </div> + </div> + {user ? ( + <Button onClick={handleLogout} variant="roundedS"> + 로그아웃 + </Button> + ) : ( + <Button link="/login" variant="roundedS"> + 로그인 + </Button> + )} + </Container> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> + </div> + ); +} + +export default ProductNav; diff --git a/src/components/layout/providers/query-provider.tsx b/src/components/layout/providers/query-provider.tsx new file mode 100644 index 00000000..d00546ed --- /dev/null +++ b/src/components/layout/providers/query-provider.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { AuthProvider } from '@/contexts/AuthContext'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactNode, useState } from 'react'; + +export default function QueryProvider({ children }: { children: ReactNode }) { + const [client] = useState(() => new QueryClient()); + + return( + <QueryClientProvider client={client}> + <AuthProvider> + {children} + </AuthProvider> + </QueryClientProvider> + ); +} diff --git a/src/components/members/MembersLogo.tsx b/src/components/members/MembersLogo.tsx new file mode 100644 index 00000000..4e7d8e28 --- /dev/null +++ b/src/components/members/MembersLogo.tsx @@ -0,0 +1,23 @@ + +import React from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { logoImg1, logoImg2 } from '@/lib/imageAssets'; + + +function MembersLogo() { + return ( + <div> + <Link href="/" className='flex items-center justify-center mb-10 gap-6'> + <span className='relative inline-flex h-[84px] mobile:hidden'> + <Image src={logoImg1} width={110} height={110} className="w-full h-auto" priority alt="로고이미지" /> + </span> + <span className='relative inline-flex h-[60px]'> + <Image src={logoImg2} width={266} height={90} className="w-full h-auto" priority alt="판다마켓" /> + </span> + </Link> + </div> + ); +} + +export default MembersLogo; diff --git a/src/components/members/SnsLogin.module.css b/src/components/members/SnsLogin.module.css new file mode 100644 index 00000000..d4578c10 --- /dev/null +++ b/src/components/members/SnsLogin.module.css @@ -0,0 +1,19 @@ +.sns_login { + background-color: #e6f2ff; + color: #1f2937; + padding: 14px 23px; + margin-top: 24px; + margin-bottom: 24px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: space-between; +} +.sns_login .sns_icon a { + display: inline-block; + margin-left: 8px; +} +.sns_login .sns_icon a img { + height: 42px; + width: 42px; +} diff --git a/src/components/members/SnsLogin.tsx b/src/components/members/SnsLogin.tsx new file mode 100644 index 00000000..45d2b438 --- /dev/null +++ b/src/components/members/SnsLogin.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Link from 'next/link'; +import styles from './SnsLogin.module.css'; +import Image from 'next/image'; +import { sns_google, sns_kakao } from '@/lib/imageAssets'; + +function SnsLogin() { + return ( + <div className={styles.sns_login}> + <div className="sns_txt">간편 로그인하기</div> + <div className={styles.sns_icon}> + <Link href="https://www.google.com/" className="sns_gg" target="_blank" rel="noopener noreferrer"> + <Image src={sns_google} width={42} height={42} alt="sns_login_google" /> + </Link> + <Link href="https://www.kakaocorp.com/page/" className="sns_kt" target="_blank" rel="noopener noreferrer"> + <Image src={sns_kakao} width={42} height={42} alt="sns_login_kakao" /> + </Link> + </div> + </div> + ); +} + +export default SnsLogin; \ No newline at end of file diff --git a/src/components/ui/Button.module.css b/src/components/ui/Button.module.css new file mode 100644 index 00000000..9ec84004 --- /dev/null +++ b/src/components/ui/Button.module.css @@ -0,0 +1,214 @@ +.btn { + position: relative; + align-items: center; + overflow: hidden; +} +.btn .top { + position: absolute; + display: inline-flex; + top: 0; + left: 49.9%; + width: 100%; + text-align: center; + transition: opacity 0.5s ease, top 0.5s ease; + transform: translateX(-50%) translateY(-50%); + justify-content: center; + align-items: center; + opacity: 0; +} +.btn:hover .top { + top: 50%; + opacity: 1; +} +.btn:disabled .top { + transform: translateX(-50%) translateY(-15%) !important; + opacity: 0 !important; +} + +.btn:hover._2 .top { + top: 50%; + opacity: 1; +} +.btn .front { + display: inline-flex; + transition: opacity 0.5s ease, transform 0.5s ease; + transform: translateY(0px); + align-items: center; + opacity: 1; +} +.btn:hover .front { + top: 100%; + transform: translateY(50%); + opacity: 0; +} +.btn:disabled .front { + top: 0 !important; + transform: translateY(0%) !important; + opacity: 1 !important; +} +/* btn_large */ +.btn.roundedXL { + display: inline-flex; + font-size: 20px; + padding: 16px 124px; + border-radius: 50px; + justify-content: center; +} +/* btn_large */ +.btn.roundedL { + display: inline-flex; + font-size: 18px; + padding: 11px 40px; + border-radius: 50px; + justify-content: center; +} +/* btn_medium */ +.btn.roundedM { + display: inline-flex; + font-size: 16px; + padding: 11px 43px; + border-radius: 8px; +} +/* btn_small_48 */ +.btn.roundedS { + display: inline-flex; + font-size: 16px; + padding: 11px 30px; + border-radius: 8px; +} +/* btn_small_40 */ +.btn.roundedSS { + display: inline-flex; + font-size: 16px; + padding: 11px 23px; + border-radius: 8px; + line-height: 1; +} +.btn.roundedXL, +.btn.roundedL, +.btn.roundedM, +.btn.roundedS, +.btn.roundedSS { + background-color: var(--Primary); + color: #fff; + cursor: pointer; + border: 0; +} +.btn.roundedXL:hover, +.btn.roundedL:hover, +.btn.roundedM:hover, +.btn.roundedS:hover, +.btn.roundedSS:hover { + background-color: var(--Primary_200); + color: #fff; +} +.btn.roundedXL:active, +.btn.roundedL:active, +.btn.roundedM:active, +.btn.roundedS:active, +.btn.roundedSS:active { + background-color: var(--Primary_300); + color: #fff; +} +.btn.roundedXL.n_act, +.btn.roundedL.n_act, +.btn.roundedM.n_act, +.btn.roundedS.n_act, +.btn.roundedSS.n_act { + background-color: var(--Cool_Gray_400); + color: #fff; +} +.btn.roundedXL:disabled, +.btn.roundedL:disabled, +.btn.roundedM:disabled, +.btn.roundedS:disabled, +.btn.roundedSS:disabled { + background-color: var(--Cool_Gray_400); + animation: none; +} + +@keyframes color-change-2x { + 0% { + background: #19dcea; + } + + to { + background: #8697ff; + } +} +.btn.none { + display: inline-flex; + font-size: 16px; + padding: 11px 30px; +} + +/* btn_small_40 */ +.btn.lined_btn { + display: inline-flex; + font-size: 16px; + padding: 11px 30px; + border-radius: 8px; +} + +.btn.lined_btn { + border: 1px solid var(--Primary); + color: var(--Primary); + cursor: pointer; +} +.btn.lined_btn:hover { + border-color: var(--Primary_200); + color: var(--Primary_200); +} +.btn.lined_btn:active { + border-color: var(--Primary_300); + color: var(--Primary_300); +} +.btn.lined_btn:disabled { + border-color: var(--Cool_Gray_400); + color: var(--Cool_Gray_400); +} + +.btn.btn-heart_L { + color: var(--Cool_Gray_500); + border: 1px solid var(--Cool_Gray_500); + border-radius: 35px; + padding: 8px 13px; + display: flex; + align-items: center; + gap: 6px; + font-size: 16px; + background-color: #fff; +} +.btn.btn-heart_L img { + height: 23px; +} + +.btn.btn-heart_S { + font-size: 12px; + line-height: 18px; + display: flex; + cursor: pointer; +} + +.btn.btn-heart_S img { + width: 16px; + margin-right: 4px; +} + +@media (max-width: 1120px) { + .btn.roundedM { + padding: 12px 124px; + } +} + +/* Mobile */ +@media (max-width: 767px) { + .btn.roundedXL { + font-size: 18px; + padding: 11px 71px; + } + .btn.roundedM { + font-size: 18px; + padding: 11px 71px; + } +} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 00000000..1b34e7e5 --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,48 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import styles from './Button.module.css'; +import clsx from 'clsx'; + +interface ButtonProps { + link?: string; // Made optional + variant: string; + children: React.ReactNode; + className?: string; + heightError?: any; + disabled?: boolean; + onClick?: () => void; + childrenClassName?: string; + [key: string]: any; +} +function Button({ variant, className, childrenClassName, link, children,heightError,disabled, ...restProps } : ButtonProps) { + let combinedClassName = clsx(styles.btn, styles[variant], className); + + if(heightError) combinedClassName = clsx(styles.btn, styles._2, styles[variant], className, 'flex gap-2'); + + if (link) { + return ( + <Link + {...restProps} + href={link} + className={combinedClassName} + > + <span className={clsx(styles.top,childrenClassName)}>{children}</span> + <span className={clsx(styles.front,childrenClassName)}>{children}</span> + </Link> + ); + } + return ( + <button + {...restProps} + className={combinedClassName} + disabled={disabled} + > + <span className={clsx(styles.top,childrenClassName)}>{children}</span> + <span className={clsx(styles.front,childrenClassName)}>{children}</span> + </button> + ); +} + +export default Button; diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx new file mode 100644 index 00000000..6ab3b3ea --- /dev/null +++ b/src/components/ui/ConfirmModal.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import Button from './Button'; + +interface ErrorModalProps { + isOpen: boolean; + errorMessage: string; + onClose: () => void; +} + +export default function ConfirmModal({ isOpen, errorMessage, onClose }: ErrorModalProps) { + const [mounted, setMounted] = useState(false); + const confirmButtonRef = useRef<HTMLButtonElement>(null); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (isOpen && confirmButtonRef.current) { + confirmButtonRef.current.focus(); + } + }, [isOpen]); + + + if (!isOpen || !mounted) return null; + + const modalRoot = document.getElementById('modal-root'); + if (!modalRoot) return null; + + return createPortal( + <div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50"> + <div className="bg-white px-[187px] py-[68px] tablet:px-[90px] tablet:py-[52px] rounded-lg shadow-lg text-center"> + <p className="mb-[42px] font-bold">{errorMessage}</p> + <Button + variant="roundedS" + onClick={onClose} + onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => { + if (e.key === 'Enter') { + e.preventDefault(); + onClose(); + } + }} + ref={confirmButtonRef} + className="px-[46px] py-[11px] bg-blue-500 text-white rounded hover:bg-blue-600" + > + 확인 + </Button> + </div> + </div>, + modalRoot + ); +} diff --git a/src/components/ui/DropdownMenu.tsx b/src/components/ui/DropdownMenu.tsx new file mode 100644 index 00000000..2de52585 --- /dev/null +++ b/src/components/ui/DropdownMenu.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useRef, useState } from 'react'; +import clsx from 'clsx'; +import Icon from './Icon'; + + +interface DropdownAction { + label: string; + onClick: () => void; +} + +interface DropdownMenuProps { + dropdownActions: DropdownAction[]; + className?: string; +} + +function DropdownMenu({ dropdownActions, className }: DropdownMenuProps) { + + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + // cleanup + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + return ( + <div className={clsx( className)} ref={dropdownRef}> + <div className="absolute top-0 right-0 cursor-pointer" onClick={() => setIsOpen((prev) => !prev)}> + <Icon iconName="ic_kebab" width="24" height="24" alt="드롭다운 버튼" /> + </div> + {isOpen && ( + <div className={clsx(`${isOpen ? 'scale-y-100' : 'scale-y-0'} transition-transform origin-top absolute top-8 right-0 py-2 z-40 w-32 rounded-lg border border-Cool-Gray-200)] bg-white text-secondary-500 flex flex-col mobile:w-[100px]`)}> + {dropdownActions.map((action, index) => ( + <button + key={index} + className='py-2 hover:bg-secondary-50' + onClick={() => { + setIsOpen(false); + action.onClick(); + }}> + {action.label} + </button> + ))} + </div> + )} + </div> + ); +} +export default DropdownMenu; diff --git a/src/components/ui/EmptyBox.tsx b/src/components/ui/EmptyBox.tsx new file mode 100644 index 00000000..3db0a251 --- /dev/null +++ b/src/components/ui/EmptyBox.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Container from '../layout/Container'; +import Image from 'next/image'; +import { emptyImg } from '@/lib/imageAssets'; +import clsx from 'clsx'; + + +interface EmptyBoxProps { + context?: string; + className?: string; +} + +function EmptyBox({context, className}: EmptyBoxProps) { + return ( + <Container className={clsx('mt-12 mb-20 text-center',className)}> + <div className='flex flex-col justify-center items-center h-full rounded-[8px]'> + <Image src={emptyImg} width={176} height={176} className='mx-auto' alt='빈페이지' /> + <span className='text-center mx-auto text-cool-gray-400'>{context}</span> + </div> + </Container> + ); +} +export default EmptyBox; diff --git a/src/components/ui/Icon.tsx b/src/components/ui/Icon.tsx new file mode 100644 index 00000000..e926f745 --- /dev/null +++ b/src/components/ui/Icon.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import Image from 'next/image'; + +const eyeOpen = '/assets/eye_1.svg'; +const eyeClose = '/assets/eye_2.svg'; + +const heartOpen = '/assets/heart_1.svg'; +const heartClose = '/assets/heart_2.svg'; + +const arrowDown = '/assets/ic_arrow_down.svg'; +const back = '/assets/ic_back.svg'; +const check = '/assets/ic_check.svg'; +const plus = '/assets/ic_plus.svg'; +const search = '/assets/ic_search.svg'; +const searchDarker = '/assets/ic_search_darker.svg'; +const X = '/assets/ic_X.svg'; +const sort = '/assets/ic_sort.svg'; + +const statusActiveL = '/assets/status_active.svg'; +const statusActiveR = '/assets/status_active-1.svg'; +const statusInactiveL = '/assets/status_inactive.svg'; +const statusInactiveR = '/assets/status_inactive-1.svg'; +const statusWhiteL = '/assets/status_white.svg'; +const statusWhiteR = '/assets/status_white-1.svg'; + +const ic_kebab = '/assets/ic_kebab.svg'; + +const ICON = { + eyeOpen, + eyeClose, + heartOpen, + heartClose, + arrowDown, + back, + check, + plus, + search, + searchDarker, + X, + ic_kebab, + sort, + statusActiveL, + statusActiveR, + statusInactiveL, + statusInactiveR, + statusWhiteL, + statusWhiteR, +}; + +interface IconProps { + iconName: keyof typeof ICON; + alt: string; + [key: string]: any; +} + +function Icon({ iconName, alt, width="24", height="24", ...rest }: IconProps) { + return ( + <Image src={ICON[iconName]} width={width} height={height} alt={alt} {...rest} /> + ); +} + + +export default Icon ; \ No newline at end of file diff --git a/src/components/ui/LikeButton.tsx b/src/components/ui/LikeButton.tsx new file mode 100644 index 00000000..d68843ad --- /dev/null +++ b/src/components/ui/LikeButton.tsx @@ -0,0 +1,49 @@ +'use client'; + +import Button from "./Button"; +import Icon from "./Icon"; +import { useState } from "react"; +import { useToggleProductFavorite } from "@/hooks/useItems"; +import ConfirmModal from "./ConfirmModal"; +import { useConfirmModal } from "@/hooks/useModal"; + +interface LikeButtonProps { + className?: string; + childrenClassName?: string; + onClick?: () => void; + [key: string]: any; +} +function LikeButton({ + productId, + className, + childrenClassName, + favoriteCount, + isFavorite, + variant="btn-heart_S", + width = 16, height = 16 , + ...restProps +} : LikeButtonProps) { + + const [isFavorited, setIsFavorited] = useState(isFavorite); + const [count, setCount ] = useState(favoriteCount); + + + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + const { mutate: toggleFavorite } = useToggleProductFavorite(openConfirmModal); + + const handleClick = () => { + toggleFavorite({ productId, isFavorited ,setIsFavorited, setCount}); + }; + + return ( + <> + <Button onClick={handleClick} variant={variant} childrenClassName={childrenClassName}> + <Icon iconName={isFavorited === false ? 'heartOpen' : 'heartClose'} width={width} height={height} alt='Like icon' /> + <span>{count}</span> + </Button> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> + </> + ) + +} +export default LikeButton; diff --git a/src/components/ui/LoadingBox.tsx b/src/components/ui/LoadingBox.tsx new file mode 100644 index 00000000..c6de85ee --- /dev/null +++ b/src/components/ui/LoadingBox.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import styles from './LoadingBox.module.css'; +import Container from '../layout/Container'; + + +interface LoadingBoxProps { + className?: string; +} + +function LoadingBox({className}: LoadingBoxProps) { + return ( + <Container className={className}> + <div className='flex justify-center items-center h-full bg-[var(--Cool_Gray_100)] rounded-[8px]'> 페이지 로딩중입니다. </div> + </Container> + ); +} +export default LoadingBox; diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx new file mode 100644 index 00000000..f3a2594a --- /dev/null +++ b/src/components/ui/Modal.tsx @@ -0,0 +1,59 @@ +'use client'; +import React, { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import Icon from './Icon'; +import Button from './Button'; + + +interface ModalProps { + isOpen: boolean; + closeModal: () => void; + onclick: () => void; + message: string; +} + +const Modal: React.FC<ModalProps> = ({ isOpen, closeModal, onclick, message }) => { + const [mounted, setMounted] = useState(false); + const confirmButtonRef = useRef<HTMLButtonElement>(null); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (isOpen && confirmButtonRef.current) { + confirmButtonRef.current.focus(); + } + }, [isOpen]); + + if (!isOpen || !mounted) return null; + + const modalRoot = document.getElementById('modal-root'); + if (!modalRoot) return null; + + return createPortal( + <div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50"> + <div className="bg-white rounded-xl p-6 shadow-xl relative border border-cool-gray-200"> + <Icon iconName="check" width="12" height="12" className="bg-[var(--primary_100)] mx-auto mb-6" alt="check icon" /> + <p className="mb-8 text-center">{message}</p> + <div className="flex justify-center gap-2"> + <Button variant="lined_btn" onClick={() => closeModal()}>아니요</Button> + <Button + variant="roundedS" + onClick={() => onclick()} + onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => { + if (e.key === 'Enter') { + e.preventDefault(); + onclick(); + } + }} + ref={confirmButtonRef} + >확인</Button> + </div> + </div> + </div>, + modalRoot + ); +}; + +export default Modal; \ No newline at end of file diff --git a/src/components/ui/PageNation.module.css b/src/components/ui/PageNation.module.css new file mode 100644 index 00000000..54f0435a --- /dev/null +++ b/src/components/ui/PageNation.module.css @@ -0,0 +1,34 @@ +.pageNation { + display: flex; + justify-content: center; + margin: 43px auto 58px; + gap: 4px; +} + +.pageNation li { + line-height: 20px; + padding: 10px 0; + height: 40px; + width: 40px; + border-radius: 9999px; + text-align: center; + border: 1px solid var(--Cool_Gray_200); + color: var(--Cool_Gray_500); + font-size: 16px; + cursor: pointer; +} +.pageNation li img { + margin: 0 auto; +} + +.pageNation li.active { + background-color: #2f80ed; + border-color: #2f80ed; + color: #f9fafb; +} +.pageNation li.disabled { + cursor: default; +} +.pageNation li.disabled img { + opacity: 0.2; +} diff --git a/src/components/ui/PageNation.tsx b/src/components/ui/PageNation.tsx new file mode 100644 index 00000000..001c9b22 --- /dev/null +++ b/src/components/ui/PageNation.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import styles from './PageNation.module.css'; +import Icon from './Icon'; + + +interface PageNationListProps { + list: number; + onClick: (list: number) => void; + current: number; +} + +function PageNationList ({list, onClick, current}: PageNationListProps){ + const handleClick = () => onClick(list); + return ( + <li onClick={handleClick} className={ list === Number(current) ? styles.active : ''}>{list}</li> + ) +} + +interface PageNationProps { + current: number; + page: number; + totalNum: number | undefined; + size: number; + clickEvent: (page: number) => void; +} + +function PageNation({ current , page , totalNum , size , clickEvent}: PageNationProps){ + + + if( totalNum === undefined || size === 0) return null; + const totalPages = Math.ceil(totalNum / size); + + function getNearestMultiplesOfFive(num: number) { + if( num > 0 || num % Number(page) !== 0){ + const lower = Math.floor((num - 1) / 5) * 5 + 1; + const upper = lower + 4; + return { lower , upper }; + } + } + + const getPageNation = (num: number) => { + const pages = getNearestMultiplesOfFive(num); + + if (!pages) return []; + + const first = pages?.lower; + const last = pages?.upper > totalPages ? totalPages : pages?.upper; + + return Array.from( { length: last - first + 1 }, (_, i) => first + i); + } + + const pageArr = getPageNation(current); + const pageFirst = pageArr[0]; + const pageLast = pageArr[Number(page) - 1]; + + const handleClickPrev = () => { + if (pageFirst > 1) { + clickEvent(pageFirst - 1); + } + }; + + const handleClickNext = () => { + if (pageLast < totalPages) { + clickEvent(pageLast + 1); + } + }; + + return ( + ( totalPages < 2) ? null : ( + <ul className={styles.pageNation}> + <li className={ pageFirst > 1 ? styles.prev : styles.disabled } onClick={handleClickPrev} ><Icon iconName='statusActiveL' width="16" height="16" alt='prev'/></li> + {pageArr.map(list => ( + <PageNationList key={list} onClick={clickEvent} list={list} current={current}/> + ))} + <li className={ pageLast < totalPages ? styles.next : styles.disabled } onClick={handleClickNext}><Icon iconName='statusActiveR' width="16" height="16" alt='next'/></li> + </ul> + ) + ) +}; + +export default PageNation; \ No newline at end of file diff --git a/src/components/ui/SelectBox.module.css b/src/components/ui/SelectBox.module.css new file mode 100644 index 00000000..13410663 --- /dev/null +++ b/src/components/ui/SelectBox.module.css @@ -0,0 +1,78 @@ +.selectBox { + position: relative; + width: 130px; + z-index: 999; +} +.selectBox .selectBtn { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + border: 1px solid var(--Cool_Gray_200); + color: var(--Secondary_800); + padding: 8px 18px; + border-radius: 12px; + background-color: #fff; + cursor: pointer; +} + +.selectBox .selectBtn span { + line-height: 24px; + font-size: 16px; +} +.selectBox .selectBtn img { + height: 24px; +} + +.selectBoxOption { + position: absolute; + top: 50px; + right: 0; + width: 100%; + border-radius: 12px; + border: 1px solid var(--Cool_Gray_200); + overflow: hidden; + transform: scaleY(0); + z-index: 99; +} + +.selectBoxOption.active { + transform: scaleY(1); +} + +.selectBoxOption li { + width: 100%; + text-align: center; +} +.selectBoxOption button { + width: 100%; + border: 0; + background-color: #fff; + cursor: pointer; + font-size: 16px; + padding: 8px 18px; + color: var(--Secondary_800); +} + +.selectBoxOption li:first-child button { + border-bottom: 1px solid var(--Cool_Gray_200); +} + +/* tablet */ +@media (max-width: 1199px) { +} +/* Mobile */ +@media (max-width: 767px) { + .selectBox { + width: 42px; + } + .selectBox .selectBtn { + padding-left: 0; + padding-right: 0; + justify-content: center; + } + + .selectBoxOption { + width: 130px; + } +} diff --git a/src/components/ui/SelectBox.tsx b/src/components/ui/SelectBox.tsx new file mode 100644 index 00000000..cab7d204 --- /dev/null +++ b/src/components/ui/SelectBox.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { useState } from 'react'; +import styles from './SelectBox.module.css'; +import Icon from './Icon'; +import clsx from 'clsx'; + + +interface SelectBoxListProps { + ItemValue: string; + Event: (value: string) => void; + ItemLabel: string; +} + +function SelectBoxList({ ItemValue, Event, ItemLabel }: SelectBoxListProps) { + const handleClick = () => Event(ItemValue); + return ( + <li onClick={handleClick}><button type="button">{ItemLabel}</button></li> + ); +} + +interface SelectBoxProps { + options: { value: string; label: string }[]; + current: string; + clickEvent: (value: string) => void; + screenType: number; +} + +function SelectBox({ options, current, clickEvent, screenType }: SelectBoxProps) { + + const [isSelect, setIsSelect] = useState(false); + const handleClickToggle = () => { + setIsSelect((prev) => !prev); // 현재 상태를 반전시킴 + }; + + return ( + <section className={styles.selectBox} > + <button type="button" className={styles.selectBtn} onClick={handleClickToggle}> + { screenType === 0 ? + <Icon iconName='sort' alt='select box'/> + : + <> + <span>{( current === options[0].value ) ? options[0].label : options[1].label }</span> + <Icon iconName='arrowDown' alt='select box'/> + </> + } + </button> + <ul className={clsx(styles.selectBoxOption, { [styles.active]: isSelect })}> + {options.map((item , index) => ( + <SelectBoxList key={index} ItemValue={item.value} Event={clickEvent} ItemLabel={item.label} /> + ))} + </ul> + </section> + ); +} + +export default SelectBox; diff --git a/src/components/ui/TagBox.module.css b/src/components/ui/TagBox.module.css new file mode 100644 index 00000000..385c2987 --- /dev/null +++ b/src/components/ui/TagBox.module.css @@ -0,0 +1,34 @@ +.tagBox { + display: flex; + flex-direction: column; + gap: 14px; +} +.tagList { + display: flex; + gap: 12px; +} +.tagList li { + position: relative; + display: flex; + gap: 8px; + padding: 5px 16px; + padding-right: 44px; + background-color: var(--Cool_Gray_100); + border-radius: 999px; + line-height: 26px; +} +.tagDeleteBtn { + position: absolute; + top: 7px; + right: 12px; + width: 22px; + height: 22px; + background-color: var(--Cool_Gray_400); + border-radius: 999px; + z-index: 99; + cursor: pointer; +} + +.tagDeleteBtn img { + width: 22px; +} diff --git a/src/components/ui/TagBox.tsx b/src/components/ui/TagBox.tsx new file mode 100644 index 00000000..ca27b196 --- /dev/null +++ b/src/components/ui/TagBox.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { useState } from "react"; +import styles from './TagBox.module.css'; +import Icon from './Icon'; +import { InputField } from './form/InputBox'; +import { CreateProductRequest, ProductSummary } from '@/hooks/useItems'; + + +interface TagListProps { + tags: string; + onClickDelete: (index: number) => void; + num: number +} +function TagList({tags, onClickDelete, num}: TagListProps){ + const handleClick = () => onClickDelete(num); + return ( + <> + <span>#{tags}</span> + <div className={styles.tagDeleteBtn} onClick={handleClick}><Icon iconName='X' width="12" height="12" alt='delete product tag' /></div> + </> + ) +} + +interface TagBoxProps { + product: CreateProductRequest; + setProduct: React.Dispatch<React.SetStateAction<CreateProductRequest>>; +} + +function TagBox({product, setProduct}: TagBoxProps){ + const [inputValue, setInputValue] = useState(''); + + // 엔터를 KeyDown 했을때 + // inputValue 값을 product.tags 에 추가하고 input 박스 리셋 + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (inputValue.trim()) { // 공백 입력 + setProduct((prev) => ({ + ...prev, + tags: Array.from(new Set([...prev.tags || [], inputValue.trim()])), // 중복 제거 + })); + setInputValue(''); + } + } + } + + // 태그 미리보기 삭제 + function handleClickTagDelete(index: number){ + const updatedtag = [...product.tags || []]; + updatedtag.splice(index, 1); + + setProduct((prev) => ({ + ...prev, + tags: updatedtag, + })); + } + + return ( + <div className={styles.tagBox}> + <InputField + label='태그' + inputBoxType='text' + placeholder='태그를 입력해주세요' + value={inputValue} + onChange={(e: React.ChangeEvent<HTMLInputElement>) => setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + /> + {product.tags?.length === 0 ? null : + <ul className={styles.tagList}> + {product.tags?.map((tag ,index) => ( + <li key={index}> + <TagList tags={tag} onClickDelete={handleClickTagDelete} num={index}/> + </li> + ))} + </ul> + } + </div> + ) +} +export default TagBox; \ No newline at end of file diff --git a/src/components/ui/Title.module.css b/src/components/ui/Title.module.css new file mode 100644 index 00000000..eeef88e3 --- /dev/null +++ b/src/components/ui/Title.module.css @@ -0,0 +1,11 @@ +.title { + display: flex; + justify-content: space-between; + margin: 24px auto; +} + +.titleTag { + font-size: 20px; + line-height: 42px; + font-weight: 700; +} diff --git a/src/components/ui/Title.tsx b/src/components/ui/Title.tsx new file mode 100644 index 00000000..81bf2c34 --- /dev/null +++ b/src/components/ui/Title.tsx @@ -0,0 +1,25 @@ + +import React from 'react'; +import styles from './Title.module.css'; + +interface TitleProps { + titleTag?: keyof JSX.IntrinsicElements; + text?: string; + children?: React.ReactNode; + [key: string]: any; +} + +function Title ({ titleTag = 'h1', text, children, ...rest }: TitleProps) { + const TitleComponent = titleTag; + return ( + <div className={styles.title} {...rest} > + <div className={styles.left}> + <TitleComponent className={styles.titleTag}>{text}</TitleComponent> + </div> + <div className={styles.right}> + {children} + </div> + </div> + ) +} +export default Title; \ No newline at end of file diff --git a/src/components/ui/UserInfo.module.css b/src/components/ui/UserInfo.module.css new file mode 100644 index 00000000..bd3dfd41 --- /dev/null +++ b/src/components/ui/UserInfo.module.css @@ -0,0 +1,34 @@ +.userInfo { + display: flex; + align-items: center; + gap: 16px; +} +.userInfo > div { + display: flex; + flex-direction: column; + gap: 3px; +} +.userInfo > span { + width: 40px; + padding-top: 40px; + position: relative; + overflow: hidden; + border-radius: 999px; + border: 1px solid var(--Cool_Gray_200); +} +.userInfo img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; +} +.userInfo > div span:first-child { + color: var(--Secondary_600); + font-size: 14px; +} +.userInfo > div span:last-child { + color: var(--Cool_Gray_400); + font-size: 14px; +} diff --git a/src/components/ui/UserInfo.tsx b/src/components/ui/UserInfo.tsx new file mode 100644 index 00000000..8cc98acf --- /dev/null +++ b/src/components/ui/UserInfo.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import styles from './UserInfo.module.css'; +import clsx from 'clsx'; +import Image from 'next/image'; +import { tempUserImg } from '@/lib/imageAssets'; + + +interface UserInfoProps { + userImg?: string | null; + ownerNickname: string; + createdAtString: string; + fontSize?: string; +} + +function UserInfo({userImg = '', ownerNickname, createdAtString, fontSize = '14px'}: UserInfoProps) { + const userImageSrc = userImg === '' || userImg === null ? tempUserImg : userImg; + return ( + <div className={clsx(styles.userInfo, `text-[${fontSize}]`)}> + <span><Image src={userImageSrc} fill alt="작성자이미지"/></span> + <div> + <span>{ownerNickname}</span> + <span>{createdAtString}</span> + </div> + </div> + ); +} +export default UserInfo; diff --git a/src/components/ui/form/FormField.tsx b/src/components/ui/form/FormField.tsx new file mode 100644 index 00000000..342be083 --- /dev/null +++ b/src/components/ui/form/FormField.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import Image from 'next/image'; +import { eyeClose, eyeOpen } from '@/lib/imageAssets'; + +interface FormFieldProps { + id: string; + label: string; + type: string; + placeholder: string; + error: string; + onBlur: (e: React.FocusEvent<HTMLInputElement>) => void; + withEyeToggle?: boolean; + eyeState?: boolean; + onEyeToggle?: () => void; + eyeIconOpen?: string; + eyeIconClose?: string; +} + +export default function FormField({ + id, + label, + type, + placeholder, + error, + onBlur, + withEyeToggle = false, + eyeState = true, + onEyeToggle, + eyeIconOpen = eyeOpen, + eyeIconClose = eyeClose, +}: FormFieldProps) { + return ( + <label htmlFor={id} className="relative"> + {label} + <div> + <input + id={id} + type={type} + className={error && 'outline outline-1 outline-red-500'} + placeholder={placeholder} + onBlur={onBlur} + /> + {withEyeToggle && onEyeToggle && ( + <button + type="button" + onClick={onEyeToggle} + className="absolute bottom-4 right-6" + aria-label="비밀번호 표시 전환" + > + <Image + src={eyeState ? eyeIconOpen : eyeIconClose} + width={24} + height={24} + alt="toggle password" + /> + </button> + )} + </div> + {error && <span className="absolute top-3 right-3 text-error_red-50 text-base font-normal">{error}</span>} + </label> + ); +} diff --git a/src/components/ui/form/ImageFile.module.css b/src/components/ui/form/ImageFile.module.css new file mode 100644 index 00000000..173d34fe --- /dev/null +++ b/src/components/ui/form/ImageFile.module.css @@ -0,0 +1,120 @@ +.imageFile { + display: flex; + flex-direction: column; + gap: 16px; +} +.label span { + line-height: 26px; + font-size: 18px; + font-weight: 700; +} +.inputFile { + position: relative; + display: flex; + height: 282px; + gap: 16px; +} +.inputBtn { + position: relative; + width: 282px; + height: 100%; + background-color: var(--Cool_Gray_100); + overflow: hidden; + border-radius: 12px; +} +.inputBtn input { + opacity: 0; + width: 100%; + height: 100%; + cursor: pointer; +} +.fakeBox { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 12px; + pointer-events: none; +} + +.fakeBox img { + width: 48px; +} + +.fakeBox span { + font-size: 16px; + color: var(--Cool_Gray_400); +} + +.previewImg { + height: 100%; + display: flex; + gap: 12px; +} + +.previewImg li { + position: relative; + width: 282px; + overflow: hidden; + border-radius: 16px; +} + +.previewImg img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.previewDeleteBtn { + position: absolute; + top: 12px; + right: 12px; + width: 24px; + height: 24px; + background-color: var(--Cool_Gray_400); + border-radius: 999px; + z-index: 99; + cursor: pointer; +} +.previewDeleteBtn img { + width: 24px; +} +.error { + display: block; + color: #f74747; + font-size: 16px; + font-weight: 500; + margin-top: 8px; +} + +/* Mobile */ +@media (max-width: 767px) { + .inputFile { + height: auto; + } + .inputBtn { + width: 50%; + height: auto; + } + .inputBtn input { + width: 100%; + padding-top: 100%; + } + .previewImg { + width: 50%; + height: auto; + } + + .previewImg li { + width: 100%; + padding-top: 100%; + } +} diff --git a/src/components/ui/form/ImageFile.tsx b/src/components/ui/form/ImageFile.tsx new file mode 100644 index 00000000..6ac7ce1c --- /dev/null +++ b/src/components/ui/form/ImageFile.tsx @@ -0,0 +1,67 @@ + +import React from 'react'; +import styles from './ImageFile.module.css'; +import Image from 'next/image'; +import Icon from '../Icon'; +import { FallbackImage } from '@/components/FallbackImage/FallbackImage'; + +interface ImagePreviewsProps { + num: number; + onClickDelete: (num: number) => void; + src: string ; +} + +function ImagePreviews({ num, onClickDelete, src }: ImagePreviewsProps) { + const handleClick = () => onClickDelete(num); + console.log('src', src , typeof src); + return ( + <> + <div className="relative w-full aspect-[1/1]"> + <div className={styles.previewDeleteBtn} onClick={handleClick}><Icon iconName='X' width="12" height="12" alt='delete product image'/></div> + <FallbackImage src={src} fill alt={`preview image_${num}`}/> + </div> + </> + ) +} + +interface ImageFile { + label: string; + text: string; + images:(string | null)[]; + errorCase: string; + onChange: React.ChangeEventHandler; + onClickDelete: (num: number) => void; + [key: string]: any +} +function ImageFile({label, text, images, errorCase, onChange, onClickDelete, ...rest }:ImageFile) { + + return ( + <div className={styles.imageFile} {...rest}> + <label className={styles.label}> + <span>{label}</span> + </label> + <div> + <div className={styles.inputFile}> + <div className={styles.inputBtn}> + <input type='file' onChange={onChange} /> + <div className={styles.fakeBox}> + <Icon iconName='plus' width="48" height="48" alt='add product image'/> + <span>{text}</span> + </div> + </div> + <ul className={styles.previewImg}> + {( images.map((img ,index) => ( + img === null ? null : + <li key={index}> + <ImagePreviews num={index} onClickDelete={onClickDelete} src={img}/> + </li> + )))} + </ul> + </div> + { errorCase === '' ? null : <span className={styles.error}>{errorCase}</span> } + </div> + </div> + ) +} + +export default ImageFile; \ No newline at end of file diff --git a/src/components/ui/form/ImageFileBox.tsx b/src/components/ui/form/ImageFileBox.tsx new file mode 100644 index 00000000..35361087 --- /dev/null +++ b/src/components/ui/form/ImageFileBox.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { useState } from 'react'; +import ImageFile from './ImageFile'; +import { CreateProductRequest } from '@/hooks/useItems'; +import { useUploadImage } from '@/hooks/useUploadImage'; +import { useConfirmModal } from '@/hooks/useModal'; +import ConfirmModal from '../ConfirmModal'; + + +interface ImageFileBoxProps { + product: CreateProductRequest; + setProduct: React.Dispatch<React.SetStateAction<CreateProductRequest>>; +} + +function ImageFileBox({ product, setProduct }: ImageFileBoxProps) { + + const [preview, setPreview] = useState<(string | null)[]>([]); // 미리보기 이미지 상태 + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + const [errorCase, setErrorCase] = useState(''); + const MAX_IMAGE_COUNT = 1; + + // console.log('업로드된 이미지:', product.images); + const { mutate: uploadImage } = useUploadImage( + (msg) => openConfirmModal(msg), + (url) => { + // console.log('업로드 완료 URL:', url); + setPreview((prev) => [...prev, url]); + } + ); + function getFilesValue(e: React.ChangeEvent<HTMLInputElement>) { + const file = e.target.files?.[0]; + if (!file) return; + + if (preview.length >= MAX_IMAGE_COUNT) { + setErrorCase(`*이미지 등록은 최대 ${MAX_IMAGE_COUNT}개까지 가능합니다.`); + setTimeout(() => setErrorCase(''), 2000); + return; + } + + // 2. 서버 업로드 요청 → 성공 시 URL 저장 + uploadImage(file, { + onSuccess: (url) => { + setProduct((prev) => ({ + ...prev, + images: [...(prev.images || []), url], + })); + }, + onError: (msg) => { + setErrorCase(msg); + setTimeout(() => setErrorCase(''), 2000); + }, + }); +} + +// 미리보기 & URL 동기화 삭제 +function handleClickImgDelete(index: number) { + if (preview.length > 0) setErrorCase(''); + + // 삭제 시 preview와 images 모두 index 기준 삭제 + setPreview((prev) => prev.filter((_, i) => i !== index)); + setProduct((prev) => ({ + ...prev, + images: (prev.images || []).filter((_, i) => i !== index), + })); +} + + return( + <> + <ImageFile + label='상품 이미지' + text='이미지 등록' + images={preview} + errorCase={errorCase} + onChange={getFilesValue} + onClickDelete={handleClickImgDelete} + /> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> + </> + ) +} + +export default ImageFileBox; \ No newline at end of file diff --git a/src/components/ui/form/InputBox.module.css b/src/components/ui/form/InputBox.module.css new file mode 100644 index 00000000..89a77713 --- /dev/null +++ b/src/components/ui/form/InputBox.module.css @@ -0,0 +1,39 @@ +.label { + display: flex; + flex-direction: column; + gap: 16px; +} +.label span { + line-height: 26px; + font-size: 18px; + font-weight: 700; +} +.textarea, +.input { + padding: 16px 24px; + background-color: var(--Cool_Gray_100); + border-radius: 12px; + font-size: 16px; + border: 0; + outline: 0; +} + +.textarea:hover, +.textarea:active, +.textarea:focus, +.input:hover, +.input:active, +.input:focus { + border: 0; + outline: 0; +} + +.input[type="number"]::-webkit-outer-spin-button, +.input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.input[type="number"] { + -moz-appearance: textfield; +} diff --git a/src/components/ui/form/InputBox.tsx b/src/components/ui/form/InputBox.tsx new file mode 100644 index 00000000..92b25a60 --- /dev/null +++ b/src/components/ui/form/InputBox.tsx @@ -0,0 +1,60 @@ + +import React from 'react'; +import styles from './InputBox.module.css'; +import clsx from 'clsx'; + + +interface TextAreaBoxProps { + placeholder?: string; + height?: string | number; + [key: string]: any; +} +export function InputBox({ inputBoxType='text', placeholder, ...rest }: TextAreaBoxProps) { + return ( + <input + className={clsx(styles.input,'w-full h-20 text-sm')} + type={inputBoxType} + placeholder={placeholder} + {...rest} + /> + ); +} + +export function InputField({ label, inputBoxType, placeholder, ...rest }: TextAreaBoxProps) { + return ( + <label className={styles.label}> + <span>{label}</span> + <input + className={styles.input} + type={inputBoxType} + placeholder={placeholder} + {...rest} + /> + </label> + ); +} + + +export function TextAreaBox({ placeholder, height, ...rest }: TextAreaBoxProps) { + return ( + <textarea + className={clsx(styles.input,'w-full text-sm desktop:h-20 mobile:h-36')} + placeholder={placeholder} + {...rest} + /> + ); +} + +export function TextAreaField({ label, placeholder, height, ...rest }: TextAreaBoxProps) { + return ( + <label className={styles.label}> + <span>{label}</span> + <textarea + className={styles.textarea} + placeholder={placeholder} + style={{ height: 'auto', minHeight: `${height}`}} + {...rest} + /> + </label> + ); +} diff --git a/src/components/ui/mainSelection.tsx b/src/components/ui/mainSelection.tsx new file mode 100644 index 00000000..31697e7d --- /dev/null +++ b/src/components/ui/mainSelection.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import Container from '../layout/Container'; +import clsx from 'clsx'; + +interface MotionSelectionProps { + className?: string; + children?: React.ReactNode; + [key: string]: any; +} + +export function VisualSelection({ className, children }: MotionSelectionProps) { + + return ( + <div className={clsx("h-[540px] bg-[#CFE5FF] tablet:h-[744px] mobile:h-[540px]",className)}> + <Container className="relative h-full">{children}</Container> + </div> + ) +} +export function MotionSelection({ className, children }: MotionSelectionProps) { + return ( + <> + <motion.div + className="box" + initial={{ opacity: 0, scale: 1.1}} + whileInView={{ opacity: 1, scale: 1}} + transition={{ duration: 0.6 , delay:0.3 }} + viewport={{ once: true, amount: 0.3 }} + > + <Container className="relative"> + <div className={clsx("flex items-center gap-[64px] max-w-[988px] my-[138px] mx-auto bg-secondary-10 tablet:flex-col tablet:gap-[24px] tablet:my-[24px]", className)}> + {children} + </div> + </Container> + </motion.div> + </> + ); +} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 00000000..e089a5d0 --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { parseJwt, DecodedToken } from '@/utils/parseJwt'; + +interface AuthUser { + id: number; + nickname: string; + email: string; + image: string | null; +} + +interface AuthContextType { + token: DecodedToken | null; + user: AuthUser | null; + setUser: (user: AuthUser | null) => void; + setToken: (token: DecodedToken | null) => void; + logout: () => void; +} + +const AuthContext = createContext<AuthContextType | undefined>(undefined); + +export const AuthProvider = ({ children }: { children: React.ReactNode }) => { + const [token, setToken] = useState<DecodedToken | null>(null); + const [user, setUser] = useState<AuthUser | null>(null); + + useEffect(() => { + const token = localStorage.getItem('accessToken'); + const refreshToken = localStorage.getItem('refreshToken'); + const rawUser = localStorage.getItem('user'); + + console.log('token', token); + console.log('refreshToken', refreshToken); + console.log('rawUser', rawUser); + + if (token) { + const parsedToken = parseJwt(token); + + if (parsedToken && parsedToken.exp > Date.now() / 1000) { + if (rawUser) { + const user = JSON.parse(rawUser); + setUser(user); + } + } else { + localStorage.removeItem('accessToken'); + localStorage.removeItem('user'); + } + } + }, []); + const logout = () => { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + setUser(null); + }; + + return ( + <AuthContext.Provider value={{ token, user, setToken, setUser, logout }}> + {children} + </AuthContext.Provider> + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) throw new Error('useAuth must be used within AuthProvider'); + + return context; +}; diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 00000000..11d32486 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,122 @@ +'use client'; + +import { useMutation } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import axios from 'axios'; +import { requestor } from '@/lib/requestor'; +import { useAuth } from '@/contexts/AuthContext'; +import { useEffect, useState } from 'react'; +import { parseJwt } from '@/utils/parseJwt'; + +interface SignUpForm { + email: string; + nickname: string; + password: string; + passwordConfirmation: string; +} +export function useSignUp(openModal: (msg: string) => void) { + const router = useRouter(); + const { setUser } = useAuth(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const [savedPath, setSavedPath] = useState<string | null>(null); + + useEffect(() => { + const path = sessionStorage.getItem('redirectPath'); + setSavedPath(path); + }, []); + + const mutation = useMutation({ + mutationFn: async (form: SignUpForm) => { + const res = await requestor.post('/auth/signUp', form); + return res.data; + }, + onSuccess: (data) => { + const { accessToken, refreshToken, user } = data; + + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + localStorage.setItem('user', JSON.stringify(user)); + setUser(user); + + openModal('회원가입 완료 되었습니다!'); + if(savedPath){ + router.push(savedPath); + } else { + router.push('/'); + } + }, + onError: (error) => { + if (axios.isAxiosError(error) && error.response) { + openModal(error.response.data.message || '회원가입 중 오류가 발생했습니다.'); + } else { + openModal('서버 연결에 실패했습니다.'); + } + }, + }); + + return { + ...mutation, + isModalOpen, + closeModal: () => setIsModalOpen(false), + }; +} + + +interface LoginForm { + email: string; + password: string; +} + +export function useLoginMutation(openModal: (msg: string) => void) { + const router = useRouter(); + const { setToken, setUser } = useAuth(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [savedPath, setSavedPath] = useState<string | null>(null); + + useEffect(() => { + const path = sessionStorage.getItem('redirectPath'); + setSavedPath(path); + }, []); + + const mutation = useMutation({ + mutationFn: async (form: LoginForm) => { + const res = await requestor.post('/auth/signIn', form); + return res.data; + }, + onSuccess: (data) => { + const { accessToken, refreshToken, user } = data; + + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + localStorage.setItem('user', JSON.stringify(user)); + + const decoded = parseJwt(accessToken); + if (decoded) { + setToken(decoded); + setUser(user); + } + + openModal('로그인 되었습니다!'); + if(savedPath){ + router.push(savedPath); + } else { + router.push('/'); + } + }, + onError: (error) => { + if (axios.isAxiosError(error) && error.response) { + openModal(error.response.data.message || '로그인 실패'); + } else { + openModal('네트워크 오류가 발생했습니다.'); + } + }, + }); + + return { + ...mutation, + isModalOpen, + closeModal: () => setIsModalOpen(false), + }; +} + diff --git a/src/hooks/useBreakpoint.tsx b/src/hooks/useBreakpoint.tsx new file mode 100644 index 00000000..113e7063 --- /dev/null +++ b/src/hooks/useBreakpoint.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; + +export type Breakpoint = "mobile" | "tablet" | "desktop"; + +export function useBreakpoint(): Breakpoint { + const [breakpoint, setBreakpoint] = useState<Breakpoint>("desktop"); + + useEffect(() => { + const handleResize = () => { + const width = window.innerWidth; + + if (width <= 767) setBreakpoint("mobile"); + else if (width <= 1199) setBreakpoint("tablet"); + else setBreakpoint("desktop"); + }; + + handleResize(); // 최초 실행 + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + return breakpoint; +} diff --git a/src/hooks/useItemQuery.tsx b/src/hooks/useItemQuery.tsx new file mode 100644 index 00000000..847f816c --- /dev/null +++ b/src/hooks/useItemQuery.tsx @@ -0,0 +1,68 @@ +import { useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { ProductQuery, useItemList } from "./useItems"; + + +export const useItemService = ( + defaultQuery :ProductQuery, + searchParams: URLSearchParams +) => { + const parsedQuery = useParsedItemQuery(defaultQuery, searchParams); + const queryResult = useItemList(parsedQuery); + return { + ...queryResult, + }; +}; + +export const useParsedItemQuery = ( + defaultQuery :ProductQuery, + searchParams: URLSearchParams +) => { + if (!searchParams) return defaultQuery; + const parsedQuery = useMemo(() => { + const obj: Record<string, any> = {}; + + for (const [key, value] of searchParams.entries()) { + // console.log("searchParams", `${key} = ${value}`); + if (value === "true") obj[key] = true; + else if (value === "false") obj[key] = false; + else if (!isNaN(Number(value))) obj[key] = Number(value); + else obj[key] = value; + } + + return { + ...defaultQuery, + ...obj, + }; + }, [searchParams.toString()]); // URLSearchParams는 얕은 비교가 안 되므로 .toString()을 기준으로 해야 변경을 감지? + + return parsedQuery; +}; + +export const useSetItemQuery = () => { + const router = useRouter(); + + const setQueryToURL = (query: Record<string, any>) => { + const queryString = toQueryString(query); + + if (router) { + router.push(`${queryString}`, { scroll: false }); + } + }; + + return setQueryToURL; +}; + +export const toQueryString = (params: Record<string, any>): string => { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + if (Array.isArray(value)) { + value.forEach((v) => query.append(key, String(v))); + } else { + query.append(key, String(value)); + } + } + }); + return `?${query.toString()}`; +}; \ No newline at end of file diff --git a/src/hooks/useItems.tsx b/src/hooks/useItems.tsx new file mode 100644 index 00000000..7e59f207 --- /dev/null +++ b/src/hooks/useItems.tsx @@ -0,0 +1,136 @@ +import { requestor } from "@/lib/requestor"; +import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; +import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; +import { useEffect, useState } from "react"; + +export interface ProductQuery { + page: number; // 기본값 1 + pageSize: number; // 기본값 10 + orderBy: 'favorite' | 'recent'; // 기본값 'recent' + keyword?: string; // optional +} +export interface ProductSummary { + id?: number; + name?: string; + description?: string; + price?: number; + images?: string[]; + tags?: string[]; + ownerId?: number; + ownerNickname?: string; + favoriteCount?: number; + createdAt?: string; // ISO 문자열, 필요시 Date로 변환 가능 +} + +export interface ProductListResponse { + totalCount: number; + list: ProductSummary[]; +} + +export const useItemList = (query:ProductQuery) => { + return useQuery({ + queryKey: ['Items', query], + queryFn: async () => { + const res = await requestor.get<ProductListResponse>('/products', { + params: query, + }); + return res.data ; + }, + placeholderData: keepPreviousData, + }); +}; + +// 상품 등록 요청 타입 +export interface CreateProductRequest { + images: string[]; + tags: string[]; + price: number; + description: string; + name: string; +} + +// 상품 응답 타입 +export interface CreateProductResponse { + createdAt: string; + favoriteCount: number; + ownerNickname: string; + ownerId: number; + images: string[]; + tags: string[]; + price: number; + description: string; + name: string; + id: number; +} + +// 상품 등록 +export function usePostProduct(openModal: (msg: string) => void, router: AppRouterInstance ) { // 사용에따라 router를 인자로 받음 + + return useMutation({ + mutationFn: async (productData: CreateProductRequest) => { + const res = await requestor.post<CreateProductResponse>('/products', productData); + return res.data; + }, + onSuccess: (product) => { + openModal('상품 등록이 완료되었습니다!'); + router.push('/items'); + }, + onError: (error: any) => { + openModal(error?.response?.data?.message || '상품 등록 실패'); + }, + }); +}; + + +interface ProductFavoriteResponse { + productId: number; + isFavorited: boolean; + setIsFavorited: (value: boolean) => void, + setCount: (value: number | ((prev: number) => number)) => void +} +export const useToggleProductFavorite = ( + openModal: (msg: string) => void +) => { + + return useMutation({ + mutationFn: async ({ productId, isFavorited, setIsFavorited, setCount }:ProductFavoriteResponse ) => { + const token = localStorage.getItem('accessToken'); + if (!token) { + openModal('로그인이 필요합니다.'); + return Promise.reject('No accessToken'); + } + + setIsFavorited(!isFavorited); + setCount((prev: number) => isFavorited ? prev - 1 : prev + 1); + + if (isFavorited) { + return requestor.delete(`/products/${productId}/favorite`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + } else { + return requestor.post( + `/products/${productId}/favorite`, + {}, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + } + }, + onSuccess: (_, variables) => { + openModal(variables.isFavorited ? '관심상품이 해제되었습니다!' : '관심상품이 등록되었습니다!'); + }, + onError: (error) => { + const message = (error as any)?.response?.data?.message; + if (message?.includes('jwt malformed')) { + openModal('로그인 후 등록 가능합니다!'); + } else { + openModal(message || '관심상품 처리 실패'); + } + }, + }); +}; diff --git a/src/hooks/useModal.ts b/src/hooks/useModal.ts new file mode 100644 index 00000000..b3abd132 --- /dev/null +++ b/src/hooks/useModal.ts @@ -0,0 +1,46 @@ +import { useState, useCallback } from 'react'; + +export function useModal() { + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalMessage, setModalMessage] = useState(''); + + const openModal = useCallback((msg: string) => { + setModalMessage(msg); + setIsModalOpen(true); + }, []); + + const closeModal = useCallback(() => { + setIsModalOpen(false); + setModalMessage(''); + }, []); + + return { + isModalOpen, + modalMessage, + openModal, + closeModal + }; +} + + +export function useConfirmModal() { + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const [confirmMessage, setConfirmMessage] = useState(''); + + const openConfirmModal = useCallback((msg: string) => { + setConfirmMessage(msg); + setIsConfirmOpen(true); + }, []); + + const closeConfirmModal = useCallback(() => { + setIsConfirmOpen(false); + setConfirmMessage(''); + }, []); + + return { + isConfirmOpen, + confirmMessage, + openConfirmModal, + closeConfirmModal, + }; +} \ No newline at end of file diff --git a/src/hooks/usePagination.ts b/src/hooks/usePagination.ts new file mode 100644 index 00000000..82a2639b --- /dev/null +++ b/src/hooks/usePagination.ts @@ -0,0 +1,62 @@ +import { useEffect, useMemo, useState } from 'react' + +interface UsePaginationProps { + offset?: number | undefined + limit?: number | undefined + totalCount?: number | undefined + pageRange?: number // 보여줄 페이지 버튼 수 (기본값: 5) + setQuery: (query: { offset: number; limit: number }) => void +} + +export const usePagination = ({ offset = 0, limit = 10, totalCount = 0, pageRange = 5, setQuery }: UsePaginationProps) => { + const currentPage = Math.floor(offset / limit) + 1 // 현재페이지 + const totalPages = Math.ceil(totalCount / limit) // 총 페이지 + + const [pageGroupStart, setPageGroupStart] = useState(1) + + useEffect(() => { + const newGroupStart = Math.floor((currentPage - 1) / pageRange) * pageRange + 1 + setPageGroupStart(newGroupStart) + }, [currentPage, pageRange]) + + const pageList = useMemo(() => { + const end = Math.min(pageGroupStart + pageRange - 1, totalPages) + return Array.from({ length: end - pageGroupStart + 1 }, (_, i) => pageGroupStart + i) + }, [pageGroupStart, totalPages, pageRange]) + + + const goToPage = (page: number) => { + const newOffset = (page - 1) * limit + setQuery({ offset: newOffset, limit }) + } + + const goToPrev = () => { + const prevPage = currentPage - pageRange; + if (prevPage < 1) { + setQuery({ offset: 0, limit }) + } else { + const prevOffset = (prevPage - 1) * limit + setQuery({ offset: prevOffset, limit }) + } + } + + + const goToNext = () => { + const nextPage = currentPage + pageRange + if (nextPage > totalPages) { + const lastOffset = (totalPages - 1) * limit + setQuery({ offset: lastOffset, limit }) + } else { + const nextOffset = (nextPage - 1) * limit + setQuery({ offset: nextOffset, limit }) + } + } + return { + currentPage, + totalPages, + pageList, + goToPage, + goToNext, + goToPrev + } +} diff --git a/src/hooks/useProductsComments.tsx b/src/hooks/useProductsComments.tsx new file mode 100644 index 00000000..f1ae10ff --- /dev/null +++ b/src/hooks/useProductsComments.tsx @@ -0,0 +1,146 @@ +import { requestor } from '@/lib/requestor'; +import {useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + + +// 댓글 작성자 정보 +export interface CommentWriter { + id: number; + nickname: string; + image: string | null; +} + +// 댓글 아이템 +export interface CommentItemUnit { + id: number; + content: string; + createdAt: string; + updatedAt: string; + writer: CommentWriter; +} + +// 전체 응답 타입 +export interface CommentListResponse { + list: CommentItemUnit[]; + nextCursor: number; +} + +export interface GetCommentsQuery { + limit: number; + cursor?: number; +} + +export const useInfiniteProductsComments = (productId: number, limit = 10) => { + return useInfiniteQuery<CommentListResponse, Error>({ + queryKey: ['productComments', productId], + queryFn: async ({ pageParam }) => { + const res = await requestor.get<CommentListResponse>(`/products/${productId}/comments`, { + params: { + limit, + cursor: pageParam ?? null, + }, + }); + return res.data; + }, + initialPageParam: null, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? null, + }); +}; + +export const usePostProductComment = (productId: number, openModal: (msg: string) => void) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (requestCommentValue: string | undefined) => { + const token = localStorage.getItem('accessToken'); + if (!token) { + openModal('로그인이 필요합니다.'); + return Promise.reject('No accessToken'); + } + return requestor.post( + `/products/${productId}/comments`, + { content: requestCommentValue }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + }, + onSuccess: () => { + openModal('댓글이 등록되었습니다!'); + queryClient.invalidateQueries({ queryKey: ['productComments', productId] }); + }, + onError: (error: any) => { + const message = error?.response?.data?.message; + if (message?.includes('jwt malformed')) { + openModal('로그인 후 등록 가능합니다!'); + } else { + openModal(message || '댓글 등록 실패'); + } + }, + }); +}; + +export const usePatchProductComment = (productId: number, openModal: (msg: string) => void) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ commentId, requestCommentValue }: { commentId: number; requestCommentValue: string }) => { + const token = localStorage.getItem('accessToken'); + if (!token) { + openModal('로그인이 필요합니다.'); + return Promise.reject('No accessToken'); + } + return requestor.patch( + `/comments/${commentId}`, + { content: requestCommentValue }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + }, + onSuccess: () => { + openModal('댓글이 수정되었습니다!'); + queryClient.invalidateQueries({ queryKey: ['productComments', productId] }); + }, + onError: (error: any) => { + const message = error?.response?.data?.message; + if (message?.includes('jwt malformed')) { + openModal('로그인 후 수정 가능합니다!'); + } else { + openModal(error?.response?.data?.message || '댓글 수정 실패'); + } + }, + }); +}; +export const useDeleteCommentMutation = (productId: number, openModal: (msg: string) => void) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (commentId: number) => { + const token = localStorage.getItem('accessToken'); + if (!token) { + openModal('로그인이 필요합니다.'); + return Promise.reject('No accessToken'); + } + return requestor.delete(`/comments/${commentId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['productComments', productId] }); + }, + onError: (error: any) => { + const message = error?.response?.data?.message; + if (message?.includes('jwt malformed')) { + openModal('로그인 후 등록 가능합니다!'); + } else { + openModal(message || '댓글 삭제 실패'); + } + }, + }); +}; diff --git a/src/hooks/useProductsDetail.tsx b/src/hooks/useProductsDetail.tsx new file mode 100644 index 00000000..8314095b --- /dev/null +++ b/src/hooks/useProductsDetail.tsx @@ -0,0 +1,27 @@ +import { requestor } from "@/lib/requestor"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +export interface ProductDetail { + id: number; + name: string; + description: string; + price: number; + images: string[]; // 이미지 URL 리스트 + tags: string[]; // 태그 리스트 (예: ["전자제품"]) + isFavorite: boolean; // 사용자가 찜한 여부 + favoriteCount: number; // 총 찜 수 + createdAt: string; // ISO 시간 문자열 + ownerId: number; + ownerNickname: string; +} + +export const useProductsDetails = (productId:number) => { + return useQuery({ + queryKey: ['ItemsDetails', productId], + queryFn: async () => { + const res = await requestor.get<ProductDetail>(`/products/${productId}`); + return res.data; + }, + placeholderData: keepPreviousData, + }); +}; diff --git a/src/hooks/useScreenType.tsx b/src/hooks/useScreenType.tsx new file mode 100644 index 00000000..fa560b9d --- /dev/null +++ b/src/hooks/useScreenType.tsx @@ -0,0 +1,63 @@ +'use client'; +import { useState, useEffect, useCallback, useRef } from 'react'; + +const screenTypeValue = { + mobile: 767, + tablet: 1199, +}; + +const getScreenType = (winWidth: number) => + winWidth < screenTypeValue.mobile + ? 0 + : winWidth < screenTypeValue.tablet + ? 1 + : 2; + +export const useScreenType = () => { + + const winWidth = useWindowWidth(); + + + const [screenType, setScreenType] = useState(() => + typeof window !== 'undefined' ? getScreenType(winWidth) : 2 + ); + + const resizeTimeout = useRef<number | null>(null); + + const handleResize = useCallback(() => { + if (resizeTimeout.current) { + clearTimeout(resizeTimeout.current); + } + + resizeTimeout.current = window.setTimeout(() => { + const width = window.innerWidth; + const screenCount = getScreenType(width); + setScreenType(screenCount); + }, 100); + }, []); + + useEffect(() => { + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + if (resizeTimeout.current) clearTimeout(resizeTimeout.current); + }; + }, [handleResize]); + + return screenType; +}; + +export const useWindowWidth = () => { + const [width, setWidth] = useState(() => + typeof window !== 'undefined' ? window.innerWidth : 0 + ); + + useEffect(() => { + const handleResize = () => setWidth(window.innerWidth); + window.addEventListener('resize', handleResize); + + return () => window.removeEventListener('resize', handleResize); + }, []); + + return width; +}; \ No newline at end of file diff --git a/src/hooks/useUploadImage.ts b/src/hooks/useUploadImage.ts new file mode 100644 index 00000000..af8fc096 --- /dev/null +++ b/src/hooks/useUploadImage.ts @@ -0,0 +1,29 @@ +import { useMutation } from '@tanstack/react-query'; +import { requestor } from '@/lib/requestor'; // axios 인스턴스 + + + +export const useUploadImage = (openModal: (msg: string) => void ,onSuccessCallback: (url: string) => void) => { + return useMutation({ + mutationFn: async ( file:File ) => { + const formData = new FormData(); + formData.append('image', file); + + const res = await requestor.post<{ url: string }>('/images/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + return res.data.url; + }, + + onSuccess: (url) => { + onSuccessCallback(url); + }, + + // 💡 openModal은 mutation 호출 시 외부에서 핸들링 권장 (mutationFn에 묶는 건 anti-pattern일 수 있음) + onError: (error: any) => { + const message = error?.response?.data?.message || '이미지 업로드 실패'; + openModal(message); + }, + }); +}; diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts new file mode 100644 index 00000000..87796890 --- /dev/null +++ b/src/hooks/useUser.ts @@ -0,0 +1,60 @@ +import { requestor } from "@/lib/requestor"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +export interface UserFavorite { + id: number; + name: string; + description: string; + price: number; + images: string[]; + tags: string[]; + favoriteCount: number; + ownerId: number; + ownerNickname: string; + createdAt: string; +} + +export interface UserFavorites { + totalCount: number; + list: UserFavorite[]; +} + +export interface UserFavoritesQuery { + page?: number; + pageSize?: number; + keyword?: string; +} + +export const useGetUserFavorites = (query:UserFavoritesQuery) => { + const token = localStorage.getItem('accessToken'); + return useQuery({ + queryKey: ['UserFavorites', query], + queryFn: async () => { + const res = await requestor.get<UserFavorites>('/users/me/favorites', { + params: query, + headers: { + Authorization: `Bearer ${token}` + } + }); + return res.data ; + }, + placeholderData: keepPreviousData, + }); +}; + +export const useGetUserProducts = (query:UserFavoritesQuery) => { + const token = localStorage.getItem('accessToken'); + return useQuery({ + queryKey: ['UserProducts', query], + queryFn: async () => { + const res = await requestor.get<UserFavorites>('/users/me/Products', { + params: query, + headers: { + Authorization: `Bearer ${token}` + } + }); + return res.data ; + }, + placeholderData: keepPreviousData, + }); +}; diff --git a/src/lib/imageAssets.ts b/src/lib/imageAssets.ts new file mode 100644 index 00000000..e28d04cc --- /dev/null +++ b/src/lib/imageAssets.ts @@ -0,0 +1,46 @@ + +/** 허용된 외부 이미지 도메인 (next.config.ts, 클라이언트 공통 사용) */ +export const allowedImageDomains = [ + "cdn.wccftech.com", + "example.com", + "image.hanatour.com", + "cdn.choicenews.co.kr", + "sprint-fe-project.s3.ap-northeast-2.amazonaws.com", + "cdn.pixabay.com", + "i.pinimg.com", + "upload.wikimedia.org", + "image.hanatour.com", + "encrypted-tbn0.gstatic.com", + "health.chosun.com", + "via.placeholder.com", + "images.unsplash.com", +]; + +/** 허용된 이미지 확장자 (정규식 검사용) */ +export const imageExtensionRegex = /\.(jpe?g|png|webp|gif)$/i; + +/** fallback 이미지 경로 */ +export const defaultImg = '/assets/img/img_default_2x.png'; + +export const logoImg1 = '/assets/logo_01.svg'; +export const logoImg2 = '/assets/logo_03.svg'; + +export const emptyImg = '/assets/img/Img_inquiry_empty_2x.png'; +export const imgHome_top = '/assets/Img_home_top.png'; +export const imgHome1 = '/assets/Img_home_01.png'; +export const imgHome2 = '/assets/Img_home_02.png'; +export const imgHome3 = '/assets/Img_home_03.png'; +export const imgHome_bottom = '/assets/Img_home_bottom.png'; + +export const eyeOpen = '/assets/eye_1.svg'; +export const eyeClose = '/assets/eye_2.svg'; + +export const facebookIcon = '/assets/ic_facebook.svg'; +export const twitterIcon = '/assets/ic_twitter.svg'; +export const instagramIcon = '/assets/ic_instagram.svg'; +export const youtubeIcon = '/assets/ic_youtube.svg'; + +export const sns_google = '/assets/gg_icon.png'; +export const sns_kakao = '/assets/kakao_icon.png'; + +export const tempUserImg = '/assets/ic_3_01.png'; \ No newline at end of file diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts new file mode 100644 index 00000000..008af6db --- /dev/null +++ b/src/lib/react-query.ts @@ -0,0 +1,10 @@ +import { QueryClient } from '@tanstack/react-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); \ No newline at end of file diff --git a/src/lib/requestor.ts b/src/lib/requestor.ts new file mode 100644 index 00000000..5d534eb3 --- /dev/null +++ b/src/lib/requestor.ts @@ -0,0 +1,52 @@ +import axios from 'axios'; + +export const requestor = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_URL, + timeout: 60000, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, +}); + +// response interceptor +requestor.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + try { + const refreshToken = localStorage.getItem('refreshToken'); + if (!refreshToken) throw new Error('리프레시 토큰 없음'); + + const res = await requestor.post('/auth/refresh-token', { refreshToken }); + const { accessToken, user } = res.data; + + localStorage.setItem('accessToken', accessToken); + if (user) { + localStorage.setItem('user', JSON.stringify(user)); + } + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + return requestor(originalRequest); + } catch (refreshError) { + console.error('리프레시 실패 → 자동 로그아웃 필요'); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + window.location.href = '/login'; // ⬅️ 강제 로그인 페이지 이동 + return Promise.reject(refreshError); + } + } + return Promise.reject(error); + } +); + +// 요청 시 Authorization 자동 추가 (권장) +requestor.interceptors.request.use((config) => { + const token = localStorage.getItem('accessToken'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); \ No newline at end of file diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 00000000..1d8cd7df --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,62 @@ + +export const MEMBER_MESSAGE = { + enterEmail : '이메일을 입력해주세요.', + wrongEmail : '잘못된 이메일 형식입니다.', + enterName : '닉네임을 입력해주세요.', + enterPassword : '비밀번호를 입력해주세요.', + checkPassword : '비밀번호를 8자 이상 입력해주세요.', + wrongPassword : '비밀번호가 일치하지 않습니다.', +} + +export const memberCheck = { + PasswordLength : 8, + checkEmail : (email: string) => { + const emailRegex = /^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/; + return emailRegex.test(email); + }, + checkPassword : (password: string) => { + return password.length >= memberCheck.PasswordLength; + }, + EmailChecked : (email: string) => { + let errorMessage = ''; + if(email === '') { + errorMessage = MEMBER_MESSAGE.enterEmail; + } else if ( !memberCheck.checkEmail(email)) { + errorMessage = MEMBER_MESSAGE.wrongEmail; + } else { + errorMessage = ''; + } + return errorMessage; + }, + NameChecked: (name: string) => { + let errorMessage = ''; + if(name === '') { + errorMessage = MEMBER_MESSAGE.enterName; + } else { + errorMessage = ''; + } + return errorMessage; + }, + passwordChecked: (password: string) => { + let errorMessage = ''; + if(password === '') { + errorMessage = MEMBER_MESSAGE.enterPassword; + } else if ( !memberCheck.checkPassword(password)) { + errorMessage = MEMBER_MESSAGE.checkPassword; + } else { + errorMessage = ''; + } + return errorMessage; + }, + passwordDoubleChecked: (password: string, passwordDouble: string) => { + let errorMessage = ''; + if(password === '') { + errorMessage = MEMBER_MESSAGE.enterPassword; + } else if(password !== passwordDouble) { + errorMessage = MEMBER_MESSAGE.wrongPassword; + } else { + errorMessage = ''; + } + return errorMessage; + }, +} \ No newline at end of file diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 00000000..537519ed --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,36 @@ + +export function formatDate(dateStr: string) { + if (!dateStr) return ''; + + const inputDate = new Date(dateStr); + if (isNaN(inputDate.getTime())) return ''; + + const now = new Date(); + const diffMs = now.getTime() - inputDate.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + // console.log(diffHours, diffMinutes); + if (diffHours >= 1) { + return `${diffHours}시간 전`; + } else if (diffMinutes >= 1) { + return `${diffMinutes}분 전`; + } else { + return '방금 전'; + } + } else if (diffDays === 1) { + return '1일 전'; + } else if (diffDays === 2) { + return '2일 전'; + } else if (diffDays === 3) { + return '3일 전'; + } else { + // 날짜 포맷: 2024. 01. 02 + const yyyy = inputDate.getFullYear(); + const mm = String(inputDate.getMonth() + 1).padStart(2, '0'); + const dd = String(inputDate.getDate()).padStart(2, '0'); + return `${yyyy}. ${mm}. ${dd}`; + } +} \ No newline at end of file diff --git a/src/utils/parseJwt.ts b/src/utils/parseJwt.ts new file mode 100644 index 00000000..603b0e37 --- /dev/null +++ b/src/utils/parseJwt.ts @@ -0,0 +1,18 @@ +export interface DecodedToken { + sub: string; + exp: number; + iat: number; + nickname?: string; + email?: string; + role?: 'user'; +} + +export const parseJwt = (token: string): DecodedToken | null => { + try { + const payload = token.split('.')[1]; + const decoded = atob(payload); + return JSON.parse(decoded) as DecodedToken; + } catch { + return null; + } +}; \ No newline at end of file diff --git a/styles/Home.module.css b/styles/Home.module.css deleted file mode 100644 index 6676d2c6..00000000 --- a/styles/Home.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); - } -} diff --git a/styles/globals.css b/styles/globals.css deleted file mode 100644 index d4f491e1..00000000 --- a/styles/globals.css +++ /dev/null @@ -1,107 +0,0 @@ -: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/tailwind.config.js b/tailwind.config.js new file mode 100644 index 00000000..f7c6d3fc --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,46 @@ +module.exports = { + content: ["./src/**/*.{js,jsx,ts,tsx}"], + theme: { + extend: { + boxShadow: { + "soft-xl": "0 6px 6px rgba(0, 0, 0, 0.05)", // shadow-soft-xl + }, + minWidth: { + base: "325px", // min-w-base + }, + maxWidth: { + container: "1200px", // max-w-container + }, + screens: { + desktop: "1200px", // 데스크탑: 1200px 이상 + tablet: { max: "1199px" }, // 태블릿: 768px ~ 1199px + mobile: { max: "767px" }, // 모바일: 0 ~ 767px + }, + colors: { + primary: { + 100: "#3692ff", + 200: "#1967d6", + 300: "#1251aa", + }, + + secondary: { + 10: "#FCFCFC", + 50: "#f9fafb", + 100: "#f3f4f6", + 200: "#e5e7eb", + 400: "#9ca3af", + 500: "#6b7280", + 600: "#4b5563", + 700: "#374151", + 800: "#1f2937", + 900: "#111827", + }, + + error_red: { + 50: "#f74747", + }, + }, + }, + }, + plugins: [], +}; diff --git a/tsconfig.json b/tsconfig.json index 670224f3..620c9bd9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { - "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "target": "es2015", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -13,10 +17,23 @@ "isolatedModules": true, "jsx": "preserve", "incremental": true, + "baseUrl": "src", "paths": { - "@/*": ["./*"] - } + "@/*": ["*"] + }, + "plugins": [ + { + "name": "next" + } + ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] + "include": [ + "**/*.ts", + "**/*.tsx", + "next-env.d.ts", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] }