diff --git a/package-lock.json b/package-lock.json
index d32fbf2..346fd42 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,20 +8,28 @@
"name": "checkmo",
"version": "0.1.0",
"dependencies": {
- "next": "16.0.1",
+ "@vercel/analytics": "^1.6.1",
+ "@vercel/speed-insights": "^1.3.1",
+ "js-cookie": "^3.0.5",
+ "next": "^16.1.6",
"react": "19.2.0",
- "react-dom": "19.2.0"
+ "react-dom": "19.2.0",
+ "react-hot-toast": "^2.6.0",
+ "zod": "^4.3.6",
+ "zustand": "^5.0.10"
},
"devDependencies": {
"@tailwindcss/cli": "^4.1.17",
"@tailwindcss/postcss": "^4",
+ "@types/js-cookie": "^3.0.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"babel-plugin-react-compiler": "1.0.0",
+ "baseline-browser-mapping": "^2.9.19",
"eslint": "^9",
- "eslint-config-next": "16.0.1",
+ "eslint-config-next": "^16.1.6",
"postcss": "^8.5.6",
"tailwindcss": "^4",
"typescript": "^5"
@@ -1039,15 +1047,15 @@
}
},
"node_modules/@next/env": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.1.tgz",
- "integrity": "sha512-LFvlK0TG2L3fEOX77OC35KowL8D7DlFF45C0OvKMC4hy8c/md1RC4UMNDlUGJqfCoCS2VWrZ4dSE6OjaX5+8mw==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
+ "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.1.tgz",
- "integrity": "sha512-g4Cqmv/gyFEXNeVB2HkqDlYKfy+YrlM2k8AVIO/YQVEPfhVruH1VA99uT1zELLnPLIeOnx8IZ6Ddso0asfTIdw==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
+ "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1055,9 +1063,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.1.tgz",
- "integrity": "sha512-R0YxRp6/4W7yG1nKbfu41bp3d96a0EalonQXiMe+1H9GTHfKxGNCGFNWUho18avRBPsO8T3RmdWuzmfurlQPbg==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
+ "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
"cpu": [
"arm64"
],
@@ -1071,9 +1079,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.1.tgz",
- "integrity": "sha512-kETZBocRux3xITiZtOtVoVvXyQLB7VBxN7L6EPqgI5paZiUlnsgYv4q8diTNYeHmF9EiehydOBo20lTttCbHAg==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
+ "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
"cpu": [
"x64"
],
@@ -1087,9 +1095,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.1.tgz",
- "integrity": "sha512-hWg3BtsxQuSKhfe0LunJoqxjO4NEpBmKkE+P2Sroos7yB//OOX3jD5ISP2wv8QdUwtRehMdwYz6VB50mY6hqAg==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
+ "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
"cpu": [
"arm64"
],
@@ -1103,9 +1111,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.1.tgz",
- "integrity": "sha512-UPnOvYg+fjAhP3b1iQStcYPWeBFRLrugEyK/lDKGk7kLNua8t5/DvDbAEFotfV1YfcOY6bru76qN9qnjLoyHCQ==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
+ "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
"cpu": [
"arm64"
],
@@ -1119,9 +1127,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.1.tgz",
- "integrity": "sha512-Et81SdWkcRqAJziIgFtsFyJizHoWne4fzJkvjd6V4wEkWTB4MX6J0uByUb0peiJQ4WeAt6GGmMszE5KrXK6WKg==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
+ "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
"cpu": [
"x64"
],
@@ -1135,9 +1143,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.1.tgz",
- "integrity": "sha512-qBbgYEBRrC1egcG03FZaVfVxrJm8wBl7vr8UFKplnxNRprctdP26xEv9nJ07Ggq4y1adwa0nz2mz83CELY7N6Q==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
+ "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
"cpu": [
"x64"
],
@@ -1151,9 +1159,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.1.tgz",
- "integrity": "sha512-cPuBjYP6I699/RdbHJonb3BiRNEDm5CKEBuJ6SD8k3oLam2fDRMKAvmrli4QMDgT2ixyRJ0+DTkiODbIQhRkeQ==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
+ "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
"cpu": [
"arm64"
],
@@ -1167,9 +1175,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.1.tgz",
- "integrity": "sha512-XeEUJsE4JYtfrXe/LaJn3z1pD19fK0Q6Er8Qoufi+HqvdO4LEPyCxLUt4rxA+4RfYo6S9gMlmzCMU2F+AatFqQ==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
+ "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
"cpu": [
"x64"
],
@@ -1863,6 +1871,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/js-cookie": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
+ "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1891,7 +1906,7 @@
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -2445,6 +2460,78 @@
"win32"
]
},
+ "node_modules/@vercel/analytics": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz",
+ "integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==",
+ "license": "MPL-2.0",
+ "peerDependencies": {
+ "@remix-run/react": "^2",
+ "@sveltejs/kit": "^1 || ^2",
+ "next": ">= 13",
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "svelte": ">= 4",
+ "vue": "^3",
+ "vue-router": "^4"
+ },
+ "peerDependenciesMeta": {
+ "@remix-run/react": {
+ "optional": true
+ },
+ "@sveltejs/kit": {
+ "optional": true
+ },
+ "next": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "svelte": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ },
+ "vue-router": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vercel/speed-insights": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@vercel/speed-insights/-/speed-insights-1.3.1.tgz",
+ "integrity": "sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@sveltejs/kit": "^1 || ^2",
+ "next": ">= 13",
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "svelte": ">= 4",
+ "vue": "^3",
+ "vue-router": "^4"
+ },
+ "peerDependenciesMeta": {
+ "@sveltejs/kit": {
+ "optional": true
+ },
+ "next": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "svelte": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ },
+ "vue-router": {
+ "optional": true
+ }
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2786,13 +2873,15 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.9.11",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
- "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
- "dev": true,
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
+ "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"license": "Apache-2.0",
"bin": {
- "baseline-browser-mapping": "dist/cli.js"
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
}
},
"node_modules/brace-expansion": {
@@ -3009,7 +3098,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -3464,13 +3552,13 @@
}
},
"node_modules/eslint-config-next": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.1.tgz",
- "integrity": "sha512-wNuHw5gNOxwLUvpg0cu6IL0crrVC9hAwdS/7UwleNkwyaMiWIOAwf8yzXVqBBzL3c9A7jVRngJxjoSpPP1aEhg==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
+ "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@next/eslint-plugin-next": "16.0.1",
+ "@next/eslint-plugin-next": "16.1.6",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -4156,6 +4244,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/goober": {
+ "version": "2.1.18",
+ "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
+ "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "csstype": "^3.0.10"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -4796,6 +4893,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/js-cookie": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
+ "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5366,14 +5472,14 @@
"license": "MIT"
},
"node_modules/next": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/next/-/next-16.0.1.tgz",
- "integrity": "sha512-e9RLSssZwd35p7/vOa+hoDFggUZIUbZhIUSLZuETCwrCVvxOs87NamoUzT+vbcNAL8Ld9GobBnWOA6SbV/arOw==",
- "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
+ "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
"license": "MIT",
"dependencies": {
- "@next/env": "16.0.1",
+ "@next/env": "16.1.6",
"@swc/helpers": "0.5.15",
+ "baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -5385,14 +5491,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "16.0.1",
- "@next/swc-darwin-x64": "16.0.1",
- "@next/swc-linux-arm64-gnu": "16.0.1",
- "@next/swc-linux-arm64-musl": "16.0.1",
- "@next/swc-linux-x64-gnu": "16.0.1",
- "@next/swc-linux-x64-musl": "16.0.1",
- "@next/swc-win32-arm64-msvc": "16.0.1",
- "@next/swc-win32-x64-msvc": "16.0.1",
+ "@next/swc-darwin-arm64": "16.1.6",
+ "@next/swc-darwin-x64": "16.1.6",
+ "@next/swc-linux-arm64-gnu": "16.1.6",
+ "@next/swc-linux-arm64-musl": "16.1.6",
+ "@next/swc-linux-x64-gnu": "16.1.6",
+ "@next/swc-linux-x64-musl": "16.1.6",
+ "@next/swc-win32-arm64-msvc": "16.1.6",
+ "@next/swc-win32-x64-msvc": "16.1.6",
"sharp": "^0.34.4"
},
"peerDependencies": {
@@ -5830,6 +5936,23 @@
"react": "^19.2.0"
}
},
+ "node_modules/react-hot-toast": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
+ "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.1.3",
+ "goober": "^2.1.16"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": ">=16",
+ "react-dom": ">=16"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -6953,10 +7076,9 @@
}
},
"node_modules/zod": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.2.tgz",
- "integrity": "sha512-b8L8yn4rIVfiXyHAmnr52/ZEpDumlT0bmxiq3Ws1ybrinhflGpt12Hvv54kYnEsGPRs6o/Ka3/ppA2OWY21IVg==",
- "dev": true,
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -6974,6 +7096,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.11",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
+ "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 4a1ad1e..13073fa 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hot-toast": "^2.6.0",
+ "zod": "^4.3.6",
"zustand": "^5.0.10"
},
"devDependencies": {
@@ -27,6 +28,7 @@
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"babel-plugin-react-compiler": "1.0.0",
+ "baseline-browser-mapping": "^2.9.19",
"eslint": "^9",
"eslint-config-next": "^16.1.6",
"postcss": "^8.5.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ee7cf01..9732f85 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,6 +29,9 @@ importers:
react-hot-toast:
specifier: ^2.6.0
version: 2.6.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ zod:
+ specifier: ^4.3.6
+ version: 4.3.6
zustand:
specifier: ^5.0.10
version: 5.0.10(@types/react@19.2.2)(react@19.2.0)
@@ -57,6 +60,9 @@ importers:
babel-plugin-react-compiler:
specifier: 1.0.0
version: 1.0.0
+ baseline-browser-mapping:
+ specifier: ^2.9.19
+ version: 2.9.19
eslint:
specifier: ^9
version: 9.39.1(jiti@2.6.1)
@@ -970,8 +976,8 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
- baseline-browser-mapping@2.8.25:
- resolution: {integrity: sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==}
+ baseline-browser-mapping@2.9.19:
+ resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
hasBin: true
brace-expansion@1.1.12:
@@ -2166,8 +2172,8 @@ packages:
peerDependencies:
zod: ^3.25.0 || ^4.0.0
- zod@4.1.12:
- resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
+ zod@4.3.6:
+ resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zustand@5.0.10:
resolution: {integrity: sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==}
@@ -2979,7 +2985,7 @@ snapshots:
balanced-match@1.0.2: {}
- baseline-browser-mapping@2.8.25: {}
+ baseline-browser-mapping@2.9.19: {}
brace-expansion@1.1.12:
dependencies:
@@ -2996,7 +3002,7 @@ snapshots:
browserslist@4.27.0:
dependencies:
- baseline-browser-mapping: 2.8.25
+ baseline-browser-mapping: 2.9.19
caniuse-lite: 1.0.30001754
electron-to-chromium: 1.5.249
node-releases: 2.0.27
@@ -3326,8 +3332,8 @@ snapshots:
'@babel/parser': 7.28.5
eslint: 9.39.1(jiti@2.6.1)
hermes-parser: 0.25.1
- zod: 4.1.12
- zod-validation-error: 4.0.2(zod@4.1.12)
+ zod: 4.3.6
+ zod-validation-error: 4.0.2(zod@4.3.6)
transitivePeerDependencies:
- supports-color
@@ -3865,7 +3871,7 @@ snapshots:
dependencies:
'@next/env': 16.1.6
'@swc/helpers': 0.5.15
- baseline-browser-mapping: 2.8.25
+ baseline-browser-mapping: 2.9.19
caniuse-lite: 1.0.30001754
postcss: 8.4.31
react: 19.2.0
@@ -4418,11 +4424,11 @@ snapshots:
yocto-queue@0.1.0: {}
- zod-validation-error@4.0.2(zod@4.1.12):
+ zod-validation-error@4.0.2(zod@4.3.6):
dependencies:
- zod: 4.1.12
+ zod: 4.3.6
- zod@4.1.12: {}
+ zod@4.3.6: {}
zustand@5.0.10(@types/react@19.2.2)(react@19.2.0):
optionalDependencies:
diff --git a/public/icons_calling.svg b/public/icons_calling.svg
new file mode 100644
index 0000000..c80042f
--- /dev/null
+++ b/public/icons_calling.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/icons_chat.svg b/public/icons_chat.svg
new file mode 100644
index 0000000..9f8a19c
--- /dev/null
+++ b/public/icons_chat.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/icons_pencil.svg b/public/icons_pencil.svg
new file mode 100644
index 0000000..4b04fca
--- /dev/null
+++ b/public/icons_pencil.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/app/(main)/books/[id]/page.tsx b/src/app/(main)/books/[id]/page.tsx
index c40cf12..f2b6472 100644
--- a/src/app/(main)/books/[id]/page.tsx
+++ b/src/app/(main)/books/[id]/page.tsx
@@ -3,8 +3,8 @@
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import SearchBookResult from "@/components/base-ui/Search/search_bookresult";
-import BookStoryCard from "@/components/base-ui/BookStory/bookstory_card";
import { DUMMY_STORIES } from "@/data/dummyStories";
+import BookStoryCardLarge from "@/components/base-ui/BookStory/bookstory_card_large";
export default function BookDetailPage() {
const params = useParams();
@@ -23,58 +23,61 @@ export default function BookDetailPage() {
// 관련된 책 이야기들 (더미 데이터에서 필터링)
const relatedStories = DUMMY_STORIES.filter(
- (story) => story.bookTitle === bookData.title
+ (story) => story.bookTitle === bookData.title,
);
return (
-
-
- 도서 선택 {bookData.title} 중
-
+
+
+
+ 도서 선택 {bookData.title} 중
+
- {/* 선택한 책 카드 */}
-
- {
- router.push(`/stories/new?bookId=${bookId}`);
- }}
- />
-
+ {/* 선택한 책 카드 */}
+
+ {
+ router.push(`/stories/new?bookId=${bookId}`);
+ }}
+ />
+
- {/* 책이야기 */}
-
-
- 책이야기 {relatedStories.length}
-
-
+ {/* 책이야기 */}
+
+
+ 책이야기{" "}
+ {relatedStories.length}
+
+
- {/* 책 이야기 카드 */}
-
- {relatedStories.map((story) => (
-
router.push(`/stories/${story.id}`)}
- className="cursor-pointer"
- >
-
-
- ))}
+ {/* 책 이야기 카드 */}
+
+ {relatedStories.map((story) => (
+
router.push(`/stories/${story.id}`)}
+ className="cursor-pointer"
+ >
+
+
+ ))}
+
);
diff --git a/src/app/(main)/news/[id]/page.tsx b/src/app/(main)/news/[id]/page.tsx
index 55f3cd6..23ec1ce 100644
--- a/src/app/(main)/news/[id]/page.tsx
+++ b/src/app/(main)/news/[id]/page.tsx
@@ -1,3 +1,4 @@
+import FloatingFab from "@/components/base-ui/Float";
import TodayRecommendedBooks from "@/components/base-ui/News/today_recommended_books";
import Image from "next/image";
import { notFound } from "next/navigation";
@@ -159,21 +160,10 @@ export default async function NewsDetailPage({ params }: Props) {
- {/* 문의하기 */}
-
+
>
);
}
diff --git a/src/app/(main)/news/page.tsx b/src/app/(main)/news/page.tsx
index 60ff7dc..3d2c943 100644
--- a/src/app/(main)/news/page.tsx
+++ b/src/app/(main)/news/page.tsx
@@ -3,6 +3,7 @@
import Image from "next/image";
import NewsList from "@/components/base-ui/News/news_list";
import TodayRecommendedBooks from "@/components/base-ui/News/today_recommended_books";
+import FloatingFab from "@/components/base-ui/Float";
const DUMMY_NEWS = [
{
@@ -105,21 +106,11 @@ export default function NewsPage() {
- {/* 문의하기 */}
-
+
+
);
}
diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx
index 2799e9b..bdafd34 100644
--- a/src/app/(main)/page.tsx
+++ b/src/app/(main)/page.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import Image from "next/image";
import BookStoryCard from "@/components/base-ui/BookStory/bookstory_card";
import NewsBannerSlider from "@/components/base-ui/home/NewsBannerSlider";
import HomeBookclub from "@/components/base-ui/home/home_bookclub";
@@ -10,38 +11,30 @@ import LoginModal from "@/components/base-ui/Login/LoginModal";
import { DUMMY_STORIES } from "@/data/dummyStories";
import BookStoryCardLarge from "@/components/base-ui/BookStory/bookstory_card_large";
+import { useAuthStore } from "@/store/useAuthStore";
+
export default function HomePage() {
const groups: { id: string; name: string }[] = [];
- const [showLoginModal, setShowLoginModal] = useState(false);
-
+ const { isLoggedIn, isLoginModalOpen, openLoginModal, closeLoginModal } = useAuthStore();
+
// 사용자 더미 데이터
const users = [
- { id: '1', name: 'hy_0716', subscribingCount: 17, subscribersCount: 32 },
- { id: '2', name: 'hy_0716', subscribingCount: 17, subscribersCount: 32 },
- { id: '3', name: 'hy_0716', subscribingCount: 17, subscribersCount: 32 },
- { id: '4', name: 'hy_0716', subscribingCount: 17, subscribersCount: 32 },
+ { id: "1", name: "hy_0716", subscribingCount: 17, subscribersCount: 32 },
+ { id: "2", name: "hy_0716", subscribingCount: 17, subscribersCount: 32 },
+ { id: "3", name: "hy_0716", subscribingCount: 17, subscribersCount: 32 },
+ { id: "4", name: "hy_0716", subscribingCount: 17, subscribersCount: 32 },
];
return (
- {/* 임시 로그인 모달 테스트 버튼 */}
-
-
- {showLoginModal && (
-
setShowLoginModal(false)} />
+ {isLoginModalOpen && (
+ closeLoginModal()} />
)}
{/* 모바일 */}
{/* 소식 */}
@@ -49,9 +42,7 @@ export default function HomePage() {
-
- 독서모임
-
+ 독서모임
@@ -65,7 +56,7 @@ export default function HomePage() {
name={u.name}
subscribingCount={u.subscribingCount}
subscribersCount={u.subscribersCount}
- onSubscribeClick={() => console.log('subscribe', u.id)}
+ onSubscribeClick={() => console.log("subscribe", u.id)}
/>
))}
@@ -151,17 +142,17 @@ export default function HomePage() {
{/* 소식 */}
{/* 책 이야기 카드 */}
{DUMMY_STORIES.slice(0, 3).map((story) => (
-
+ />
))}
-
-
+
+
+
+ {/* 비로그인 시 플로팅 로그인 하기 버튼 */}
+ {!isLoggedIn && (
+
+ )}
);
}
diff --git a/src/app/(main)/profile/mypage/page.tsx b/src/app/(main)/profile/mypage/page.tsx
index 2086238..dc54b66 100644
--- a/src/app/(main)/profile/mypage/page.tsx
+++ b/src/app/(main)/profile/mypage/page.tsx
@@ -11,8 +11,15 @@ import MyMeetingList from "@/components/base-ui/MyPage/MyMeetingList";
import MyNotificationList from "@/components/base-ui/MyPage/MyNotificationList";
import MyLibraryList from "@/components/base-ui/Profile/LibraryList";
+import { useAuthGuard } from "@/hooks/useAuthGuard";
+
export default function MyPage() {
const [activeTab, setActiveTab] = useState("stories");
+ const { isInitialized, isLoggedIn } = useAuthGuard();
+
+ if (!isInitialized || !isLoggedIn) {
+ return null; // 초기화 중이거나 리다이렉트 중에는 빈 화면
+ }
return (
diff --git a/src/app/(main)/setting/layout.tsx b/src/app/(main)/setting/layout.tsx
index 51b8273..63f23cb 100644
--- a/src/app/(main)/setting/layout.tsx
+++ b/src/app/(main)/setting/layout.tsx
@@ -1,3 +1,7 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useAuthGuard } from "@/hooks/useAuthGuard";
import Header from "@/components/layout/Header";
import SettingsSidebar from "@/components/base-ui/Settings/SettingsSidebar";
import BottomNav from "@/components/layout/BottomNav";
@@ -7,6 +11,11 @@ export default function SettingsLayout({
}: {
children: React.ReactNode;
}) {
+ const { isInitialized, isLoggedIn } = useAuthGuard();
+
+ if (!isInitialized || !isLoggedIn) {
+ return null;
+ }
return (
{/* 메인 컨텐츠 래퍼 */}
diff --git a/src/app/(main)/setting/logout/page.tsx b/src/app/(main)/setting/logout/page.tsx
new file mode 100644
index 0000000..420599a
--- /dev/null
+++ b/src/app/(main)/setting/logout/page.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { authService } from "@/services/authService";
+
+export default function LogoutPage() {
+ const router = useRouter();
+
+ useEffect(() => {
+ const performLogout = async () => {
+ await authService.logout();
+ router.push("/");
+ };
+
+ performLogout();
+ }, [router]);
+
+ return (
+
+ );
+}
diff --git a/src/app/(main)/setting/profile/page.tsx b/src/app/(main)/setting/profile/page.tsx
index 59cb42d..02ff2ad 100644
--- a/src/app/(main)/setting/profile/page.tsx
+++ b/src/app/(main)/setting/profile/page.tsx
@@ -1,8 +1,53 @@
+"use client";
+
+import { useAuthStore } from "@/store/useAuthStore";
import CategorySelector from "@/components/base-ui/Settings/EditProfile/CategorySelector";
import ProfileImageSection from "@/components/base-ui/Settings/EditProfile/ProfileImageSection";
import SettingsDetailLayout from "@/components/base-ui/Settings/SettingsDetailLayout";
+import { useState, useEffect } from "react";
+import toast from "react-hot-toast";
export default function ProfileEditPage() {
+ const { user } = useAuthStore();
+ const [nickname, setNickname] = useState(user?.nickname || "");
+ const [intro, setIntro] = useState(user?.description || "");
+ const [name, setName] = useState(user?.nickname || ""); // Assuming nickname is used for name if not separate
+ const [phone, setPhone] = useState("");
+ const [selectedCategories, setSelectedCategories] = useState(
+ user?.categories || []
+ );
+
+ useEffect(() => {
+ if (user) {
+ setNickname(user.nickname || "");
+ setIntro(user.description || "");
+ setName(user.nickname || "");
+ setSelectedCategories(user.categories || []);
+ }
+ }, [user]);
+
+ const handleToggleCategory = (category: string) => {
+ setSelectedCategories((prev) =>
+ prev.includes(category)
+ ? prev.filter((c) => c !== category)
+ : prev.length < 6
+ ? [...prev, category]
+ : prev
+ );
+ };
+
+ const handleSave = () => {
+ console.log("Saving profile changes:", {
+ nickname,
+ intro,
+ name,
+ phone,
+ selectedCategories,
+ });
+ // TODO: Connect to backend API for profile update
+ toast.success("프로필 정보가 저장되었습니다.");
+ };
+
// 공통 스타일 상수
const inputContainerClass =
"flex items-center gap-[10px] rounded-[8px] border border-Subbrown-4 bg-White px-[16px] py-[12px] h-[36px] md:h-[52px]";
@@ -16,10 +61,14 @@ export default function ProfileEditPage() {
return (
-
+
{/* 닉네임 */}
@@ -28,43 +77,69 @@ export default function ProfileEditPage() {
setNickname(e.target.value)}
placeholder="변경할 닉네임을 입력해주세요"
/>
-
- {/* 소개, 이름, 전화번호 (반복 패턴) */}
- {["소개", "이름", "전화번호"].map((label) => (
-
-
-
-
-
+ {/* 소개 */}
+
+
+
+ setIntro(e.target.value)}
+ placeholder="20자 이내로 작성해주세요"
+ />
- ))}
+
+
+ {/* 이름 */}
+
+
+
+ setName(e.target.value)}
+ placeholder="이름을 입력해주세요"
+ />
+
+
-
+ {/* 전화번호 */}
+
+
+
+ setPhone(e.target.value)}
+ placeholder="010-0000-0000"
+ />
+
+
+
+
{/* 저장 버튼 */}
-
+
변경하기
저장하기
@@ -74,3 +149,4 @@ export default function ProfileEditPage() {
);
}
+
diff --git a/src/app/(main)/stories/new/page.tsx b/src/app/(main)/stories/new/page.tsx
index a87875f..5d09d79 100644
--- a/src/app/(main)/stories/new/page.tsx
+++ b/src/app/(main)/stories/new/page.tsx
@@ -42,35 +42,39 @@ function StoryNewContent() {
return (
- {/* 책이야기 > 글 작성하기 */}
- {/* 모바일: 전체 너비 선 */}
-
-
-
전체
-
-
+ {/* 책이야기 > 글 작성하기 - 모달 열리면 숨김 */}
+ {!isBookSelectModalOpen && (
+ <>
+ {/* 모바일: 전체 너비 선 */}
+
-
글 작성하기
-
-
- {/* 태블릿/데스크탑: max-w 안에서 선 */}
-
+ {/* 태블릿/데스크탑: max-w 안에서 선 */}
+
+ >
+ )}
{/* 메인 콘텐츠 영역 */}
{/* 책 선택하기 박스 */}
diff --git a/src/app/(main)/stories/page.tsx b/src/app/(main)/stories/page.tsx
index 14aae92..fa6ae07 100644
--- a/src/app/(main)/stories/page.tsx
+++ b/src/app/(main)/stories/page.tsx
@@ -1,9 +1,9 @@
"use client";
import BookStoryCardLarge from "@/components/base-ui/BookStory/bookstory_card_large";
import ListSubscribeLarge from "@/components/base-ui/home/list_subscribe_large";
-import Image from "next/image";
import { useRouter } from "next/navigation";
import { DUMMY_STORIES } from "@/data/dummyStories";
+import FloatingFab from "@/components/base-ui/Float";
// TODO: 실제 로그인 상태 여부는 나중에
const isLoggedIn = false; // true: 로그인, false: 로그인X
@@ -17,30 +17,30 @@ export default function StoriesPage() {
return (
-
-
+
+
전체
-
+
구독중
-
+
긁적긁적
-
{/* 메인 콘텐츠 영역 */}
-
+
{/* 첫 번째 줄 */}
{DUMMY_STORIES.slice(0, 4).map((story) => (
handleCardClick(story.id)}
- className="cursor-pointer"
+ className="cursor-pointer shrink-0"
>
handleCardClick(story.id)}
- className="cursor-pointer"
+ className="cursor-pointer shrink-0"
>
))}
-
{/* 글쓰기 버튼 */}
-
router.push("/stories/new")}
- >
- router.push("/stories/new")}
/>
-
);
diff --git a/src/app/(public)/signup/[step]/page.tsx b/src/app/(public)/signup/[step]/page.tsx
new file mode 100644
index 0000000..ccd72d3
--- /dev/null
+++ b/src/app/(public)/signup/[step]/page.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import React from "react";
+import { useParams, useRouter } from "next/navigation";
+import TermsAgreement from "@/components/base-ui/Join/steps/TermsAgreement/TermsAgreement";
+import EmailVerification from "@/components/base-ui/Join/steps/EmailVerification/EmailVerification";
+import PasswordEntry from "@/components/base-ui/Join/steps/PasswordEntry/PasswordEntry";
+import ProfileSetup from "@/components/base-ui/Join/steps/ProfileSetup/ProfileSetup";
+import ProfileImage from "@/components/base-ui/Join/steps/ProfileImage/ProfileImage";
+import SignupComplete from "@/components/base-ui/Join/steps/SignupComplete/SignupComplete";
+
+export default function SignupStepPage() {
+ const params = useParams();
+ const router = useRouter();
+ const step = params.step as string;
+
+ const navigateTo = (nextStep: string) => {
+ router.push(`/signup/${nextStep}`);
+ };
+
+ const steps: Record
= {
+ terms: navigateTo("email")} />,
+ email: navigateTo("password")} />,
+ password: navigateTo("profile")} />,
+ profile: navigateTo("profile-image")} />,
+ "profile-image": navigateTo("complete")} />,
+ complete: ,
+ };
+
+ const currentStep = steps[step];
+
+ if (!currentStep) {
+ // Falls back to terms if step is invalid
+ return null;
+ }
+
+ return <>{currentStep}>;
+}
diff --git a/src/app/(public)/signup/layout.tsx b/src/app/(public)/signup/layout.tsx
index 8b21600..835a483 100644
--- a/src/app/(public)/signup/layout.tsx
+++ b/src/app/(public)/signup/layout.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import { SignupProvider } from "@/contexts/SignupContext";
export default function SignupLayout({
children,
@@ -6,11 +7,13 @@ export default function SignupLayout({
children: React.ReactNode;
}) {
return (
-
- {children}
-
+
+
+ {children}
+
+
);
}
\ No newline at end of file
diff --git a/src/app/(public)/signup/page.tsx b/src/app/(public)/signup/page.tsx
index a35a982..b916629 100644
--- a/src/app/(public)/signup/page.tsx
+++ b/src/app/(public)/signup/page.tsx
@@ -1,35 +1,14 @@
"use client";
-import React, { useState } from "react";
-import TermsAgreement from "@/components/base-ui/Join/steps/TermsAgreement/TermsAgreement";
-import EmailVerification from "@/components/base-ui/Join/steps/EmailVerification/EmailVerification";
-import PasswordEntry from "@/components/base-ui/Join/steps/PasswordEntry/PasswordEntry";
-import ProfileSetup from "@/components/base-ui/Join/steps/ProfileSetup/ProfileSetup";
-import ProfileImage from "@/components/base-ui/Join/steps/ProfileImage/ProfileImage";
-import SignupComplete from "@/components/base-ui/Join/steps/SignupComplete/SignupComplete";
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
export default function SignupPage() {
- const [step, setStep] = useState<
- "terms" | "email" | "password" | "profile" | "profile-image" | "complete"
- >("terms");
- const steps = {
- terms: (
- {
- setStep("email");
- }}
- />
- ),
- email: setStep("password")} />,
- password: setStep("profile")} />,
- profile: setStep("profile-image")} />,
- "profile-image": setStep("complete")} />,
- complete: ,
- };
+ const router = useRouter();
- return (
- <>
- {steps[step]}
- >
- );
+ useEffect(() => {
+ router.replace("/signup/terms");
+ }, [router]);
+
+ return null;
}
diff --git a/src/app/globals.css b/src/app/globals.css
index 604a790..086e1a3 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -146,3 +146,32 @@ body {
--breakpoint-t: 768px;
--breakpoint-d: 1440px;
}
+
+/* 애니메이션 */
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slide-down {
+ from {
+ opacity: 0;
+ transform: translateY(-20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@utility animate-fade-in {
+ animation: fade-in 0.2s ease-out;
+}
+
+@utility animate-slide-down {
+ animation: slide-down 0.3s ease-out;
+}
diff --git a/src/app/groups/[id]/admin/notice/new/page.tsx b/src/app/groups/[id]/admin/notice/new/page.tsx
index 553570a..12d33be 100644
--- a/src/app/groups/[id]/admin/notice/new/page.tsx
+++ b/src/app/groups/[id]/admin/notice/new/page.tsx
@@ -1,11 +1,11 @@
-'use client';
+"use client";
-import { useState, useRef, useEffect, type ChangeEvent } from 'react';
-import { useParams, useRouter } from 'next/navigation';
-import Image from 'next/image';
-import BookshelfModal from '@/components/base-ui/Group/BookshelfModal';
-import BookDetailCard from '@/components/base-ui/Bookcase/BookDetailCard';
-import { useHeaderTitle } from '@/contexts/HeaderTitleContext';
+import { useState, useRef, useEffect, type ChangeEvent } from "react";
+import { useParams, useRouter } from "next/navigation";
+import Image from "next/image";
+import BookshelfModal from "@/components/base-ui/Group/BookshelfModal";
+import BookDetailCard from "@/components/base-ui/Bookcase/BookDetailCard";
+import { useHeaderTitle } from "@/contexts/HeaderTitleContext";
type Book = {
id: number;
@@ -21,16 +21,17 @@ export default function NewNoticePage() {
const router = useRouter();
const groupId = params.id as string;
const { setCustomTitle } = useHeaderTitle();
-
- const [title, setTitle] = useState('');
- const [content, setContent] = useState('');
- const [selectedOption, setSelectedOption] =
- useState<'vote' | 'bookshelf' | 'image' | null>(null);
- const [voteItems, setVoteItems] = useState(['', '', '', '']);
+
+ const [title, setTitle] = useState("");
+ const [content, setContent] = useState("");
+ const [selectedOption, setSelectedOption] = useState<
+ "vote" | "bookshelf" | "image" | null
+ >(null);
+ const [voteItems, setVoteItems] = useState(["", "", "", ""]);
const [isMultiple, setIsMultiple] = useState(false);
const [isAnonymous, setIsAnonymous] = useState(false);
const [isImportant, setIsImportant] = useState(false);
- const [voteDate, setVoteDate] = useState('');
+ const [voteDate, setVoteDate] = useState("");
const [isBookshelfModalOpen, setIsBookshelfModalOpen] = useState(false);
const [imagePreviews, setImagePreviews] = useState([]);
const [selectedBook, setSelectedBook] = useState(null);
@@ -39,7 +40,7 @@ export default function NewNoticePage() {
// 모바일 헤더 타이틀 설정
useEffect(() => {
- setCustomTitle('공지사항 작성');
+ setCustomTitle("공지사항 작성");
return () => setCustomTitle(null);
}, [setCustomTitle]);
@@ -49,20 +50,18 @@ export default function NewNoticePage() {
const handleSubmit = () => {
const trimmedVoteItems =
- selectedOption === 'vote'
- ? voteItems
- .map((item) => item.trim())
- .filter((item) => item.length > 0)
+ selectedOption === "vote"
+ ? voteItems.map((item) => item.trim()).filter((item) => item.length > 0)
: [];
// TODO: 실제 저장 로직 구현
- console.log('공지사항 저장:', {
+ console.log("공지사항 저장:", {
title,
content,
selectedOption,
voteItems: trimmedVoteItems,
voteSettings:
- selectedOption === 'vote'
+ selectedOption === "vote"
? {
isMultiple,
isAnonymous,
@@ -75,34 +74,31 @@ export default function NewNoticePage() {
};
const handleCreateVote = () => {
- setSelectedOption(selectedOption === 'vote' ? null : 'vote');
+ setSelectedOption(selectedOption === "vote" ? null : "vote");
// TODO: 투표 생성 페이지로 이동 또는 모달 열기
- console.log('투표 생성');
+ console.log("투표 생성");
};
const handleVoteItemChange = (index: number, value: string) => {
- setVoteItems((prev) =>
- prev.map((item, i) => (i === index ? value : item)),
- );
+ setVoteItems((prev) => prev.map((item, i) => (i === index ? value : item)));
};
const handleRegisterBookshelf = () => {
- const nextSelected =
- selectedOption === 'bookshelf' ? null : 'bookshelf';
+ const nextSelected = selectedOption === "bookshelf" ? null : "bookshelf";
setSelectedOption(nextSelected);
- setIsBookshelfModalOpen(nextSelected === 'bookshelf');
- console.log('책장 등록');
+ setIsBookshelfModalOpen(nextSelected === "bookshelf");
+ console.log("책장 등록");
};
const handleBookSelect = (book: Book) => {
setSelectedBook(book);
- setSelectedOption('bookshelf');
+ setSelectedOption("bookshelf");
};
const handleImageFile = () => {
- setSelectedOption('image');
+ setSelectedOption("image");
imageInputRef.current?.click();
- console.log('이미지 파일');
+ console.log("이미지 파일");
};
const handleImageChange = (e: ChangeEvent) => {
@@ -122,27 +118,17 @@ export default function NewNoticePage() {
}, []);
return (
- {/* 뒤로가기 - 모바일에서만 표시 */}
-
-
-
- 뒤로가기
-
-
-
{/* 구분선 */}
-
-
+
+
{/* 제목 및 내용 입력 영역 */}
-
공지사항 작성
-
+
+ 공지사항 작성
+
+
{/* 선택된 책 표시 */}
{selectedBook && (
@@ -157,215 +143,221 @@ export default function NewNoticePage() {
)}
{/* 입력 박스 */}
-
- {/* 제목 */}
-
- setTitle(e.target.value)}
- placeholder="제목을 입력해주세요."
- className="w-full bg-transparent outline-none text-Gray-7 subhead_4_1 placeholder:text-Gray-3"
- />
-
-
- {/* 내용 */}
-
-
+
+ {/* 제목 */}
+
+ setTitle(e.target.value)}
+ placeholder="제목을 입력해주세요."
+ className="w-full bg-transparent outline-none text-Gray-7 subhead_4_1 placeholder:text-Gray-3"
+ />
+
- {/* 투표 */}
-
- {selectedOption === 'vote' && (
-
-
- {[0, 1, 2, 3].map((index) => (
-
-
- handleVoteItemChange(index, e.target.value)
- }
- placeholder={`투표 항목 ${index + 1} 입력`}
- className="w-full bg-transparent outline-none text-Gray-7 body_1_2 placeholder:text-Gray-3"
- />
-
- ))}
-
+ {/* 내용 */}
+
+
- {/* 추가 설정 */}
-
- {/* 복수선택 */}
-
setIsMultiple((prev) => !prev)}
- >
-
- {isMultiple && (
-
- )}
-
- 복수선택
-
+ {/* 투표 */}
+
+ {selectedOption === "vote" && (
+
+
+ {[0, 1, 2, 3].map((index) => (
+
+
+ handleVoteItemChange(index, e.target.value)
+ }
+ placeholder={`투표 항목 ${index + 1} 입력`}
+ className="w-full bg-transparent outline-none text-Gray-7 body_1_2 placeholder:text-Gray-3"
+ />
+
+ ))}
+
- {/* 익명선택 */}
-
setIsAnonymous((prev) => !prev)}
- >
-
- {isAnonymous && (
-
- )}
-
- 익명선택
-
+ {/* 추가 설정 */}
+
+ {/* 복수선택 */}
+
setIsMultiple((prev) => !prev)}
+ >
+
+ {isMultiple && (
+
+ )}
+
+ 복수선택
+
- {/* 중요여부 */}
-
setIsImportant((prev) => !prev)}
- >
-
- {isImportant && (
-
- )}
-
- 중요여부
-
+ {/* 익명선택 */}
+
setIsAnonymous((prev) => !prev)}
+ >
+
+ {isAnonymous && (
+
+ )}
+
+ 익명선택
+
- {/* 날짜 선택 */}
-
-
-
- )}
+ {/* 중요여부 */}
+
setIsImportant((prev) => !prev)}
+ >
+
+ {isImportant && (
+
+ )}
+
+ 중요여부
+
- {selectedOption === 'image' && (
-
- {imagePreviews.length > 0 ? (
-
- {imagePreviews.map((src, index) => (
-
+ {/* 날짜 선택 */}
+
+
- {/* 이미지 삭제 */}
- {
- const urlToRemove = imagePreviews[index];
- URL.revokeObjectURL(urlToRemove);
- setImagePreviews((prev) =>
- prev.filter((_, i) => i !== index),
- );
- }}
- className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-black/60"
- aria-label="이미지 삭제"
- >
-
- ×
-
-
- ))}
+
날짜선택
+
- ) : null}
-
- )}
-
-
-
-
-
- 투표 생성
-
-
-
- 책장 등록
-
-
-
- 이미지 파일
-
+
+ )}
+
+ {selectedOption === "image" && (
+
+ {imagePreviews.length > 0 ? (
+
+ {imagePreviews.map((src, index) => (
+
+
+ {/* 이미지 삭제 */}
+ {
+ const urlToRemove = imagePreviews[index];
+ URL.revokeObjectURL(urlToRemove);
+ setImagePreviews((prev) =>
+ prev.filter((_, i) => i !== index),
+ );
+ }}
+ className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-black/60"
+ aria-label="이미지 삭제"
+ >
+
+ ×
+
+
+
+ ))}
+
+ ) : null}
+
+ )}
+
+
+
+
+
+ 투표 생성
+
+
+
+ 책장 등록
+
+
+
+ 이미지 파일
+
+
+
-
-
@@ -395,9 +387,7 @@ export default function NewNoticePage() {
isOpen={isBookshelfModalOpen}
onClose={() => {
setIsBookshelfModalOpen(false);
- setSelectedOption((prev) =>
- prev === 'bookshelf' ? null : prev,
- );
+ setSelectedOption((prev) => (prev === "bookshelf" ? null : prev));
}}
onSelect={handleBookSelect}
/>
diff --git a/src/app/groups/[id]/bookcase/[bookId]/DebateSection.tsx b/src/app/groups/[id]/bookcase/[bookId]/DebateSection.tsx
index a8cdcbf..12236e9 100644
--- a/src/app/groups/[id]/bookcase/[bookId]/DebateSection.tsx
+++ b/src/app/groups/[id]/bookcase/[bookId]/DebateSection.tsx
@@ -56,7 +56,7 @@ export default function DebateSection({
@@ -77,7 +77,7 @@ export default function DebateSection({
"
>
{/* 프로필 + 이름 (가로) */}
-
+
@@ -86,14 +86,14 @@ export default function ReviewSection({
bg-White
px-5 py-3
flex flex-col
- t:gap-5
+ t:gap-3
mb-[6px]
t:flex-row t:items-center
"
>
{/* (모바일) 프로필+이름 / 별점은 한 덩어리로 보이게 */}
-
+
diff --git a/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx b/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx
index 6e9f2ca..a93dd79 100644
--- a/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx
+++ b/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx
@@ -4,6 +4,7 @@
import React, { useEffect, useMemo, useState } from "react";
import Image from "next/image";
import { useSearchParams } from "next/navigation";
+import FloatingFab from "@/components/base-ui/Float";
type Team = {
teamId: string;
@@ -213,7 +214,7 @@ export default function MeetingPage({
return (
-
+
{/* TeamFilter */}
);
}
diff --git a/src/app/groups/[id]/bookcase/page.tsx b/src/app/groups/[id]/bookcase/page.tsx
index a6d6f70..16fc909 100644
--- a/src/app/groups/[id]/bookcase/page.tsx
+++ b/src/app/groups/[id]/bookcase/page.tsx
@@ -2,6 +2,7 @@
import BookcaseCard from "@/components/base-ui/Bookcase/BookcaseCard";
+import FloatingFab from "@/components/base-ui/Float";
import { useParams, useRouter } from "next/navigation";
@@ -73,8 +74,15 @@ export default function BookcasePage() {
/>
))}
+
))}
+
+
router.push(`/groups/${groupId}/admin/bookcase/new`)}
+ />
);
}
diff --git a/src/app/groups/[id]/dummy.ts b/src/app/groups/[id]/dummy.ts
index 0025094..9a935d1 100644
--- a/src/app/groups/[id]/dummy.ts
+++ b/src/app/groups/[id]/dummy.ts
@@ -17,6 +17,8 @@ export const DUMMY_CLUB_HOME_RESPONSE: ClubHomeResponse = {
{ code: 'COMPUTER_IT', description: '컴퓨터/IT' },
{ code: 'ESSAY', description: '에세이' },
{ code: 'HISTORY_CULTURE', description: '역사/문화' },
+ { code: 'COMPUTER_IT', description: '정치/외교/국방' },
+ { code: 'HISTORY_CULTURE', description: '어린이/청소년' },
],
participantTypes: [
{ code: 'OFFLINE', description: '대면' },
diff --git a/src/app/groups/[id]/notice/page.tsx b/src/app/groups/[id]/notice/page.tsx
index 31cd83e..29a3312 100644
--- a/src/app/groups/[id]/notice/page.tsx
+++ b/src/app/groups/[id]/notice/page.tsx
@@ -1,54 +1,60 @@
-'use client';
+"use client";
-import { useParams, useRouter } from 'next/navigation';
-import Image from 'next/image';
-import NoticeItem from '@/components/base-ui/Group/notice_item';
+import { useState } from "react";
+import { useParams, useRouter } from "next/navigation";
+import NoticeItem from "@/components/base-ui/Group/notice_item";
+import FloatingFab from "@/components/base-ui/Float";
export default function GroupNoticePage() {
const params = useParams();
const router = useRouter();
const groupId = params.id as string;
-
+
// TODO: 실제 관리자 여부는 API로 확인
const isAdmin = true; // true: 관리자, false: 일반회원
+ // 페이지네이션 상태
+ const [currentPage, setCurrentPage] = useState(1);
+ const totalPages = 5;
+
// 더미 데이터
const pinnedNotices = [
{
id: 1,
- title: '긁적긁적 독서 모임 공지사항',
- content: '이번 주 모임은 정상적으로 진행됩니다. 많은 참여 부탁드립니다.',
- date: '2025.01.15',
+ title: "긁적긁적 독서 모임 공지사항",
+ content: "이번 주 모임은 정상적으로 진행됩니다. 많은 참여 부탁드립니다.",
+ date: "2025.01.15",
},
{
id: 2,
- title: '새로운 책 추천 받습니다',
- content: '이번 달 읽을 책을 추천해주세요. 추천하신 책 중에서 선정하겠습니다.',
- date: '2025.01.10',
+ title: "새로운 책 추천 받습니다",
+ content:
+ "이번 달 읽을 책을 추천해주세요. 추천하신 책 중에서 선정하겠습니다.",
+ date: "2025.01.10",
},
];
const notices = [
{
id: 3,
- title: '1월 독서 후기 공유',
- content: '1월에 읽으신 책들의 후기를 공유해주세요.',
- date: '2025.01.05',
- tags: ['vote'] as const,
+ title: "1월 독서 후기 공유",
+ content: "1월에 읽으신 책들의 후기를 공유해주세요.",
+ date: "2025.01.05",
+ tags: ["vote"] as const,
},
{
id: 4,
- title: '모임 장소 변경 안내',
- content: '다음 모임부터 장소가 변경됩니다. 자세한 내용은 확인해주세요.',
- date: '2024.12.28',
- tags: ['vote', 'meeting'] as const,
+ title: "모임 장소 변경 안내",
+ content: "다음 모임부터 장소가 변경됩니다. 자세한 내용은 확인해주세요.",
+ date: "2024.12.28",
+ tags: ["vote", "meeting"] as const,
},
{
id: 5,
- title: '연말 모임 안내',
- content: '연말 특별 모임을 준비했습니다. 많은 참여 부탁드립니다.',
- date: '2024.12.20',
- tags: ['meeting'] as const,
+ title: "연말 모임 안내",
+ content: "연말 특별 모임을 준비했습니다. 많은 참여 부탁드립니다.",
+ date: "2024.12.20",
+ tags: ["meeting"] as const,
},
];
@@ -95,22 +101,78 @@ export default function GroupNoticePage() {
))}
- {/* 관리자일 때만 공지사항 작성 버튼 표시 */}
- {isAdmin && (
-
-
+ setCurrentPage((prev) => Math.max(prev - 1, 1))}
+ disabled={currentPage === 1}
+ className="text-Gray-4 hover:text-Gray-7 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer"
+ aria-label="이전 페이지"
+ >
+
+
+
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
+ setCurrentPage(page)}
+ className={`w-8 h-8 flex items-center justify-center body_1 cursor-pointer rounded-full transition-colors ${
+ currentPage === page
+ ? "text-Gray-7 font-semibold"
+ : "text-Gray-4 hover:text-Gray-7"
+ }`}
+ >
+ {page}
-
+ ))}
+
+
+ setCurrentPage((prev) => Math.min(prev + 1, totalPages))
+ }
+ disabled={currentPage === totalPages}
+ className="text-Gray-4 hover:text-Gray-7 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer"
+ aria-label="다음 페이지"
+ >
+
+
+
+
+ {/* 관리자일 때만 공지사항 작성 버튼 표시 */}
+ {isAdmin && (
+
)}
);
diff --git a/src/app/groups/[id]/page.tsx b/src/app/groups/[id]/page.tsx
index 5a5ffa6..76be133 100644
--- a/src/app/groups/[id]/page.tsx
+++ b/src/app/groups/[id]/page.tsx
@@ -40,11 +40,12 @@ export default function AdminGroupHomePage() {
.filter((n: number) => n >= 1 && n <= 15);
const [isContactOpen, setIsContactOpen] = useState(false);
+ const [isAdmin, setIsAdmin] = useState(true);
return (
{/* 최대 1024, d에서만 px-10(40px) */}
-
+
{/* 1) 공지 (항상 최상단) */}
@@ -106,15 +107,17 @@ export default function AdminGroupHomePage() {
{/* 3) 텍스트 + 4) 버튼 (같은 컬럼) */}
{/* 운영진용 모임 관리 드롭다운 */}
-
-
-
+ {isAdmin && (
+
+
+
+ )}
- {/* chips */}
-
+
+
모임 대상
@@ -128,7 +131,6 @@ export default function AdminGroupHomePage() {
-
설명
{DUMMY_CLUB_HOME.description ?? '설명이 없습니다.'}
@@ -142,7 +144,7 @@ export default function AdminGroupHomePage() {
bgColorVar="--Primary_1"
borderColorVar="--Primary_1"
textColorVar="--White"
- className="w-[300px] h-[44px] body_1"
+ className="w-[300px] h-[44px] body_1 hover:brightness-90 hover:-translate-y-[1px] cursor-pointer"
/>
@@ -166,7 +168,7 @@ export default function AdminGroupHomePage() {
relative shrink-0 overflow-hidden
w-[110px] h-[110px]
t:w-[300px] t:h-[300px]
- rounded-[12px]
+ rounded-[4px]
bg-Gray-1
"
>
@@ -180,12 +182,13 @@ export default function AdminGroupHomePage() {
{/* 3) 내용 */}
- {/* 운영진용 모임 관리 드롭다운 */}
-
-
-
+ {isAdmin && (
+
+
+
+ )}
-
+
@@ -202,7 +205,6 @@ export default function AdminGroupHomePage() {
-
설명
{DUMMY_CLUB_HOME.description ?? '설명이 없습니다.'}
@@ -218,7 +220,7 @@ export default function AdminGroupHomePage() {
bgColorVar="--Primary_1"
borderColorVar="--Primary_1"
textColorVar="--White"
- className="w-full d:w-[300px] h-[44px] body_1"
+ className="w-full d:w-[300px] h-[44px] body_1 hover:brightness-90 hover:-translate-y-[1px] cursor-pointer"
/>
@@ -261,12 +263,12 @@ export default function AdminGroupHomePage() {
>
{/* 헤더: 타이틀 + X */}
-
Contact Us
+
Contact Us
setIsContactOpen(false)}
- className="shrink-0"
+ className="shrink-0 hover:brightness-0 cursor-pointer hover:scale-[1.07]"
aria-label="닫기"
>
{/* TODO: 실제 X 아이콘 파일명 맞춰서 교체 */}
diff --git a/src/app/groups/create/page.tsx b/src/app/groups/create/page.tsx
index 0ea0c95..5c14412 100644
--- a/src/app/groups/create/page.tsx
+++ b/src/app/groups/create/page.tsx
@@ -222,7 +222,7 @@ export default function CreateClubWizardPreview() {
className={cx(
"flex justify-center items-center gap-[10px] h-[48px] px-4 py-3 rounded-[8px]",
"w-full t:w-[148px]",
- "bg-primary-1 hover:bg-primary-3 text-White",
+ "bg-primary-1 text-White hover:brightness-90 hover:-translate-y-[1px] cursor-pointer",
"disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed"
)}
>
@@ -282,9 +282,9 @@ export default function CreateClubWizardPreview() {
}}
className={cx(
"flex justify-center items-center gap-[10px] w-[200px] h-[36px] px-4 py-3 rounded-[8px] border body_1_3",
- "hover:opacity-90 active:opacity-80",
+ "hover:brightness-98 hover:-translate-y-[1px] cursor-pointer",
profileMode === "default"
- ? "bg-primary-1 border-primary-1 text-White"
+ ? "bg-primary-2 border-primary-2 text-White"
: "bg-Subbrown-4 border-Subbrown-3 text-primary-3"
)}
>
@@ -299,9 +299,9 @@ export default function CreateClubWizardPreview() {
}}
className={cx(
"flex justify-center items-center gap-[10px] w-[200px] h-[36px] px-4 py-3 rounded-[8px] border body_1_3",
- "hover:opacity-90 active:opacity-80",
+ "hover:brightness-98 hover:-translate-y-[1px] cursor-pointer",
profileMode === "upload"
- ? "bg-primary-1 border-primary-1 text-White"
+ ? "bg-primary-2 border-primary-1 text-White"
: "bg-Subbrown-4 border-Subbrown-3 text-primary-3"
)}
>
@@ -365,8 +365,9 @@ export default function CreateClubWizardPreview() {
onClick={onPrev}
className={cx(
"hidden t:flex justify-center items-center gap-[10px] w-[148px] h-[48px] px-4 py-3 rounded-[8px]",
- "bg-primary-1 hover:bg-primary-3 text-White",
+ "bg-primary-1 text-White hover:brightness-90 hover:-translate-y-[1px] cursor-pointer",
"disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed"
+
)}
>
이전
@@ -379,7 +380,7 @@ export default function CreateClubWizardPreview() {
className={cx(
"flex justify-center items-center gap-[10px] h-[48px] px-4 py-3 rounded-[8px]",
"w-full t:w-[148px]",
- "bg-primary-1 hover:bg-primary-3 text-White",
+ "bg-primary-1 text-White hover:brightness-90 hover:-translate-y-[1px] cursor-pointer",
"disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed"
)}
>
@@ -438,7 +439,7 @@ export default function CreateClubWizardPreview() {
onClick={onPrev}
className={cx(
"hidden t:flex justify-center items-center gap-[10px] w-[148px] h-[48px] px-4 py-3 rounded-[8px]",
- "bg-primary-1 hover:bg-primary-3 text-White",
+ "bg-primary-1 text-White hover:brightness-90 hover:-translate-y-[1px] cursor-pointer",
"disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed"
)}
>
@@ -446,18 +447,18 @@ export default function CreateClubWizardPreview() {
- 다음
-
+ type="button"
+ onClick={onNext}
+ disabled={!canNext}
+ className={cx(
+ "flex justify-center items-center gap-[10px] h-[48px] px-4 py-3 rounded-[8px]",
+ "w-full t:w-[148px]",
+ "bg-primary-1 text-White hover:brightness-90 hover:-translate-y-[1px] cursor-pointer",
+ "disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed"
+ )}
+ >
+ 다음
+
)}
@@ -522,7 +523,7 @@ export default function CreateClubWizardPreview() {
rounded-[8px]
bg-Gray-1
flex items-center justify-center
- hover:bg-Gray-2
+ hover:brightness-97 hover:-translate-y-[1px] cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-Gray-1
"
title="삭제"
@@ -545,7 +546,7 @@ export default function CreateClubWizardPreview() {
w-full h-[56px] rounded-[8px]
bg-Gray-1
flex items-center justify-center
- hover:bg-Gray-2
+ hover:brightness-97 hover:-translate-y-[1px] cursor-pointer
"
title="추가"
>
@@ -562,7 +563,7 @@ export default function CreateClubWizardPreview() {
onClick={onPrev}
className={cx(
"hidden t:flex justify-center items-center gap-[10px] w-[148px] h-[48px] px-4 py-3 rounded-[8px]",
- "bg-primary-1 hover:bg-primary-3 text-White",
+ "bg-primary-1 text-White hover:brightness-90 hover:-translate-y-[1px] cursor-pointer",
"disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed"
)}
>
@@ -576,7 +577,7 @@ export default function CreateClubWizardPreview() {
className={cx(
"flex justify-center items-center gap-[10px] h-[48px] px-4 py-3 rounded-[8px]",
"w-full t:w-[148px]",
- "bg-primary-1 hover:bg-primary-3 text-White",
+ "bg-primary-1 text-White hover:brightness-90 hover:-translate-y-[1px] cursor-pointer",
"disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed"
)}
>
diff --git a/src/app/groups/page.tsx b/src/app/groups/page.tsx
index 73dc394..934c5cf 100644
--- a/src/app/groups/page.tsx
+++ b/src/app/groups/page.tsx
@@ -71,7 +71,7 @@ export default function Searchpage() {
bgColorVar="--Primary_1"
borderColorVar="--Primary_1"
textColorVar="--White"
- className=" flex-1 body_1"
+ className=" flex-1 body_1 hover:brightness-95 hover:-translate-y-[1px] cursor-pointer"
onClick={() => router.push('/groups/create')}
/>
@@ -84,7 +84,7 @@ export default function Searchpage() {
bgColorVar="--Primary_1"
borderColorVar="--Primary_1"
textColorVar="--White"
- className="flex-1 subhead_4_1"
+ className="flex-1 subhead_4_1 hover:brightness-95 hover:-translate-y-[1px] cursor-pointer"
onClick={() => router.push('/groups/create')}
/>
@@ -112,7 +112,7 @@ export default function Searchpage() {
-
+
{dummyClubs.map((club) => (
state.login);
+ const { login, logout, setInitialized } = useAuthStore();
useEffect(() => {
- // Hydration Logic: Check for token on app load
- const token = Cookies.get("accessToken");
- if (token) {
- // If token exists, restore session state.
- // Note: Since we don't store user details in the cookie,
- // we pass a placeholder or partial data to set isLoggedIn to true.
- // TODO: Call /api/auth/me here to fetch full user profile and validate token.
- // If fetch fails (401), the interceptor in apiClient will handle logout.
- login({ email: "" });
- }
- }, [login]);
+ const initAuth = async () => {
+ try {
+ const response = await authService.getProfile();
+ if (response.isSuccess && response.result) {
+ login({
+ ...response.result,
+ email: response.result.email || "",
+ });
+ } else {
+ logout();
+ }
+ } catch (error) {
+ // 401 에러 등은 apiClient에서 이미 처리하지만, 여기서도 세션을 안전하게 초기화합니다.
+ logout();
+ } finally {
+ setInitialized(true);
+ }
+ };
+
+ initAuth();
+ }, [login, logout, setInitialized]);
return <>{children}>;
}
diff --git a/src/components/base-ui/BookStory/bookstory_card.tsx b/src/components/base-ui/BookStory/bookstory_card.tsx
index 02928d2..15a9e3f 100644
--- a/src/components/base-ui/BookStory/bookstory_card.tsx
+++ b/src/components/base-ui/BookStory/bookstory_card.tsx
@@ -86,8 +86,7 @@ export default function BookStoryCard({
{/* 3. 제목 + 내용 */}
{/* 제목 */}
diff --git a/src/components/base-ui/Bookcase/BookDetailNav.tsx b/src/components/base-ui/Bookcase/BookDetailNav.tsx
index 03ebe94..1d38145 100644
--- a/src/components/base-ui/Bookcase/BookDetailNav.tsx
+++ b/src/components/base-ui/Bookcase/BookDetailNav.tsx
@@ -22,13 +22,12 @@ export default function BookDetailNav({ activeTab, onTabChange }: Props) {
onClick={() => onTabChange(tab)}
className={`
flex w-full t:w-[115px] items-center justify-center gap-[10px] p-[10px] transition-colors
- border-b-2
- /* [핵심 수정] mb-[-2px]: 부모의 border-b-2 위로 버튼의 border를 끌어내려 덮어씌움 */
+ border-b-2 hover:brightness-80 cursor-pointer
mb-[-2px]
${
isActive
? "border-primary-3 text-primary-3"
- : "border-Gray-3 text-Gray-3 hover:bg-gray-50"
+ : "border-Gray-3 text-Gray-3"
}
`}
>
diff --git a/src/components/base-ui/Bookcase/BookcaseCard.tsx b/src/components/base-ui/Bookcase/BookcaseCard.tsx
index 8e80716..7ad0f7b 100644
--- a/src/components/base-ui/Bookcase/BookcaseCard.tsx
+++ b/src/components/base-ui/Bookcase/BookcaseCard.tsx
@@ -86,7 +86,7 @@ export default function BookcaseCard({
{/* 액션 리스트 및 별점 */}
-
+
{[
{ label: "발제", onClick: onTopicClick },
{ label: "한줄평", onClick: onReviewClick },
@@ -97,7 +97,7 @@ export default function BookcaseCard({
{item.label}
diff --git a/src/components/base-ui/Bookcase/MeetingInfo.tsx b/src/components/base-ui/Bookcase/MeetingInfo.tsx
index a261570..687f58a 100644
--- a/src/components/base-ui/Bookcase/MeetingInfo.tsx
+++ b/src/components/base-ui/Bookcase/MeetingInfo.tsx
@@ -39,7 +39,7 @@ export default function MeetingInfo({
onClick={onManageGroupClick}
className="flex items-center gap-[8px] px-[8px] hover:bg-black/5 rounded transition-colors"
>
-
조 관리하기
+
조 관리하기
diff --git a/src/components/base-ui/Bookcase/bookid/ReviewList.tsx b/src/components/base-ui/Bookcase/bookid/ReviewList.tsx
index 506722f..f752e74 100644
--- a/src/components/base-ui/Bookcase/bookid/ReviewList.tsx
+++ b/src/components/base-ui/Bookcase/bookid/ReviewList.tsx
@@ -1,23 +1,22 @@
-'use client';
-
-import Image from 'next/image';
-import { StarRating } from './StarRating';
+"use client";
+import Image from "next/image";
+import { StarRating } from "./StarRating";
export type ReviewItem = {
id: number | string;
name: string;
content: string;
- rating: number; // 0~5 (0.5 가능)
+ rating: number;
profileImageUrl?: string | null;
};
type Props = {
items: ReviewItem[];
- onClickMore?: (id: ReviewItem['id']) => void;
+ onClickMore?: (id: ReviewItem["id"]) => void;
};
-const DEFAULT_PROFILE = '/profile4.svg';
+const DEFAULT_PROFILE = "/profile4.svg";
export default function ReviewList({ items, onClickMore }: Props) {
return (
@@ -37,45 +36,86 @@ export default function ReviewList({ items, onClickMore }: Props) {
px-5 py-3
"
>
- {/* 상단: (모바일) 프로필+이름 / 별점 / 더보기
- (t~) 프로필+이름 / 별점 / 내용 / 더보기 */}
-
-
- {/* 프로필 + 이름 */}
-
+ {/* Mobile */}
+
+ {/* 왼쪽 덩어리: 프로필+이름 + 별점 + 내용 */}
+
+
+
+
+ {item.content}
+
+
+
+ {/* 햄버거 */}
+
onClickMore?.(item.id)}
+ className="relative w-6 h-6 shrink-0 justify-self-end self-center hover:brightness-80 cursor-pointer"
+ aria-label="더보기"
+ >
+
+
+
+
+ {/* Tablet+ */}
+
+ {/* 프로필+이름 */}
+
- {/* 별점:*/}
+ {/* 별점 */}
-
- {/* 내용: t 이상에서만 같은 줄 */}
+ {/* 내용 */}
onClickMore?.(item.id)}
- className="relative w-6 h-6 shrink-0 justify-self-end t:col-start-4"
+ className="relative w-6 h-6 shrink-0 hover:brightness-80 cursor-pointer"
aria-label="더보기"
>
-
+
-
- {/* 모바일: 내용은 아래로 */}
-
- {item.content}
-
);
})}
diff --git a/src/components/base-ui/Bookcase/bookid/TeamFilter.tsx b/src/components/base-ui/Bookcase/bookid/TeamFilter.tsx
index e499aef..f2c6baf 100644
--- a/src/components/base-ui/Bookcase/bookid/TeamFilter.tsx
+++ b/src/components/base-ui/Bookcase/bookid/TeamFilter.tsx
@@ -22,11 +22,11 @@ export default function TeamFilter({ teams, selectedTeam, onSelect }: Props) {
onSelect(team)}
- className={`shrink-0 flex h-[36px] min-w-[83px] items-center justify-center rounded-[4px] border px-[10px] transition-colors body_2_2
+ className={`shrink-0 flex h-[36px] min-w-[83px] items-center justify-center rounded-[4px] border px-[10px] transition-colors body_2_2 hover:brightness-97 hover:-translate-y-[3px] cursor-pointer
${
isActive
? "bg-primary-2 border-primary-2 text-White"
- : "bg-White border-Subbrown-4 text-Gray-7 hover:bg-Gray-1"
+ : "bg-White border-Subbrown-4 text-Gray-7"
}
`}
>
diff --git a/src/components/base-ui/Bookcase/bookid/TeamMemberItem.tsx b/src/components/base-ui/Bookcase/bookid/TeamMemberItem.tsx
index ee4c88f..cca1252 100644
--- a/src/components/base-ui/Bookcase/bookid/TeamMemberItem.tsx
+++ b/src/components/base-ui/Bookcase/bookid/TeamMemberItem.tsx
@@ -16,7 +16,7 @@ export default function TeamMemberItem({
return (
{/* 왼쪽: 프로필 + 이름 */}
-
+
{/* 프로필 이미지 (임시 원형 플레이스홀더) */}
{profileImageUrl ? (
@@ -39,7 +39,7 @@ export default function TeamMemberItem({
{/* 오른쪽: 더보기 버튼 */}
diff --git a/src/components/base-ui/Comment/comment_list.tsx b/src/components/base-ui/Comment/comment_list.tsx
index 1da98b1..41dd8a1 100644
--- a/src/components/base-ui/Comment/comment_list.tsx
+++ b/src/components/base-ui/Comment/comment_list.tsx
@@ -133,13 +133,13 @@ export default function CommentList({
}
}}
placeholder="답글 달기"
- className="flex-1 w-[240px] h-[36px] t:w-[850px] t:h-[56px] px-4 py-3 rounded-lg border border-Subbrown-4 bg-White Body_1_2 text-Gray-7 placeholder:text-Gray-3 outline-none"
+ className="flex-1 min-w-0 h-[36px] t:h-[56px] px-4 py-3 rounded-lg border border-Subbrown-4 bg-White Body_1_2 text-Gray-7 placeholder:text-Gray-3 outline-none"
autoFocus
/>
handleReplySubmit(comment.id)}
- className="px-auto t:px-6 py-auto t:py-3 w-[60px] h-[36px] t:w-[128px] t:h-[56px] border border-Subbrown-3 text-primary-3 rounded-lg bg-Subbrown-4 subhead_4_1 cursor-pointer"
+ className="px-4 t:px-6 py-2 t:py-3 h-[36px] t:h-[56px] border border-Subbrown-3 text-primary-3 rounded-lg bg-Subbrown-4 subhead_4_1 cursor-pointer shrink-0"
>
입력
diff --git a/src/components/base-ui/Comment/comment_section.tsx b/src/components/base-ui/Comment/comment_section.tsx
index b2dd4dd..66b7689 100644
--- a/src/components/base-ui/Comment/comment_section.tsx
+++ b/src/components/base-ui/Comment/comment_section.tsx
@@ -43,7 +43,7 @@ export default function CommentSection({ storyId }: CommentSectionProps) {
isAuthor: false,
isMine: true, // 내가 쓴 댓글
};
- setComments([...comments, newComment]);
+ setComments([newComment, ...comments]);
};
const handleAddReply = (parentId: number, content: string) => {
diff --git a/src/components/base-ui/Float.tsx b/src/components/base-ui/Float.tsx
new file mode 100644
index 0000000..c807fce
--- /dev/null
+++ b/src/components/base-ui/Float.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import Image from "next/image";
+import React from "react";
+
+type FloatingFabProps = {
+ iconSrc?: string; // 예: "/icons/pencil_white.svg"
+ iconAlt?: string; // 접근성/aria용
+ onClick?: React.MouseEventHandler
;
+ className?: string; // 추가 커스텀
+ iconClassName?: string; // 아이콘 추가 커스텀
+ type?: "button" | "submit" | "reset";
+};
+
+export default function FloatingFab({
+ iconSrc = "/add_story.svg",
+ iconAlt = "플로팅 버튼",
+ onClick,
+ className = "",
+ iconClassName = "",
+ type = "button",
+}: FloatingFabProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/base-ui/Group-Create/Chip.tsx b/src/components/base-ui/Group-Create/Chip.tsx
index 8e238ef..8050d33 100644
--- a/src/components/base-ui/Group-Create/Chip.tsx
+++ b/src/components/base-ui/Group-Create/Chip.tsx
@@ -22,12 +22,12 @@ export default function Chip({ label, selected, onClick, disabled }: ChipProps)
"w-[90px] h-[32px] t:w-[150px] t:h-[48px]",
"rounded-full",
"body_1_3 t:subhead_4_1",
- "bg-White border border-primary-2 t:border-2",
- "hover:bg-Subbrown-4",
+ " border border-Subbrown-1 t:border-2",
+ "hover:brightness-98 hover:-translate-y-[1px] cursor-pointer",
"disabled:text-Gray-4 disabled:border-Gray-4 disabled:hover:bg-White disabled:cursor-not-allowed",
selected
- ? "bg-primary-2 text-White border-primary-2 hover:bg-primary-1 hover:border-primary-1"
- : "text-Gray-5"
+ ? "bg-Subbrown-1 text-White"
+ : "bg-White text-Gray-5"
)}
>
{label}
diff --git a/src/components/base-ui/Group-Search/search_club_apply_modal.tsx b/src/components/base-ui/Group-Search/search_club_apply_modal.tsx
index 408068f..a375f68 100644
--- a/src/components/base-ui/Group-Search/search_club_apply_modal.tsx
+++ b/src/components/base-ui/Group-Search/search_club_apply_modal.tsx
@@ -81,7 +81,7 @@ export default function SearchClubApplyModal({
'w-full max-w-[1040px]',
'flex flex-col items-end gap-4',
'p-[28px]',
- 'rounded-[12px] bg-White',
+ 'rounded-[8px] bg-White',
'shadow-lg',
].join(' ')}
>
@@ -121,7 +121,7 @@ export default function SearchClubApplyModal({
{/* 오른쪽 */}
-
+
@@ -168,9 +168,9 @@ export default function SearchClubApplyModal({
type="button"
onClick={() => onSubmit(club.clubId,reason)}
className={[
- 'h-[40px] px-4 rounded-[8px]',
+ 'w-[132px] h-[40px] px-4 rounded-[8px]',
'bg-primary-2 text-White border border-primary-2',
- 'Body_1_2',
+ 'body_1_2 hover:brightness-90 cursor-pointer',
].join(' ')}
disabled={reason.trim().length === 0}
>
diff --git a/src/components/base-ui/Group-Search/search_clublist/search_clublist_item.tsx b/src/components/base-ui/Group-Search/search_clublist/search_clublist_item.tsx
index 922c734..f77e043 100644
--- a/src/components/base-ui/Group-Search/search_clublist/search_clublist_item.tsx
+++ b/src/components/base-ui/Group-Search/search_clublist/search_clublist_item.tsx
@@ -101,37 +101,55 @@ export default function SearchClubListItem({
{/* Left - Mobile */}
-
- {/* 이름, 태그 */}
-
+
+ {/* 제목/태그 + 상태(모바일에서 오른쪽 글 영역) */}
+
+
- {/* 이미지 + 정보 */}
-
-
-
-
- 모임 대상 : {participantText || '-'}
-
-
- 활동 지역 : {club.region}
-
+
+
+ {club.public ? '공개' : '비공개'}
+
+
+ {applyMeta.label && (
+
+
+ {applyMeta.label}
+
+ {applyMeta.icon && }
+
+ )}
+
+
+
+ {/* 이미지 + 정보 (이제 오른쪽 버튼이 없어서 풀폭으로 널찍해짐) */}
+
+
+
+
+ 모임 대상 : {participantText || '-'}
+
+
+ 활동 지역 : {club.region}
+
+
- {/* RIGHT */}
-
+ {/* RIGHT (Tablet~) */}
+
{/* 공개/비공개 + 상태 */}
@@ -150,7 +168,7 @@ export default function SearchClubListItem({
{/* 버튼 */}
-
+
{club.applytype === 'No' && (
@@ -187,6 +205,34 @@ export default function SearchClubListItem({
+
+ {club.applytype === 'No' && (
+ onClickApply?.(club.clubId)}
+ className={[
+ 'flex-1 h-[28px] rounded-[10px] body_2_1',
+ 'bg-primary-2 text-White hover:brightness-90 cursor-pointer',
+ ].join(' ')}
+ >
+ {isOpen ? '신청 닫기' : '가입신청하기'}
+
+ )}
+
+ onClickVisit?.(club.clubId)}
+ className={[
+ club.applytype === 'No' ? 'flex-1' : 'w-full',
+ 'h-[28px] rounded-[10px] body_2_1',
+ 'border border-primary-1 bg-background text-primary-3 hover:brightness-95 cursor-pointer',
+ ].join(' ')}
+ >
+ 방문하기
+
+
+
+
{/* ✅ 모바일에서만: 아이템 아래로 확장 신청폼 */}
{club.applytype === 'No' && isOpen && (
@@ -201,8 +247,8 @@ export default function SearchClubListItem({
disabled={!reason.trim()}
onClick={() => onSubmitApply(club.clubId, reason)}
className={[
- 'mt-4 w-full h-[44px] rounded-[10px] body_1_2',
- reason.trim() ? 'bg-primary-2 hover:bg-primary-1 text-White' : 'bg-Gray-2 text-Gray-4',
+ 'mt-4 w-full h-[44px] rounded-[10px] body_1_2 hover:brightness-95 cursor-pointer',
+ reason.trim() ? 'bg-primary-2 text-White' : 'bg-Gray-2 text-Gray-4',
].join(' ')}
>
가입신청하기
diff --git a/src/components/base-ui/Group-Search/search_groupsearch.tsx b/src/components/base-ui/Group-Search/search_groupsearch.tsx
index 595e22b..9f24a6a 100644
--- a/src/components/base-ui/Group-Search/search_groupsearch.tsx
+++ b/src/components/base-ui/Group-Search/search_groupsearch.tsx
@@ -91,7 +91,7 @@ export default function SearchGroupSearch({
'placeholder:text-Gray-3 text-Gray-7',
].join(' ')}
/>
-
+
@@ -105,7 +105,7 @@ export default function SearchGroupSearch({
className="flex items-center gap-[4px]"
aria-expanded={open}
>
- {category}
+ {category}
setGroup(!group)}
- className="flex items-center gap-2 t:gap-3"
+ className="flex items-center gap-2 t:gap-3 cursor-pointer"
aria-pressed={group}
>
setRegion(!region)}
- className="flex items-center gap-2 t:gap-3"
+ className="flex items-center gap-2 t:gap-3 cursor-pointer"
aria-pressed={region}
>
(
{group.name}
@@ -93,12 +93,12 @@ export default function Mybookclub({ groups }: Props) {
className="w-full rounded-[6px] bg-transparent text-[13px] flex items-center justify-center gap-[6px] text-Gray-3"
>
{open ? (
-
+
접기
) : (
-
+
전체보기
diff --git a/src/components/base-ui/Group/DebateList.tsx b/src/components/base-ui/Group/DebateList.tsx
index b984c6a..ed5315f 100644
--- a/src/components/base-ui/Group/DebateList.tsx
+++ b/src/components/base-ui/Group/DebateList.tsx
@@ -11,7 +11,7 @@ export type DebateItem = {
type Props = {
items: DebateItem[];
- onClickMore?: (id: DebateItem["id"]) => void; // 햄버거(⋮) 눌렀을 때
+ onClickMore?: (id: DebateItem["id"]) => void;
};
const DEFAULT_PROFILE = "/profile4.svg";
@@ -34,32 +34,71 @@ export default function DebateList({ items, onClickMore }: Props) {
px-5 py-3
"
>
- {/* 모바일: grid로 2줄 구성 (위: 프로필+이름+메뉴 / 아래: 내용)
- t 이상: flex 한 줄 */}
-
- {/* 프로필 + 이름: '한 덩어리' */}
-
-
-
- {item.name}
-
+ {/* Mobile */}
+
+ {/* 왼쪽 덩어리 (프로필+이름 + 내용) */}
+
+
+
+
+ {item.content}
+
+
+
+ {/* 햄버거 */}
+
onClickMore?.(item.id)}
+ className="relative w-6 h-6 shrink-0 justify-self-end self-center hover:brightness-70 cursor-pointer"
+ aria-label="더보기"
+ >
+
+
- {/* t 이상에서만 내용이 같은 줄로 옴 */}
+ {/* Tablet+ */}
+
+ {/* 프로필+이름 */}
+
+
+ {/* 내용 */}
onClickMore?.(item.id)}
- className="relative w-6 h-6 shrink-0 justify-self-end"
+ className="relative w-6 h-6 shrink-0 hover:brightness-70 cursor-pointer"
aria-label="더보기"
>
-
+
-
- {/* 모바일에서만: 내용이 아래로 내려감 (name 아래 라인) */}
-
- {item.content}
-
);
})}
diff --git a/src/components/base-ui/Group/group_admin_menu.tsx b/src/components/base-ui/Group/group_admin_menu.tsx
index d034008..10a7f4f 100644
--- a/src/components/base-ui/Group/group_admin_menu.tsx
+++ b/src/components/base-ui/Group/group_admin_menu.tsx
@@ -39,9 +39,9 @@ export default function GroupAdminMenu({ groupId }: GroupAdminMenuProps) {
setMenuOpen(!menuOpen)}
- className="flex items-center gap-2 cursor-pointer"
+ className="flex items-center gap-2 hover:brightness-50 cursor-pointer"
>
- 모임 관리하기
+ 모임 관리하기
= ({
const variants = {
primary: disabled
? "bg-[#DADADA] text-[#8D8D8D] cursor-not-allowed"
- : "bg-[#7B6154] text-[#FFF]",
+ : "bg-[#7B6154] text-[#FFF] hover:bg-[#7B6154] hover:text-[#FFF]",
secondary: "bg-[#EAE5E2] text-[#5E4A40] border border-[#D2C5B6]",
};
- // className에 width 관련 클래스가 없으면 기본값 적용
- const widthClass = className?.includes("w-") ? "" : "w-[526px]";
+ // Determine classes to avoid conflicts with className prop
+ const hasWidth = className?.includes("w-");
+ const hasHeight = className?.includes("h-");
+ const hasPx =
+ className?.includes("px-") ||
+ className?.includes("pl-") ||
+ className?.includes("pr-") ||
+ className?.includes("p-");
+ const hasPy =
+ className?.includes("py-") ||
+ className?.includes("pt-") ||
+ className?.includes("pb-") ||
+ className?.includes("p-");
+
return (
diff --git a/src/components/base-ui/Join/JoinInput.tsx b/src/components/base-ui/Join/JoinInput.tsx
index 6352587..9f4f264 100644
--- a/src/components/base-ui/Join/JoinInput.tsx
+++ b/src/components/base-ui/Join/JoinInput.tsx
@@ -3,6 +3,7 @@ import React, { useState } from "react";
interface JoinInputProps extends React.InputHTMLAttributes {
label?: string;
+ description?: string;
hideLabel?: boolean;
}
@@ -42,6 +43,7 @@ const EyeOffIcon = () => (
const JoinInput: React.FC = ({
label,
+ description,
hideLabel,
className,
type,
@@ -55,23 +57,41 @@ const JoinInput: React.FC = ({
: "password"
: type;
+ const hasHeight = className?.includes("h-");
+ const hasPx =
+ className?.includes("px-") ||
+ className?.includes("pl-") ||
+ className?.includes("pr-") ||
+ className?.includes("p-");
+ const hasPy =
+ className?.includes("py-") ||
+ className?.includes("pt-") ||
+ className?.includes("pb-") ||
+ className?.includes("p-");
+
return (
{label && (
-
- {label}
-
+
+
+ {label}
+
+ {description && (
+
+ {description}
+
+ )}
+
)}
{isPasswordType && (
diff --git a/src/components/base-ui/Join/JoinLayout.tsx b/src/components/base-ui/Join/JoinLayout.tsx
index 5c6bf9b..ee40c44 100644
--- a/src/components/base-ui/Join/JoinLayout.tsx
+++ b/src/components/base-ui/Join/JoinLayout.tsx
@@ -1,7 +1,6 @@
-// JoinLayout.tsx
-
import React from "react";
import JoinHeader from "./JoinHeader";
+import { useSignup } from "@/contexts/SignupContext";
interface JoinLayoutProps {
children: React.ReactNode;
@@ -9,13 +8,31 @@ interface JoinLayoutProps {
desktopGap?: string;
}
-const JoinLayout: React.FC
= ({ children, title, desktopGap = "t:gap-[100px]" }) => {
+const JoinLayout: React.FC = ({
+ children,
+ title,
+ desktopGap = "t:gap-[100px]",
+}) => {
+ const { toast } = useSignup();
+
return (
-
+
-
- {children}
-
+
{children}
+
+ {/* Global Toast Notification */}
+ {toast && (
+
+
+ {toast.message}
+
+
+ )}
);
};
diff --git a/src/components/base-ui/Join/steps/EmailVerification/EmailVerification.tsx b/src/components/base-ui/Join/steps/EmailVerification/EmailVerification.tsx
index a1aaeac..eb757a1 100644
--- a/src/components/base-ui/Join/steps/EmailVerification/EmailVerification.tsx
+++ b/src/components/base-ui/Join/steps/EmailVerification/EmailVerification.tsx
@@ -24,17 +24,15 @@ const EmailVerification: React.FC
= ({ onNext }) => {
startTimer,
isVerified,
handleVerify,
- showToast,
- isToastVisible,
formatTime,
} = useEmailVerification();
return (
- {/* Form Container: Frame 2087328372 */}
+ {/* Form Container */}
- {/* Email Input Group: Frame 2087328369 */}
+ {/* Email Input Group */}
= ({ onNext }) => {
인증 번호 발송
@@ -77,18 +74,17 @@ const EmailVerification: React.FC = ({ onNext }) => {
인증 완료
- {/* Next Button: CTA_2 */}
+ {/* Next Button */}
= ({ onNext }) => {
>
다음
-
- {/* Toast Notification */}
- {showToast && (
-
-
- 인증이 완료되었습니다.
-
-
- )}
);
diff --git a/src/components/base-ui/Join/steps/PasswordEntry/PasswordEntry.tsx b/src/components/base-ui/Join/steps/PasswordEntry/PasswordEntry.tsx
index ec293e3..8591ee4 100644
--- a/src/components/base-ui/Join/steps/PasswordEntry/PasswordEntry.tsx
+++ b/src/components/base-ui/Join/steps/PasswordEntry/PasswordEntry.tsx
@@ -1,22 +1,40 @@
"use client";
-import React, { useState } from "react";
+import React from "react";
import JoinLayout from "@/components/base-ui/Join/JoinLayout";
import JoinButton from "@/components/base-ui/Join/JoinButton";
import JoinInput from "@/components/base-ui/Join/JoinInput";
+import { useSignup } from "@/contexts/SignupContext";
+import { authService } from "@/services/authService";
interface PasswordEntryProps {
onNext: () => void;
}
const PasswordEntry: React.FC = ({ onNext }) => {
- const [password, setPassword] = useState("");
- const [confirmPassword, setConfirmPassword] = useState("");
+ const { email, password, setPassword, confirmPassword, setConfirmPassword, showToast } = useSignup();
+ const [isLoading, setIsLoading] = React.useState(false);
- // 유효성 검사: 비밀번호가 입력되었고, 두 비밀번호가 일치하는지 확인
- // 스펙상 "20자 이내" 제한
+ // 유효성 검사: 6-12자, 영어 최소 1자, 특수문자 최소 1자 포함
+ const passwordRegex = /^(?=.*[A-Za-z])(?=.*[!@#$%^&*()_+={}\[\]:;"'<>,.?/-]).{6,12}$/;
const isMatch =
- password.length > 0 && password.length <= 20 && password === confirmPassword;
+ passwordRegex.test(password) && password === confirmPassword;
+
+ const handleNext = async () => {
+ if (!isMatch || isLoading) return;
+
+ setIsLoading(true);
+ try {
+ await authService.signup({ email, password });
+ await authService.login({ email, password });
+ onNext();
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : "회원가입 중 오류가 발생했습니다.";
+ showToast(errorMessage);
+ } finally {
+ setIsLoading(false);
+ }
+ };
return (
@@ -26,11 +44,12 @@ const PasswordEntry: React.FC = ({ onNext }) => {
{/* Password Input */}
setPassword(e.target.value)}
- maxLength={20}
+ maxLength={12}
className="border-Subbrown-4 placeholder-Gray-3 bg-white"
/>
@@ -40,17 +59,17 @@ const PasswordEntry: React.FC = ({ onNext }) => {
placeholder="비밀번호 확인"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
- maxLength={20}
+ maxLength={12}
className="border-Subbrown-4 placeholder-Gray-3 bg-white"
/>
- 다음
+ {isLoading ? "처리 중..." : "다음"}
diff --git a/src/components/base-ui/Join/steps/ProfileImage/InterestCategorySelector.tsx b/src/components/base-ui/Join/steps/ProfileImage/InterestCategorySelector.tsx
index 628358b..b559da3 100644
--- a/src/components/base-ui/Join/steps/ProfileImage/InterestCategorySelector.tsx
+++ b/src/components/base-ui/Join/steps/ProfileImage/InterestCategorySelector.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { INTEREST_CATEGORIES } from "./useProfileImage";
+import { CATEGORIES } from "@/constants/categories";
interface InterestCategorySelectorProps {
selectedInterests: string[];
@@ -27,19 +27,18 @@ const InterestCategorySelector: React.FC
= ({
{/* Grid for Mobile (3 cols), Flex for Desktop */}
- {INTEREST_CATEGORIES.map((category) => {
- const isSelected = selectedInterests.includes(category);
+ {CATEGORIES.map((cat) => {
+ const isSelected = selectedInterests.includes(cat.value);
return (
onToggle(category)}
- className={`w-full md:w-[122px] h-[44px] flex justify-center items-center rounded-[400px] text-[14px] leading-[145%] tracking-[-0.014px] transition-colors ${
- isSelected
+ key={cat.value}
+ onClick={() => onToggle(cat.value)}
+ className={`w-full md:w-[122px] h-[44px] flex justify-center items-center rounded-[400px] text-[14px] leading-[145%] tracking-[-0.014px] transition-colors ${isSelected
? "bg-Subbrown-1 border border-Subbrown-3 text-White font-medium"
: "bg-background border border-Subbrown-3 text-Gray-5 font-normal"
- }`}
+ }`}
>
- {category}
+ {cat.label}
);
})}
diff --git a/src/components/base-ui/Join/steps/ProfileImage/ProfileImage.tsx b/src/components/base-ui/Join/steps/ProfileImage/ProfileImage.tsx
index 68e537a..8e195e5 100644
--- a/src/components/base-ui/Join/steps/ProfileImage/ProfileImage.tsx
+++ b/src/components/base-ui/Join/steps/ProfileImage/ProfileImage.tsx
@@ -6,6 +6,7 @@ import JoinButton from "../../JoinButton";
import { useProfileImage } from "./useProfileImage";
import ProfileImageUploader from "./ProfileImageUploader";
import InterestCategorySelector from "./InterestCategorySelector";
+import { useSignup } from "@/contexts/SignupContext";
interface ProfileImageProps {
onNext?: () => void;
@@ -15,14 +16,19 @@ const ProfileImage: React.FC
= ({ onNext }) => {
const {
selectedInterests,
profileImage,
+ isProfileImageSet,
toggleInterest,
handleResetImage,
handleImageUpload,
+ handleFinish,
+ isLoading,
isValid,
} = useProfileImage();
+ const { showToast } = useSignup();
const [mobileStep, setMobileStep] = useState<"image" | "interest">("image");
const [isMobile, setIsMobile] = useState(false);
+ const interestRef = React.useRef(null);
useEffect(() => {
const checkMobile = () => {
@@ -37,20 +43,36 @@ const ProfileImage: React.FC = ({ onNext }) => {
}, []);
const handleNextClick = () => {
+ if (isLoading) return;
+
+ // 1. 프로필 사진 업로드 안하거나, 기본 프로필 사진 사용 클릭 안하고 다음 버튼 클릭시
+ if (!isProfileImageSet && (isMobile ? mobileStep === "image" : true)) {
+ showToast("프로필 사진을 입력해주세요!");
+ return;
+ }
+
if (isMobile && mobileStep === "image") {
setMobileStep("interest");
- } else {
- onNext?.();
+ return;
+ }
+
+ // 2. 관심 독서 카테고리를 선택하지 않고 다음 버튼 클릭시
+ if (selectedInterests.length === 0) {
+ showToast("관심 독서 카테고리를 최소 1개 선택해주세요!");
+ interestRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
+ return;
+ }
+
+ if (isValid) {
+ handleFinish(() => onNext?.());
}
};
// Logic for disabling the button
- // Desktop: !isValid (must select at least 1 interest)
- // Mobile Step 1 (Image): Always enabled (image is optional or default provided)
- // Mobile Step 2 (Interest): !isValid
- const isButtonDisabled = isMobile
- ? mobileStep === "interest" && !isValid
- : !isValid;
+ // 버튼은 항상 활성화 해두고 클릭 시 토스트로 피드백 (기능 명세에 '필수 입력 필드 채워졌을 때 활성화'라고 되어있으나
+ // 토스트 피드백을 보여주기 위해 클릭 가능하게 유지하는 것이 일반적인 UI 패턴임.
+ // 만약 비활성화가 우선이라면 토스트를 보여줄 수 없음.)
+ const isButtonDisabled = false;
// Dynamic Header Title
const headerTitle = isMobile
@@ -66,9 +88,8 @@ const ProfileImage: React.FC = ({ onNext }) => {
{/* Profile Image Uploader: Visible on Desktop OR Mobile Step 1 */}
= ({ onNext }) => {
{/* Interest Selector: Visible on Desktop OR Mobile Step 2 */}
= ({ onNext }) => {
- {isMobile && mobileStep === "image" ? "다음" : "가입 완료"}
+ {isLoading ? "처리 중..." : (isMobile && mobileStep === "image" ? "다음" : "가입 완료")}
diff --git a/src/components/base-ui/Join/steps/ProfileImage/ProfileImageUploader.tsx b/src/components/base-ui/Join/steps/ProfileImage/ProfileImageUploader.tsx
index 9df76dd..c13274a 100644
--- a/src/components/base-ui/Join/steps/ProfileImage/ProfileImageUploader.tsx
+++ b/src/components/base-ui/Join/steps/ProfileImage/ProfileImageUploader.tsx
@@ -23,7 +23,7 @@ const ProfileImageUploader: React.FC = ({
{
- const [selectedInterests, setSelectedInterests] = useState([]);
- const [profileImage, setProfileImage] = useState(
- "/default_profile_1.svg"
- );
+ const {
+ nickname,
+ name,
+ phone: phoneNumber,
+ intro: description,
+ selectedInterests,
+ setSelectedInterests,
+ profileImage: imgUrl,
+ setProfileImage,
+ isProfileImageSet,
+ setIsProfileImageSet,
+ showToast,
+ } = useSignup();
+
+ const [isLoading, setIsLoading] = useState(false);
+ const [selectedFile, setSelectedFile] = useState(null);
const toggleInterest = (category: string) => {
if (selectedInterests.includes(category)) {
- setSelectedInterests((prev) => prev.filter((c) => c !== category));
+ setSelectedInterests(selectedInterests.filter((c: string) => c !== category));
} else {
if (selectedInterests.length < 6) {
- setSelectedInterests((prev) => [...prev, category]);
+ setSelectedInterests([...selectedInterests, category]);
+ } else {
+ showToast("카테고리는 최대 6개까지 선택 가능합니다.");
}
}
};
const handleResetImage = () => {
- setProfileImage("/default_profile_1.svg");
+ setProfileImage("/profile.svg");
+ setIsProfileImageSet(true);
+ setSelectedFile(null);
};
const handleImageUpload = (e: React.ChangeEvent) => {
@@ -43,26 +44,80 @@ export const useProfileImage = () => {
if (file) {
const imageUrl = URL.createObjectURL(file);
setProfileImage(imageUrl);
+ setIsProfileImageSet(true);
+ setSelectedFile(file);
+ }
+ };
+
+ const handleFinish = async (onSuccess?: () => void) => {
+ if (isLoading) return;
+
+ setIsLoading(true);
+ try {
+ let finalImageUrl = "";
+
+ // 1. If a new image is selected, upload to S3 using Presigned URL
+ if (selectedFile) {
+ const presignedResponse = await authService.getPresignedUrl(
+ "PROFILE",
+ selectedFile.name,
+ selectedFile.type
+ );
+
+ if (presignedResponse.isSuccess && presignedResponse.result) {
+ const { presignedUrl, imageUrl } = presignedResponse.result;
+ await authService.uploadToS3(presignedUrl, selectedFile);
+ finalImageUrl = imageUrl;
+ } else {
+ showToast(presignedResponse.message || "이미지 업로드 준비 중 오류가 발생했습니다.");
+ setIsLoading(false);
+ return;
+ }
+ } else if (imgUrl && !imgUrl.startsWith("blob:")) {
+ // Includes uploaded S3 URL or default image path (e.g. /profile.svg)
+ finalImageUrl = imgUrl;
+ }
+
+ // 2. Submit all info to additional-info endpoint
+ const categories = selectedInterests.map((c: string) => CATEGORY_MAP[c] || c);
+
+ await authService.additionalInfo({
+ nickname,
+ name,
+ phoneNumber,
+ description,
+ profileImageUrl: finalImageUrl,
+ categories,
+ });
+
+ onSuccess?.();
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : "정보 저장 중 오류가 발생했습니다.";
+ showToast(errorMessage);
+ } finally {
+ setIsLoading(false);
}
};
- // 메모리 누수 방지를 위한 cleanup
useEffect(() => {
return () => {
- if (profileImage && profileImage.startsWith("blob:")) {
- URL.revokeObjectURL(profileImage);
+ if (imgUrl && imgUrl.startsWith("blob:")) {
+ URL.revokeObjectURL(imgUrl);
}
};
- }, [profileImage]);
+ }, [imgUrl]);
- const isValid = selectedInterests.length >= 1;
+ const isValid = selectedInterests.length >= 1 && selectedInterests.length <= 6;
return {
selectedInterests,
- profileImage,
+ profileImage: imgUrl,
+ isProfileImageSet,
toggleInterest,
handleResetImage,
handleImageUpload,
+ handleFinish,
+ isLoading,
isValid,
};
};
diff --git a/src/components/base-ui/Join/steps/ProfileSetup/ProfileSetup.tsx b/src/components/base-ui/Join/steps/ProfileSetup/ProfileSetup.tsx
index 607495e..ca144cb 100644
--- a/src/components/base-ui/Join/steps/ProfileSetup/ProfileSetup.tsx
+++ b/src/components/base-ui/Join/steps/ProfileSetup/ProfileSetup.tsx
@@ -3,6 +3,7 @@ import JoinLayout from "../../JoinLayout";
import JoinButton from "../../JoinButton";
import JoinInput from "../../JoinInput";
import { useProfileSetup } from "./useProfileSetup";
+import { useSignup } from "@/contexts/SignupContext";
interface ProfileSetupProps {
onNext?: () => void;
@@ -12,6 +13,7 @@ const ProfileSetup: React.FC = ({ onNext }) => {
const {
nickname,
isNicknameChecked,
+ isNicknameValid,
intro,
name,
phone,
@@ -20,8 +22,33 @@ const ProfileSetup: React.FC = ({ onNext }) => {
handleNameChange,
handlePhoneChange,
handleCheckDuplicate,
- isValid,
+ validate,
} = useProfileSetup();
+ const { showToast } = useSignup();
+ const nicknameRef = React.useRef(null);
+
+ const nameRef = React.useRef(null);
+ const phoneRef = React.useRef(null);
+ const introRef = React.useRef(null);
+
+ const handleNextClick = () => {
+ const { isValid, field } = validate();
+
+ if (!isValid) {
+ if (field === "nickname" || field === "isNicknameChecked") {
+ nicknameRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
+ } else if (field === "intro") {
+ introRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
+ } else if (field === "name") {
+ nameRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
+ } else if (field === "phone") {
+ phoneRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
+ }
+ return;
+ }
+
+ onNext?.();
+ };
return (
@@ -31,18 +58,17 @@ const ProfileSetup: React.FC = ({ onNext }) => {
{/* Group 1: Nickname & Intro */}
{/* 닉네임 섹션 */}
-
+
닉네임
-
+
{/* Mobile Input */}
@@ -52,7 +78,6 @@ const ProfileSetup: React.FC
= ({ onNext }) => {
@@ -60,18 +85,17 @@ const ProfileSetup: React.FC = ({ onNext }) => {
- 중복확인
+ {isNicknameChecked ? "확인됨" : "중복확인"}
-
- {/* 한줄소개 */}
-
+ {/* 한줄소개 */}
+
소개
@@ -84,45 +108,44 @@ const ProfileSetup: React.FC
= ({ onNext }) => {
- {/* Group 2: Name & Phone */}
+ {/* Group 2: Name & Phone */}
{/* 이름 */}
-
+
이름
-
{/* 전화번호 */}
-
- value={phone}
- onChange={handlePhoneChange}
- placeholder="010-0000-0000"
- className="h-[36px] t:h-[44px] py-0 t:py-[12px] border-Subbrown-4 placeholder-Gray-3 text-[14px] font-normal bg-white"
- />
+
+ 다음
+
-
-
- 다음
-
-
-
);
};
diff --git a/src/components/base-ui/Join/steps/ProfileSetup/useProfileSetup.ts b/src/components/base-ui/Join/steps/ProfileSetup/useProfileSetup.ts
index b62d676..87112ee 100644
--- a/src/components/base-ui/Join/steps/ProfileSetup/useProfileSetup.ts
+++ b/src/components/base-ui/Join/steps/ProfileSetup/useProfileSetup.ts
@@ -1,61 +1,104 @@
-import { useState } from "react";
+import { useSignup } from "@/contexts/SignupContext";
+import { authService } from "@/services/authService";
+import { z } from "zod";
-export const useProfileSetup = () => {
- const [nickname, setNickname] = useState("");
- const [isNicknameChecked, setIsNicknameChecked] = useState(false);
+const profileSchema = z.object({
+ nickname: z.string().min(1, "닉네임을 입력해주세요!"),
+ isNicknameChecked: z.boolean().refine((val) => val === true, {
+ message: "닉네임 중복확인을 해주세요!",
+ }),
+ intro: z.string().min(1, "소개를 입력해주세요!").max(40, "소개는 40자 이내로 작성해주세요."),
+ name: z.string().min(1, "이름을 입력해주세요!"),
+ phone: z.string().min(1, "전화번호를 입력해주세요!").regex(/^010-\d{3,4}-\d{4}$/, "올바른 전화번호 형식이 아닙니다."),
+});
- const [intro, setIntro] = useState("");
- const [name, setName] = useState("");
- const [phone, setPhone] = useState("");
+export const useProfileSetup = () => {
+ const {
+ nickname,
+ setNickname,
+ isNicknameChecked,
+ setIsNicknameChecked,
+ intro,
+ setIntro,
+ name,
+ setName,
+ phone,
+ setPhone,
+ showToast,
+ } = useSignup();
const handleNicknameChange = (e: React.ChangeEvent
) => {
- setNickname(e.target.value);
+ const value = e.target.value;
+ // 닉네임 : 영어 소문자 및 특수문자, 숫자만 사용 가능, 최대 20글자
+ const filteredValue = value.replace(/[^a-z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/g, "").slice(0, 20);
+ setNickname(filteredValue);
setIsNicknameChecked(false);
};
const handleIntroChange = (e: React.ChangeEvent) => {
- setIntro(e.target.value);
+ setIntro(e.target.value.slice(0, 40)); // 최대 40자
};
const handleNameChange = (e: React.ChangeEvent) => {
- setName(e.target.value);
+ setName(e.target.value.slice(0, 10)); // 10자 제한
};
const handlePhoneChange = (e: React.ChangeEvent) => {
- const value = e.target.value.replace(/[^0-9]/g, "");
+ const value = e.target.value.replace(/[^0-9]/g, "").slice(0, 11);
let formatted = value;
- if (value.length < 4) {
+ if (value.length <= 3) {
formatted = value;
- } else if (value.length < 8) {
+ } else if (value.length <= 7) {
formatted = `${value.slice(0, 3)}-${value.slice(3)}`;
- } else if (value.length <= 11) {
- formatted = `${value.slice(0, 3)}-${value.slice(3, 7)}-${value.slice(7)}`;
} else {
- formatted = `${value.slice(0, 3)}-${value.slice(3, 7)}-${value.slice(
- 7,
- 11
- )}`;
+ formatted = `${value.slice(0, 3)}-${value.slice(3, 7)}-${value.slice(7)}`;
}
setPhone(formatted);
};
- const handleCheckDuplicate = () => {
+ const handleCheckDuplicate = async () => {
if (!nickname) return;
- console.log("Check duplicate nickname:", nickname);
- setIsNicknameChecked(true);
+
+ try {
+ const response = await authService.checkNickname(nickname);
+ // Backend Spec: result: false (not duplicated/available), result: true (duplicated/taken)
+ if (response.isSuccess && response.result === false) {
+ setIsNicknameChecked(true);
+ showToast("사용 가능한 닉네임입니다.");
+ } else {
+ showToast("이미 사용 중인 닉네임입니다.");
+ setIsNicknameChecked(false);
+ }
+ } catch (error: any) {
+ showToast(error.message || "닉네임 확인 중 오류가 발생했습니다.");
+ }
+ };
+
+ const validate = () => {
+ const result = profileSchema.safeParse({
+ nickname,
+ isNicknameChecked,
+ intro,
+ name,
+ phone,
+ });
+
+ if (!result.success) {
+ const firstError = result.error.issues[0];
+ showToast(firstError.message);
+ return { isValid: false, field: firstError.path[0] as string };
+ }
+
+ return { isValid: true };
};
- const isValid =
- nickname !== "" &&
- isNicknameChecked &&
- intro !== "" &&
- name !== "" &&
- phone !== "";
+ const isNicknameValid = nickname.length > 0;
return {
nickname,
isNicknameChecked,
+ isNicknameValid,
intro,
name,
phone,
@@ -64,6 +107,7 @@ export const useProfileSetup = () => {
handleNameChange,
handlePhoneChange,
handleCheckDuplicate,
- isValid,
+ validate,
};
};
+
diff --git a/src/components/base-ui/Join/steps/SignupComplete/useSignupComplete.ts b/src/components/base-ui/Join/steps/SignupComplete/useSignupComplete.ts
index 25aca3f..e039253 100644
--- a/src/components/base-ui/Join/steps/SignupComplete/useSignupComplete.ts
+++ b/src/components/base-ui/Join/steps/SignupComplete/useSignupComplete.ts
@@ -1,12 +1,9 @@
import { useRouter } from "next/navigation";
+import { useSignup } from "@/contexts/SignupContext";
export const useSignupComplete = () => {
const router = useRouter();
-
- // 더미 데이터
- const nickname = "닉네임";
- const intro = "안녕하세요~ 윤현일입니다.";
- const profileImage = "/profile.svg"; // 이미지가 없을 경우 /default_profile_1.svg 사용 권장
+ const { nickname, intro, profileImage } = useSignup();
const handleSearchMeeting = () => {
console.log("Search Meeting clicked");
@@ -24,9 +21,9 @@ export const useSignupComplete = () => {
};
return {
- nickname,
- intro,
- profileImage,
+ nickname: nickname || "닉네임",
+ intro: intro || "안녕하세요~",
+ profileImage: profileImage || "/default_profile_1.svg",
handleSearchMeeting,
handleCreateMeeting,
handleUseWithoutMeeting,
diff --git a/src/components/base-ui/Join/steps/TermsAgreement/TermsAgreement.tsx b/src/components/base-ui/Join/steps/TermsAgreement/TermsAgreement.tsx
index 95abca8..bca1a61 100644
--- a/src/components/base-ui/Join/steps/TermsAgreement/TermsAgreement.tsx
+++ b/src/components/base-ui/Join/steps/TermsAgreement/TermsAgreement.tsx
@@ -6,6 +6,7 @@ import React, { useState } from "react";
import Image from "next/image";
import JoinLayout from "@/components/base-ui/Join/JoinLayout";
import JoinButton from "@/components/base-ui/Join/JoinButton";
+import { useSignup } from "@/contexts/SignupContext";
export const TERMS_DATA = [
{
@@ -22,17 +23,156 @@ export const TERMS_DATA = [
},
];
+const TERMS_CONTENT: Record = {
+ servicePrivacy: {
+ title: "서비스 이용을 위한 필수 개인정보 수집 이용 동의(필수)",
+ content: `책모는 서비스 제공을 위해 아래와 같이 개인정보를 수집·이용합니다.
+
+### 1. 수집 항목
+
+- 이메일 주소(아이디)
+- 비밀번호
+- 이름
+- 전화번호
+- 닉네임
+- 서비스 이용 기록 (모임 참여, 콘텐츠 작성 등)
+- 접속 로그 및 이용 이력
+
+### 2. 수집·이용 목적
+
+- 회원 식별 및 가입 의사 확인
+- 책모 서비스 제공 및 운영
+- 독서 모임, 콘텐츠, 커뮤니티 기능 제공
+- 고객 문의 및 고객센터 응대
+- 서비스 품질 개선 및 오류 분석
+
+### 3. 보유 및 이용 기간
+
+- 회원 탈퇴 시까지 보관
+- 단, 관계 법령에 따라 일정 기간 보관이 필요한 경우 해당 기간 동안 보관
+
+### 4. 동의 거부 권리 및 불이익
+
+- 이용자는 개인정보 수집·이용에 대한 동의를 거부할 수 있습니다.
+- 다만, 필수 항목에 대한 동의를 거부할 경우 서비스 이용이 제한될 수 있습니다.
+- 관련 법령: 「개인정보 보호법」 제15조`,
+ },
+ termsOfUse: {
+ title: "책모 이용 약관",
+ content: `## 제1조 (목적)
+본 약관은 책모(이하 “서비스”)가 제공하는 독서 커뮤니티 플랫폼 및 관련 제반 서비스의 이용과 관련하여 서비스와 회원 간의 권리, 의무 및 책임 사항을 규정함을 목적으로 합니다.
+
+## 제2조 (정의)
+본 약관에서 사용하는 용어의 정의는 다음과 같습니다.
+1. “서비스”란 책모가 제공하는 웹 및 애플리케이션 기반의 독서 커뮤니티, 모임, 콘텐츠 공유 등 일체의 서비스를 의미합니다.
+2. “회원”이란 본 약관에 동의하고 서비스에 가입한 자를 말합니다.
+3. “콘텐츠”란 회원 또는 서비스가 서비스 내에 게시한 텍스트, 이미지, 댓글, 채팅 메시지 등 일체의 정보를 의미합니다.
+4. “모임”이란 회원이 생성하거나 참여할 수 있는 독서 클럽 단위의 커뮤니티를 의미합니다.
+
+## 제3조 (약관의 효력 및 변경)
+1. 본 약관은 회원이 회원가입 시 동의함으로써 효력이 발생합니다.
+2. 서비스는 관련 법령을 위반하지 않는 범위에서 본 약관을 변경할 수 있습니다.
+3. 약관의 중요한 변경 사항은 서비스 내 공지 또는 별도 안내를 통해 고지합니다.
+4. 변경된 약관에 동의하지 않을 경우, 회원은 서비스 이용을 중단하고 탈퇴할 수 있습니다.
+
+## 제4조 (회원가입)
+1. 회원가입은 이용자가 본 약관과 개인정보 처리방침에 동의하고 서비스가 정한 절차에 따라 가입을 신청함으로써 이루어집니다.
+2. 서비스는 다음 경우 가입 신청을 제한할 수 있습니다.
+ - 허위 정보를 기재한 경우
+ - 타인의 정보를 도용한 경우
+ - 기타 서비스 운영상 부적절하다고 판단되는 경우
+
+## 제5조 (회원의 의무)
+1. 회원은 관계 법령, 본 약관 및 서비스 정책을 준수해야 합니다.
+2. 회원은 타인의 권리를 침해하거나 서비스 운영을 방해하는 행위를 해서는 안 됩니다.
+3. 회원은 본인의 계정 정보를 안전하게 관리할 책임이 있습니다.
+
+## 제6조 (서비스의 제공 및 변경)
+1. 서비스는 독서 모임, 콘텐츠 공유, 커뮤니티 기능 등을 제공합니다.
+2. 서비스는 운영상 또는 기술상 필요에 따라 제공하는 서비스의 일부 또는 전부를 변경할 수 있습니다.
+
+## 제7조 (콘텐츠의 권리와 책임)
+1. 회원이 서비스에 게시한 콘텐츠의 저작권은 해당 회원에게 귀속됩니다.
+2. 회원은 서비스 운영을 위해 해당 콘텐츠를 서비스 내에서 노출, 저장, 이용하는 것을 허락합니다.
+3. 회원은 타인의 저작권을 침해하는 콘텐츠를 게시해서는 안 됩니다.
+
+## 제8조 (서비스 정책의 준용)
+1. 서비스는 기능별 운영 기준을 별도의 정책집으로 정할 수 있습니다.
+2. 회원은 서비스 이용 시 다음 정책을 포함한 각 기능별 운영정책을 준수해야 합니다.
+ - 모임 운영정책
+ - 실시간 채팅 운영정책
+ - 책이야기 운영정책
+ - 소식 운영정책
+ - 고객센터 운영정책
+3. 정책집은 본 약관의 일부로서 효력을 가집니다.
+
+## 제9조 (서비스 이용 제한)
+1. 회원이 본 약관 또는 정책을 위반한 경우, 서비스는 다음 조치를 취할 수 있습니다.
+ - 서비스 이용 제한
+ - 콘텐츠 삭제
+ - 계정 정지 또는 탈퇴
+2. 중대한 위반 행위의 경우 사전 통보 없이 조치가 이루어질 수 있습니다.
+
+## 제10조 (회원 탈퇴)
+1. 회원은 언제든지 서비스에서 탈퇴할 수 있습니다.
+2. 탈퇴 시 회원의 계정 정보는 관련 법령에 따라 처리됩니다.
+
+## 제11조 (책임의 제한)
+1. 서비스는 회원 간의 자율적인 커뮤니티 활동에 개입하지 않습니다.
+2. 서비스는 회원이 게시한 콘텐츠의 정확성, 신뢰성에 대해 책임을 지지 않습니다.
+3. 서비스는 천재지변, 시스템 장애 등 불가항력으로 인한 서비스 제공 중단에 대해 책임을 지지 않습니다.
+
+## 제12조 (분쟁 해결)
+1. 본 약관과 관련하여 발생한 분쟁은 관계 법령에 따라 해결합니다.
+2. 서비스와 회원 간 분쟁에 대해 소송이 제기될 경우 대한민국 법을 준거법으로 합니다.
+
+## 제13조 (시행일)
+본 약관은부터 2025년 12월 21일부터 시행합니다.`,
+ },
+ thirdParty: {
+ title: "개인정보 제3자 제공 동의(선택)",
+ content: `책모는 원칙적으로 회원의 개인정보를 외부에 제공하지 않습니다.
+
+### 1. 개인정보 제3자 제공 여부
+- 현재 책모는 회원의 개인정보를 제3자에게 제공하지 않습니다.
+
+### 2. 향후 제공 가능성
+- 향후 서비스 운영을 위해 개인정보를 제3자에게 제공해야 하는 경우, 제공 대상, 제공 항목, 제공 목적을 사전에 고지하고 별도의 동의를 받습니다.
+
+### 3. 동의 거부 권리
+- 본 동의는 선택 사항이며, 동의하지 않더라도 서비스 이용에는 제한이 없습니다.
+- 관련 법령 :「개인정보 보호법」 제17조`,
+ },
+ marketing: {
+ title: "마케팅 및 이벤트 정보 수신 동의 (선택)",
+ content: `책모는 서비스 관련 소식 및 혜택 안내를 위해 아래와 같이 정보를 활용할 수 있습니다.
+
+### 1. 수신 내용
+- 이벤트 및 프로모션 안내
+- 신규 기능 및 서비스 소식
+- 독서 모임 및 콘텐츠 추천 정보
+
+### 2. 수신 방법
+- 이메일
+- 서비스 내 알림
+
+### 3. 보유 및 이용 기간
+- 회원 탈퇴 또는 수신 동의 철회 시까지
+
+### 4. 동의 거부 및 철회
+- 본 동의는 선택 사항이며, 동의하지 않더라도 책모 서비스 이용에는 제한이 없습니다.
+- 수신 동의는 언제든지 설정 화면을 통해 철회할 수 있습니다.
+- 관련 법령 :「정보통신망 이용촉진 및 정보보호 등에 관한 법률」 제50조`,
+ },
+};
+
interface TermsAgreementProps {
onNext: () => void;
}
const TermsAgreement: React.FC = ({ onNext }) => {
- const initialAgreements = TERMS_DATA.reduce((acc, term) => {
- acc[term.id] = false;
- return acc;
- }, {} as Record);
-
- const [agreements, setAgreements] = useState(initialAgreements);
+ const { agreements, setAgreements } = useSignup();
+ const [selectedTermId, setSelectedTermId] = useState(null);
const allAgreed = TERMS_DATA.every((term) => agreements[term.id]);
const isButtonEnabled = TERMS_DATA.filter((term) => term.required).every(
@@ -44,7 +184,7 @@ const TermsAgreement: React.FC = ({ onNext }) => {
};
const handleAgreementChange = (id: string, checked: boolean) => {
- setAgreements((prev) => ({ ...prev, [id]: checked }));
+ setAgreements({ ...agreements, [id]: checked });
};
const handleAllAgreementChange = () => {
@@ -56,6 +196,94 @@ const TermsAgreement: React.FC = ({ onNext }) => {
setAgreements(newAgreements);
};
+ const renderMarkdown = (text: string) => {
+ return text.split("\n").map((line, index) => {
+ const trimmed = line.trim();
+ if (trimmed.startsWith("# ")) {
+ return (
+
+ {trimmed.replace("# ", "")}
+
+ );
+ }
+ if (trimmed.startsWith("## ")) {
+ return (
+
+ {trimmed.replace("## ", "")}
+
+ );
+ }
+ if (trimmed.startsWith("### ")) {
+ return (
+
+ {trimmed.replace("### ", "")}
+
+ );
+ }
+ if (trimmed.startsWith("- ")) {
+ return (
+
+ •
+ {trimmed.replace("- ", "")}
+
+ );
+ }
+ if (trimmed === "") return ;
+ return (
+
+ {line}
+
+ );
+ });
+ };
+
+ if (selectedTermId) {
+ const term = TERMS_CONTENT[selectedTermId];
+ return (
+
+
+ {/* Detailed Box: Responsive width and padding */}
+
+
setSelectedTermId(null)}
+ className="absolute right-[12px] top-[12px] w-[24px] h-[24px] flex items-center justify-center text-Gray-4 hover:text-Gray-6 transition-colors z-10"
+ >
+
+
+
+
+
+ {renderMarkdown(term.content)}
+
+
+
+
{
+ handleAgreementChange(selectedTermId, true);
+ setSelectedTermId(null);
+ }}
+ className="w-full t:w-[526px] h-[48px] px-[16px] py-[12px] mt-[20px] t:mt-[24px] shrink-0 font-sans text-[14px] font-semibold leading-[145%] tracking-[-0.014px]"
+ >
+ 동의
+
+
+
+
+ );
+ }
+
return (
@@ -66,15 +294,18 @@ const TermsAgreement: React.FC
= ({ onNext }) => {
{TERMS_DATA.map((term) => (
- handleAgreementChange(term.id, !agreements[term.id])
- }
+ className="flex items-center justify-between w-full gap-[12px]"
>
-
+ setSelectedTermId(term.id)}
+ >
{term.label}
-
+
handleAgreementChange(term.id, !agreements[term.id])}
+ >
{
- const [email, setEmail] = useState("");
- const [isEmailValid, setIsEmailValid] = useState(false);
- const [timeLeft, setTimeLeft] = useState(null);
- const [verificationCode, setVerificationCode] = useState("");
- const [isCodeValid, setIsCodeValid] = useState(false);
- const [isVerified, setIsVerified] = useState(false);
+ const {
+ email,
+ setEmail,
+ verificationCode,
+ setVerificationCode,
+ isVerified,
+ setIsVerified,
+ timeLeft,
+ setTimeLeft,
+ showToast,
+ } = useSignup();
+
+ const [isLoading, setIsLoading] = useState(false);
- // Toast Animation State
- const [showToast, setShowToast] = useState(false); // Controls mounting
- const [isToastVisible, setIsToastVisible] = useState(false); // Controls opacity
+ const isEmailValid = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email);
+ const isCodeValid = verificationCode.length === 6;
const handleEmailChange = (e: React.ChangeEvent) => {
- const value = e.target.value;
- setEmail(value);
- const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
- setIsEmailValid(emailRegex.test(value));
+ setEmail(e.target.value);
};
const handleVerificationCodeChange = (
@@ -27,20 +30,44 @@ export const useEmailVerification = () => {
const value = e.target.value;
if (/^\d*$/.test(value) && value.length <= 6) {
setVerificationCode(value);
- setIsCodeValid(value.length === 6);
}
};
- const startTimer = () => {
- if (isEmailValid) {
- setTimeLeft(300);
+ const startTimer = async () => {
+ if (!isEmailValid || isLoading) return;
+
+ setIsLoading(true);
+ try {
+ const response = await authService.verifyEmail(email);
+ if (response.isSuccess) {
+ showToast(response.result || "인증번호가 발송되었습니다.");
+ setTimeLeft(300);
+ } else {
+ showToast(response.message || "인증번호 발송에 실패했습니다.");
+ }
+ } catch (error: any) {
+ showToast(error.message || "인증번호 발송에 실패했습니다.");
+ } finally {
+ setIsLoading(false);
}
};
- const handleVerify = () => {
- if (isCodeValid) {
- setIsVerified(true);
- setShowToast(true);
+ const handleVerify = async () => {
+ if (!isCodeValid || isLoading) return;
+
+ setIsLoading(true);
+ try {
+ const response = await authService.confirmEmail({ email, verificationCode });
+ if (response.isSuccess && response.result === true) {
+ setIsVerified(true);
+ showToast("인증이 완료되었습니다.");
+ } else {
+ showToast(response.message || "인증번호가 일치하지 않습니다.");
+ }
+ } catch (error: any) {
+ showToast(error.message || "인증 확인 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
}
};
@@ -53,29 +80,10 @@ export const useEmailVerification = () => {
useEffect(() => {
if (timeLeft === null || timeLeft === 0) return;
const interval = setInterval(() => {
- setTimeLeft((prev) => (prev !== null && prev > 0 ? prev - 1 : 0));
+ setTimeLeft(timeLeft > 0 ? timeLeft - 1 : 0);
}, 1000);
return () => clearInterval(interval);
- }, [timeLeft]);
-
- useEffect(() => {
- if (showToast) {
- // 1. Mount (showToast=true) -> Wait 10ms -> Fade In (isToastVisible=true)
- const showTimer = setTimeout(() => setIsToastVisible(true), 10);
-
- // 2. Wait 3000ms -> Fade Out (isToastVisible=false)
- const hideTimer = setTimeout(() => setIsToastVisible(false), 3000);
-
- // 3. Wait 3300ms (allow transition) -> Unmount (showToast=false)
- const unmountTimer = setTimeout(() => setShowToast(false), 3300);
-
- return () => {
- clearTimeout(showTimer);
- clearTimeout(hideTimer);
- clearTimeout(unmountTimer);
- };
- }
- }, [showToast]);
+ }, [timeLeft, setTimeLeft]);
return {
email,
@@ -88,8 +96,6 @@ export const useEmailVerification = () => {
startTimer,
isVerified,
handleVerify,
- showToast,
- isToastVisible, // Now correctly exported
formatTime,
};
};
diff --git a/src/components/base-ui/Login/LoginModal.module.css b/src/components/base-ui/Login/LoginModal.module.css
index 05f58a9..8fa0ef6 100644
--- a/src/components/base-ui/Login/LoginModal.module.css
+++ b/src/components/base-ui/Login/LoginModal.module.css
@@ -15,6 +15,18 @@
background-color: var(--White);
position: relative;
overflow: hidden;
+ animation: slideUp 0.3s ease-out;
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(20px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
}
/* [Close Icon] */
diff --git a/src/components/base-ui/Login/useLoginForm.tsx b/src/components/base-ui/Login/useLoginForm.tsx
index 22154cf..7f52449 100644
--- a/src/components/base-ui/Login/useLoginForm.tsx
+++ b/src/components/base-ui/Login/useLoginForm.tsx
@@ -1,6 +1,5 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
-import Cookies from "js-cookie";
import toast from "react-hot-toast";
import { useAuthStore } from "@/store/useAuthStore";
import { LoginForm } from "@/types/auth";
@@ -44,21 +43,29 @@ export default function useLoginForm(onSuccess?: () => void) {
try {
// Service Layer 호출
- const data = await authService.login(form);
+ const loginData = await authService.login(form);
- console.log("로그인 성공:", data);
- // 1. Token Storage (Secure Cookie)
- if (data.isSuccess && data.result?.accessToken) {
- Cookies.set("accessToken", data.result.accessToken, {
- secure: true,
- sameSite: "strict",
- });
- }
+ // 상세 프로필 정보 가져오기
+ try {
+ const profileResponse = await authService.getProfile();
- // 2. Global State Update
- login({ email: form.email });
+ if (profileResponse.isSuccess && profileResponse.result) {
+ // 전역 상태에 상세 정보 저장
+ login({
+ ...profileResponse.result,
+ email: form.email
+ });
+ } else {
+ // 프로필 조회 실패 시에도 최소한의 정보로 로그인 처리
+ login({ email: form.email });
+ }
+ } catch (profileError) {
+ console.error("Profile fetch failed during login:", profileError);
+ // 프로필 로드 실패해도 로그인은 성공한 상태이므로 최소 정보로 진행
+ login({ email: form.email });
+ }
- // 3. Navigation & UI Feedback
+ // 2. Navigation & UI Feedback
toast.success("로그인에 성공했습니다!");
if (onSuccess) onSuccess();
router.push("/");
diff --git a/src/components/base-ui/LongtermInput.tsx b/src/components/base-ui/LongtermInput.tsx
index 8252819..38f583e 100644
--- a/src/components/base-ui/LongtermInput.tsx
+++ b/src/components/base-ui/LongtermInput.tsx
@@ -73,7 +73,7 @@ export default function LongtermChatInput({
diff --git a/src/components/base-ui/MyPage/MyBookStoryList.tsx b/src/components/base-ui/MyPage/MyBookStoryList.tsx
index 5cf61b0..758ef03 100644
--- a/src/components/base-ui/MyPage/MyBookStoryList.tsx
+++ b/src/components/base-ui/MyPage/MyBookStoryList.tsx
@@ -6,8 +6,8 @@ import { DUMMY_MY_STORIES } from "@/constants/mocks/mypage";
const MyBookStoryList = () => {
return (
-
-
+
+
{DUMMY_MY_STORIES.map((story) => (
{
return (
-
+
{DUMMY_MEETINGS.map((meeting) => (
))}
diff --git a/src/components/base-ui/MyPage/MyNotificationList.tsx b/src/components/base-ui/MyPage/MyNotificationList.tsx
index c1f31cb..391d50b 100644
--- a/src/components/base-ui/MyPage/MyNotificationList.tsx
+++ b/src/components/base-ui/MyPage/MyNotificationList.tsx
@@ -6,7 +6,7 @@ import MyNotificationItem from "./items/MyNotificationItem";
const MyNotificationList = () => {
return (
-
+
{DUMMY_NOTIFICATIONS.map((notification) => (
))}
diff --git a/src/components/base-ui/MyPage/MyPageBreadcrumb.tsx b/src/components/base-ui/MyPage/MyPageBreadcrumb.tsx
index 74303eb..6f6feb7 100644
--- a/src/components/base-ui/MyPage/MyPageBreadcrumb.tsx
+++ b/src/components/base-ui/MyPage/MyPageBreadcrumb.tsx
@@ -3,18 +3,18 @@ import Image from "next/image";
const MyPageBreadcrumb = () => {
return (
-
+
전체
-
+
마이페이지
diff --git a/src/components/base-ui/MyPage/UserProfile.tsx b/src/components/base-ui/MyPage/UserProfile.tsx
index f121b07..8fdce77 100644
--- a/src/components/base-ui/MyPage/UserProfile.tsx
+++ b/src/components/base-ui/MyPage/UserProfile.tsx
@@ -1,13 +1,24 @@
import React from "react";
import Image from "next/image";
+import Link from "next/link";
import JoinButton from "@/components/base-ui/Join/JoinButton";
import { DUMMY_USER_PROFILE } from "@/constants/mocks/mypage";
+import { useAuthStore } from "@/store/useAuthStore";
+import FloatingFab from "../Float";
const UserProfile = () => {
- const user = DUMMY_USER_PROFILE;
+ const { user: authUser } = useAuthStore();
+
+ // 서버 데이터가 있으면 사용하고, 없으면 더미 데이터 사용 (구독자 수 등은 현재 API에 없음)
+ const user = {
+ ...DUMMY_USER_PROFILE,
+ name: authUser?.nickname || authUser?.email || DUMMY_USER_PROFILE.name,
+ intro: authUser?.description || DUMMY_USER_PROFILE.intro,
+ profileImage: authUser?.profileImageUrl || DUMMY_USER_PROFILE.profileImage,
+ };
return (
-
+
{/* Inner Content (Center Aligned) */}
{/* Profile Info Area */}
@@ -72,26 +83,29 @@ const UserProfile = () => {
/>
{/* Settings Icon: Absolute on Mobile, Static on Tablet+ */}
-
+
-
+
{/* Introduction */}
-
{/* Action Buttons */}
-
+
내 책 이야기 쓰기
@@ -99,6 +113,10 @@ const UserProfile = () => {
소식 문의하기
+
);
diff --git a/src/components/base-ui/MyPage/items/MyMeetingCard.tsx b/src/components/base-ui/MyPage/items/MyMeetingCard.tsx
index 3981ba0..0f5c99c 100644
--- a/src/components/base-ui/MyPage/items/MyMeetingCard.tsx
+++ b/src/components/base-ui/MyPage/items/MyMeetingCard.tsx
@@ -10,17 +10,17 @@ interface MyMeetingCardProps {
const MyMeetingCard = ({ meeting }: MyMeetingCardProps) => {
return (
-
-
+
+
{meeting.title}
-
+
diff --git a/src/components/base-ui/MyPage/items/MyNotificationItem.tsx b/src/components/base-ui/MyPage/items/MyNotificationItem.tsx
index ec5d713..f3bd8fe 100644
--- a/src/components/base-ui/MyPage/items/MyNotificationItem.tsx
+++ b/src/components/base-ui/MyPage/items/MyNotificationItem.tsx
@@ -10,8 +10,8 @@ interface MyNotificationItemProps {
const MyNotificationItem = ({ notification }: MyNotificationItemProps) => {
return (
-
-
+
+
{!notification.isRead && (
{
)}
{notification.isRead && }
-
+
{notification.content}
-
+
{notification.time}
diff --git a/src/components/base-ui/Profile/BookStoryList.tsx b/src/components/base-ui/Profile/BookStoryList.tsx
index fb3385f..24caa98 100644
--- a/src/components/base-ui/Profile/BookStoryList.tsx
+++ b/src/components/base-ui/Profile/BookStoryList.tsx
@@ -67,15 +67,14 @@ const MOCK_STORIES = [
export default function BookStoryList() {
return (
-
- {MOCK_STORIES.map(({ id, ...storyData }) => (
-
- ))}
+
+
+ {MOCK_STORIES.map(({ id, ...storyData }) => (
+
+ ))}
+
);
}
diff --git a/src/components/base-ui/Profile/LibraryList.tsx b/src/components/base-ui/Profile/LibraryList.tsx
index 38ffd95..5e78a39 100644
--- a/src/components/base-ui/Profile/LibraryList.tsx
+++ b/src/components/base-ui/Profile/LibraryList.tsx
@@ -13,7 +13,7 @@ const LibraryList = () => {
);
};
return (
-
+
{DUMMY_LIBRARY_BOOKS.map((book) => (
{MOCK_MEETINGS.map((meeting) => (
diff --git a/src/components/base-ui/Profile/OtherUser/ProfileBreadcrumb.tsx b/src/components/base-ui/Profile/OtherUser/ProfileBreadcrumb.tsx
index ce45b5b..7a09ccf 100644
--- a/src/components/base-ui/Profile/OtherUser/ProfileBreadcrumb.tsx
+++ b/src/components/base-ui/Profile/OtherUser/ProfileBreadcrumb.tsx
@@ -3,18 +3,18 @@ import Image from "next/image";
const MyPageBreadcrumb = () => {
return (
-
+
전체
-
+
다른 사람 페이지
diff --git a/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx b/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx
index fbc8a20..fffe001 100644
--- a/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx
+++ b/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx
@@ -88,7 +88,7 @@ export default function ProfileUserInfo({ nickname }: { nickname: string }) {
{/* 소개글 */}
-
+
이제 다양한 책을 함께 읽고 서로의 생각을 나누는 특별한 시간을
시작해보세요. 한 권의 책이 주는 작은 울림이 일상에 큰 변화를
가져올지도 모릅니다.
diff --git a/src/components/base-ui/Profile/items/MeetingCard.tsx b/src/components/base-ui/Profile/items/MeetingCard.tsx
index 46b5626..525aa25 100644
--- a/src/components/base-ui/Profile/items/MeetingCard.tsx
+++ b/src/components/base-ui/Profile/items/MeetingCard.tsx
@@ -10,18 +10,10 @@ type Props = {
export default function MeetingCard({ title, showMoreIcon = false }: Props) {
return (
{/* 모임 제목 */}
-
+
{title}
diff --git a/src/components/base-ui/Search/search_bookresult.tsx b/src/components/base-ui/Search/search_bookresult.tsx
index f1b740c..2809316 100644
--- a/src/components/base-ui/Search/search_bookresult.tsx
+++ b/src/components/base-ui/Search/search_bookresult.tsx
@@ -1,5 +1,5 @@
-'use client';
-import Image from 'next/image';
+"use client";
+import Image from "next/image";
type SearchBookResultProps = {
imgUrl?: string; // 없으면 booksample.svg
@@ -27,20 +27,20 @@ export default function SearchBookResult({
onPencilClick,
onCardClick,
- className = '',
+ className = "",
}: SearchBookResultProps) {
- const coverSrc = imgUrl && imgUrl.length > 0 ? imgUrl : '/booksample.svg';
+ const coverSrc = imgUrl && imgUrl.length > 0 ? imgUrl : "/booksample.svg";
const clippedDetail =
- detail.length > 500 ? detail.slice(0, 500) + '...' : detail;
+ detail.length > 500 ? detail.slice(0, 500) + "..." : detail;
return (
-
- {title}
-
-
- {author}
-
+
{title}
+
{author}
@@ -78,7 +74,7 @@ export default function SearchBookResult({
className="w-[24px] h-[24px] shrink-0"
>
{
- e.stopPropagation();
- onPencilClick?.();
- }}
- className="
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ onPencilClick?.();
+ }}
+ className="
flex absolute bottom-[20px] right-[20px] w-12 h-12 t:w-15 t:h-15 px-[10px] py-[4.167px]
flex-col justify-center items-center gap-[8.333px] shrink-0
rounded-full bg-primary-2
"
- >
-
-
+ >
+
+
);
}
diff --git a/src/components/base-ui/Settings/EditProfile/CategorySelector.tsx b/src/components/base-ui/Settings/EditProfile/CategorySelector.tsx
index b861673..0e23368 100644
--- a/src/components/base-ui/Settings/EditProfile/CategorySelector.tsx
+++ b/src/components/base-ui/Settings/EditProfile/CategorySelector.tsx
@@ -2,24 +2,32 @@
"use client";
const CATEGORIES = [
- "소설/시/희곡",
- "에세이",
- "인문학",
- "경영/경제",
- "자기계발",
- "사회과학",
- "역사",
- "예술/대중문화",
- "만화",
- "장르소설",
- "과학",
- "어린이/청소년",
- "여행",
- "요리",
- "기타",
+ { label: "소설/시/희곡", value: "FICTION_POETRY_DRAMA" },
+ { label: "에세이", value: "ESSAY" },
+ { label: "인문학", value: "HUMANITIES" },
+ { label: "경영/경제", value: "ECONOMY_MANAGEMENT" },
+ { label: "자기계발", value: "SELF_DEVELOPMENT" },
+ { label: "사회과학", value: "SOCIAL_SCIENCE" },
+ { label: "역사", value: "HISTORY_CULTURE" },
+ { label: "예술/대중문화", value: "ART_POP_CULTURE" },
+ { label: "만화", value: "COMIC" },
+ { label: "장르소설", value: "GENRE_FICTION" },
+ { label: "과학", value: "SCIENCE" },
+ { label: "어린이/청소년", value: "CHILDREN_BOOKS" },
+ { label: "여행", value: "TRAVEL" },
+ { label: "요리", value: "COOKING" },
+ { label: "기타", value: "OTHER" },
];
-export default function CategorySelector() {
+interface Props {
+ selectedCategories?: string[]; // Enum string values from backend
+ onToggle?: (category: string) => void;
+}
+
+export default function CategorySelector({
+ selectedCategories = [],
+ onToggle,
+}: Props) {
return (
@@ -32,18 +40,28 @@ export default function CategorySelector() {
md:w-[386px] md:gap-y-[16px]
xl:w-full xl:grid-cols-5"
>
- {CATEGORIES.map((cat) => (
-
-
- {cat}
-
-
- ))}
+ {CATEGORIES.map((cat) => {
+ const isSelected = selectedCategories.includes(cat.value);
+ return (
+
onToggle?.(cat.value)}
+ className={`flex items-center justify-center gap-[8px] rounded-[400px] border px-[16px] cursor-pointer transition-colors
+ h-[36px] w-full md:h-[44px] md:w-[122px]
+ ${isSelected
+ ? "border-primary-1 bg-primary-1"
+ : "border-Subbrown-3 bg-background hover:bg-white"
+ }`}
+ >
+
+ {cat.label}
+
+
+ );
+ })}
);
diff --git a/src/components/base-ui/Settings/EditProfile/ProfileImageSection.tsx b/src/components/base-ui/Settings/EditProfile/ProfileImageSection.tsx
index 8bfdcad..8e61187 100644
--- a/src/components/base-ui/Settings/EditProfile/ProfileImageSection.tsx
+++ b/src/components/base-ui/Settings/EditProfile/ProfileImageSection.tsx
@@ -3,7 +3,17 @@
import Image from "next/image";
-export default function ProfileImageSection() {
+interface Props {
+ nickname?: string;
+ intro?: string;
+ profileImageUrl?: string;
+}
+
+export default function ProfileImageSection({
+ nickname,
+ intro,
+ profileImageUrl,
+}: Props) {
return (
{/* 이미지 및 정보 영역 */}
@@ -17,7 +27,7 @@ export default function ProfileImageSection() {
h-[100px] w-[100px] xl:h-[138px] xl:w-[138px]"
>
{/* 닉네임 */}
-
_hy_0716
+
+ {nickname || "Loading..."}
+
{/* 소개글 */}
- 이제 다양한 책을 함께 읽고 서로의 생각을 나누는 특별한 시간을
- 시작해보세요. 한 권의 책이 주는 작은 울림이 일상에 큰 변화를
- 가져올지도 모릅니다.
+ {intro || "자기소개를 입력해주세요."}
diff --git a/src/components/base-ui/Settings/Items/SettingsMenuItem.tsx b/src/components/base-ui/Settings/Items/SettingsMenuItem.tsx
index 8250194..89e3afe 100644
--- a/src/components/base-ui/Settings/Items/SettingsMenuItem.tsx
+++ b/src/components/base-ui/Settings/Items/SettingsMenuItem.tsx
@@ -25,10 +25,8 @@ export default function SettingsMenuItem({
flex w-full items-center gap-[10px] rounded-[8px] px-[20px] py-[8px] transition-all duration-200
/* [수정 2] 하드코딩 색상 -> 테마 변수 */
- ${
- isActive
- ? "bg-Subbrown-3" // 기존 bg-[#D2C5B6]
- : "bg-transparent hover:bg-Gray-1" // 기존 hover:bg-[#F2F2F2] (Gray_1이 #EEEEEE로 유사함)
+ ${isActive
+ ? "bg-Subbrown-3" : "bg-transparent hover:bg-Gray-1"
}
`}
>
@@ -36,10 +34,9 @@ export default function SettingsMenuItem({
className={`
body_1_2
/* [수정 3] 텍스트 색상 테마 변수 적용 */
- ${
- isActive
- ? "text-Gray-6" // 기존 text-[#434343]
- : "text-Gray-4 group-hover:text-Gray-6" // 기존 text-[#8D8D8D]
+ ${isActive
+ ? "text-Gray-6"
+ : "text-Gray-4 group-hover:text-Gray-6"
}
`}
>
diff --git a/src/components/layout/BookSelectModal.tsx b/src/components/layout/BookSelectModal.tsx
index e50e8b7..a2cd8e8 100644
--- a/src/components/layout/BookSelectModal.tsx
+++ b/src/components/layout/BookSelectModal.tsx
@@ -83,7 +83,7 @@ export default function BookSelectModal({
return (
{/* 배경 오버레이 - 태블릿/데스크탑에서만 */}
@@ -94,79 +94,77 @@ export default function BookSelectModal({
className="bg-background w-full h-full t:rounded-lg t:shadow-lg t:max-w-[1121px] t:max-h-[748px] t:h-auto overflow-hidden flex flex-col d:px-10 py-0 t:py-6 d:py-6"
onClick={(e) => e.stopPropagation()}
>
-
-
- {/* 검색창 */}
-
-
-
-
-
-
- setSearchValue(e.target.value)}
- onKeyDown={handleKeyDown}
- className="w-full h-full bg-transparent text-Gray-7 subhead_3 t:headline_3 placeholder:text-Gray-4 focus:outline-none pr-10"
- autoFocus
- />
- {searchValue && (
- setSearchValue("")}
- className="absolute right-2.5 flex items-center justify-center shrink-0 z-10 w-5 h-5 t:w-7 t:h-7"
- aria-label="검색어 지우기"
- >
-
-
- )}
-
+ {/* 검색창 */}
+
+
+
+
+
+
+ setSearchValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ className="w-full h-full bg-transparent text-Gray-7 subhead_3 t:headline_3 placeholder:text-Gray-4 focus:outline-none pr-10"
+ autoFocus
+ />
+ {searchValue && (
+ setSearchValue("")}
+ className="absolute right-2.5 flex items-center justify-center shrink-0 z-10 w-5 h-5 t:w-7 t:h-7"
+ aria-label="검색어 지우기"
+ >
+
+
+ )}
+
- {/* 검색 결과 */}
-
-
- 총 {searchResults.length}개의
- 검색결과가 있습니다.
-
-
- {searchResults.map((result) => (
-
- setLikedResults((prev) => ({ ...prev, [result.id]: liked }))
- }
- onPencilClick={() => {
- onSelect(result.id);
- onClose();
- }}
- onCardClick={() => {
- onSelect(result.id);
- onClose();
- }}
- />
- ))}
-
+ {/* 검색 결과 */}
+
+
+ 총 {searchResults.length}개
+ 의 검색결과가 있습니다.
+
+
+ {searchResults.map((result) => (
+
+ setLikedResults((prev) => ({ ...prev, [result.id]: liked }))
+ }
+ onPencilClick={() => {
+ onSelect(result.id);
+ onClose();
+ }}
+ onCardClick={() => {
+ onSelect(result.id);
+ onClose();
+ }}
+ />
+ ))}
+
);
}
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
index fb1ad99..6d8f330 100644
--- a/src/components/layout/Header.tsx
+++ b/src/components/layout/Header.tsx
@@ -32,7 +32,7 @@ export default function Header() {
const [isSearchOpen, setIsSearchOpen] = useState(false);
return (
-
+
{/*로고 + 메뉴*/}
@@ -75,7 +75,7 @@ export default function Header() {
{/*아이콘*/}
-
+
setIsSearchOpen(!isSearchOpen)}
aria-label="검색"
diff --git a/src/components/layout/SearchModal.tsx b/src/components/layout/SearchModal.tsx
index 8c73f50..92f5c0d 100644
--- a/src/components/layout/SearchModal.tsx
+++ b/src/components/layout/SearchModal.tsx
@@ -76,18 +76,18 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
<>
{/* 배경 블러 */}
{/* 모달 */}
-
-
+
+
-
-
+
{/* 오늘의 추천 책 */}
-
-
오늘의 추천 책
-
- {recommendedBooks.slice(0, 4).map((book, index) => (
-
-
- setLikedBooks((prev) => ({ ...prev, [book.id]: liked }))
- }
- />
-
- ))}
+
+
+
오늘의 추천 책
+
+ {recommendedBooks.slice(0, 4).map((book, index) => (
+
+
+ setLikedBooks((prev) => ({ ...prev, [book.id]: liked }))
+ }
+ />
+
+ ))}
+
-
+
= CATEGORIES.reduce(
+ (acc, cat) => {
+ acc[cat.label] = cat.value;
+ return acc;
+ },
+ {} as Record
+);
diff --git a/src/contexts/SignupContext.tsx b/src/contexts/SignupContext.tsx
new file mode 100644
index 0000000..21de40d
--- /dev/null
+++ b/src/contexts/SignupContext.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import React, { createContext, useContext, useState, ReactNode, useCallback, useRef, useMemo } from "react";
+
+interface SignupState {
+ // Terms
+ agreements: Record;
+ // Email
+ email: string;
+ verificationCode: string;
+ isVerified: boolean;
+ timeLeft: number | null;
+ // Password
+ password: string;
+ confirmPassword: string;
+ // Profile
+ nickname: string;
+ intro: string;
+ name: string;
+ phone: string;
+ isNicknameChecked: boolean;
+ // Profile Image & Interests
+ profileImage: string | null;
+ isProfileImageSet: boolean; // Explicit check for whether user set an image or chose default
+ selectedInterests: string[];
+ // Toast
+ toast: { message: string; visible: boolean } | null;
+}
+
+interface SignupActions {
+ setAgreements: (agreements: Record) => void;
+ setEmail: (email: string) => void;
+ setVerificationCode: (code: string) => void;
+ setIsVerified: (verified: boolean) => void;
+ setTimeLeft: (time: number | null) => void;
+ setPassword: (password: string) => void;
+ setConfirmPassword: (password: string) => void;
+ setNickname: (nickname: string) => void;
+ setIntro: (intro: string) => void;
+ setName: (name: string) => void;
+ setPhone: (phone: string) => void;
+ setIsNicknameChecked: (checked: boolean) => void;
+ setProfileImage: (image: string | null) => void;
+ setIsProfileImageSet: (isSet: boolean) => void;
+ setSelectedInterests: (interests: string[]) => void;
+ showToast: (message: string) => void;
+ resetSignup: () => void;
+}
+
+interface SignupContextType extends SignupState, SignupActions { }
+
+const initialState: SignupState = {
+ agreements: {},
+ email: "",
+ verificationCode: "",
+ isVerified: false,
+ timeLeft: null,
+ password: "",
+ confirmPassword: "",
+ nickname: "",
+ intro: "",
+ name: "",
+ phone: "",
+ isNicknameChecked: false,
+ profileImage: "/default_profile_1.svg",
+ isProfileImageSet: false,
+ selectedInterests: [],
+ toast: null,
+};
+
+const SignupContext = createContext(undefined);
+
+export const SignupProvider = ({ children }: { children: ReactNode }) => {
+ const [state, setState] = useState(initialState);
+ const toastTimers = useRef([]);
+
+ const showToast = useCallback((message: string) => {
+ // Clear existing timers
+ toastTimers.current.forEach(clearTimeout);
+ toastTimers.current = [];
+
+ setState((prev) => ({ ...prev, toast: { message, visible: true } }));
+
+ // Fade out after 2.5s
+ const fadeOutTimer = setTimeout(() => {
+ setState((prev) => prev.toast ? { ...prev, toast: { ...prev.toast, visible: false } } : prev);
+ }, 2500);
+
+ // Unmount after 3s
+ const unmountTimer = setTimeout(() => {
+ setState((prev) => ({ ...prev, toast: null }));
+ }, 3000);
+
+ toastTimers.current.push(fadeOutTimer, unmountTimer);
+ }, []);
+
+ const actions = useMemo((): SignupActions => ({
+ setAgreements: (agreements) => setState((prev) => ({ ...prev, agreements })),
+ setEmail: (email) => setState((prev) => ({ ...prev, email })),
+ setVerificationCode: (verificationCode) => setState((prev) => ({ ...prev, verificationCode })),
+ setIsVerified: (isVerified) => setState((prev) => ({ ...prev, isVerified })),
+ setTimeLeft: (timeLeft) => setState((prev) => ({ ...prev, timeLeft })),
+ setPassword: (password) => setState((prev) => ({ ...prev, password })),
+ setConfirmPassword: (confirmPassword) => setState((prev) => ({ ...prev, confirmPassword })),
+ setNickname: (nickname) => setState((prev) => ({ ...prev, nickname })),
+ setIntro: (intro) => setState((prev) => ({ ...prev, intro })),
+ setName: (name) => setState((prev) => ({ ...prev, name })),
+ setPhone: (phone) => setState((prev) => ({ ...prev, phone })),
+ setIsNicknameChecked: (isNicknameChecked) => setState((prev) => ({ ...prev, isNicknameChecked })),
+ setProfileImage: (profileImage) => setState((prev) => ({ ...prev, profileImage })),
+ setIsProfileImageSet: (isProfileImageSet) => setState((prev) => ({ ...prev, isProfileImageSet })),
+ setSelectedInterests: (selectedInterests) => setState((prev) => ({ ...prev, selectedInterests })),
+ showToast,
+ resetSignup: () => setState(initialState),
+ }), [showToast]);
+
+ const contextValue = useMemo(() => ({ ...state, ...actions }), [state, actions]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useSignup = () => {
+ const context = useContext(SignupContext);
+ if (!context) {
+ throw new Error("useSignup must be used within a SignupProvider");
+ }
+ return context;
+};
diff --git a/src/hooks/useAuthGuard.ts b/src/hooks/useAuthGuard.ts
new file mode 100644
index 0000000..f8b5bce
--- /dev/null
+++ b/src/hooks/useAuthGuard.ts
@@ -0,0 +1,29 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useRouter } from "next/navigation";
+import toast from "react-hot-toast";
+import { useAuthStore } from "@/store/useAuthStore";
+
+/**
+ * 전역 인증 상태를 확인하여 비로그인 사용자를 홈으로 리다이렉트하고 로그인 모달을 띄우는 훅입니다.
+ */
+export const useAuthGuard = () => {
+ const { isLoggedIn, isInitialized, openLoginModal } = useAuthStore();
+ const router = useRouter();
+ const toastShownRef = useRef(false);
+
+ useEffect(() => {
+ // 초기화가 끝났을 때만 로그인 여부를 체크하여 리다이렉트합니다.
+ if (isInitialized && !isLoggedIn) {
+ if (!toastShownRef.current) {
+ toast.error("로그인이 필요한 서비스입니다.");
+ toastShownRef.current = true;
+ }
+ openLoginModal();
+ router.push("/");
+ }
+ }, [isInitialized, isLoggedIn, router, openLoginModal]);
+
+ return { isInitialized, isLoggedIn };
+};
diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts
index 344cc58..7b987f8 100644
--- a/src/lib/api/client.ts
+++ b/src/lib/api/client.ts
@@ -1,5 +1,4 @@
-import { API_BASE_URL } from "@/lib/api/endpoints";
-import Cookies from "js-cookie";
+
import { useAuthStore } from "@/store/useAuthStore";
import toast from "react-hot-toast";
import { getErrorMessage } from "./errorMapper";
@@ -21,12 +20,6 @@ async function request(
"Content-Type": "application/json",
};
- // [Security] Token Auto-Injection
- const token = Cookies.get("accessToken");
- if (token) {
- defaultHeaders["Authorization"] = `Bearer ${token}`;
- }
-
// [Utility] Query String Builder
let requestUrl = url;
if (params) {
@@ -45,6 +38,8 @@ async function request(
const config: RequestInit = {
...fetchOptions,
+ // [Security] Include credentials (cookies) for all requests
+ credentials: "include",
headers: {
...defaultHeaders,
...options.headers,
@@ -61,15 +56,14 @@ async function request(
console.warn("Session expired. Logging out...");
useAuthStore.getState().logout();
toast.error("세션이 만료되었습니다. 다시 로그인해주세요.");
- // 여기서 throw를 해서 흐름을 끊어주는 것이 안전할 수 있음
}
+
// [Resilience] Safe JSON Parsing
let data: any;
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
data = await response.json();
} else {
- // JSON이 아닌 경우 (예: 500 HTML 에러 페이지 등)
data = {
isSuccess: false,
message: "서버 응답 형식이 올바르지 않습니다.",
@@ -77,7 +71,6 @@ async function request(
}
// [Standardization] Response Normalization
- // HTTP Status가 200~299가 아니거나, 백엔드 로직상 실패(isSuccess: false)인 경우
if (!response.ok || (data && data.isSuccess === false)) {
const errorCode = data?.code || `HTTP${response.status}`;
const errorMessage =
@@ -85,7 +78,6 @@ async function request(
getErrorMessage(errorCode) ||
"요청 처리 중 오류가 발생했습니다.";
- // 에러 객체를 확장하여 throw
throw new ApiError(errorMessage, errorCode, data);
}
@@ -93,7 +85,6 @@ async function request(
} catch (error) {
clearTimeout(id);
console.error("API Request Error:", error);
- // Timeout Error Handling
if (error instanceof DOMException && error.name === "AbortError") {
toast.error("요청 시간이 초과되었습니다.");
throw new Error("Request timeout");
@@ -105,10 +96,10 @@ async function request(
export const apiClient = {
get: (url: string, options?: RequestOptions) =>
request(url, { ...options, method: "GET" }),
- post: (url: string, body: any, options?: RequestOptions) =>
- request(url, { ...options, method: "POST", body: JSON.stringify(body) }),
- put: (url: string, body: any, options?: RequestOptions) =>
- request(url, { ...options, method: "PUT", body: JSON.stringify(body) }),
+ post: (url: string, body?: any, options?: RequestOptions) =>
+ request(url, { ...options, method: "POST", body: body ? JSON.stringify(body) : undefined }),
+ put: (url: string, body?: any, options?: RequestOptions) =>
+ request(url, { ...options, method: "PUT", body: body ? JSON.stringify(body) : undefined }),
delete: (url: string, options?: RequestOptions) =>
request(url, { ...options, method: "DELETE" }),
};
diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts
index 51418e3..f257306 100644
--- a/src/lib/api/endpoints.ts
+++ b/src/lib/api/endpoints.ts
@@ -1,3 +1,14 @@
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL || "https://api.checkmo.co.kr/api";
-export const LOGIN_URL = `${API_BASE_URL}/auth/login`;
+
+export const AUTH_ENDPOINTS = {
+ LOGIN: `${API_BASE_URL}/auth/login`,
+ SIGNUP: `${API_BASE_URL}/auth/signup`,
+ EMAIL_VERIFICATION: `${API_BASE_URL}/auth/email-verification`,
+ EMAIL_CONFIRM: `${API_BASE_URL}/auth/email-verification/confirm`,
+ LOGOUT: `${API_BASE_URL}/auth/logout`,
+ ADDITIONAL_INFO: `${API_BASE_URL}/members/additional-info`,
+ CHECK_NICKNAME: `${API_BASE_URL}/members/check-nickname`,
+ PROFILE: `${API_BASE_URL}/members/me`,
+ IMAGE_UPLOAD: (type: string) => `${API_BASE_URL}/image/${type}/upload-url`,
+};
diff --git a/src/services/authService.ts b/src/services/authService.ts
index 0e437b4..e513dc5 100644
--- a/src/services/authService.ts
+++ b/src/services/authService.ts
@@ -1,9 +1,81 @@
import { apiClient } from "@/lib/api/client";
-import { LOGIN_URL } from "@/lib/api/endpoints";
-import { LoginForm, LoginResponse } from "@/types/auth";
+import { AUTH_ENDPOINTS, API_BASE_URL } from "@/lib/api/endpoints";
+import { useAuthStore } from "@/store/useAuthStore";
+import {
+ LoginForm,
+ LoginResponse,
+ ApiResponse,
+ EmailVerificationConfirm,
+ AdditionalInfo,
+ SignupForm,
+ User
+} from "@/types/auth";
export const authService = {
login: async (data: LoginForm): Promise => {
- return await apiClient.post(LOGIN_URL, data);
+ return await apiClient.post(AUTH_ENDPOINTS.LOGIN, data);
+ },
+
+ signup: async (data: SignupForm): Promise> => {
+ return await apiClient.post>(AUTH_ENDPOINTS.SIGNUP, data);
+ },
+
+ verifyEmail: async (email: string, type: string = "SIGN_UP"): Promise> => {
+ return await apiClient.post>(
+ AUTH_ENDPOINTS.EMAIL_VERIFICATION,
+ null,
+ { params: { email, type } }
+ );
+ },
+
+ confirmEmail: async (data: EmailVerificationConfirm): Promise> => {
+ return await apiClient.post>(AUTH_ENDPOINTS.EMAIL_CONFIRM, data);
+ },
+
+ checkNickname: async (nickname: string): Promise> => {
+ return await apiClient.post>(AUTH_ENDPOINTS.CHECK_NICKNAME, null, {
+ params: { nickname }
+ });
+ },
+
+ additionalInfo: async (data: AdditionalInfo): Promise> => {
+ return await apiClient.post>(AUTH_ENDPOINTS.ADDITIONAL_INFO, data);
+ },
+
+ getProfile: async (): Promise> => {
+ return await apiClient.get>(AUTH_ENDPOINTS.PROFILE);
+ },
+
+ getPresignedUrl: async (type: "PROFILE" | "CLUB" | "NOTICE", fileName: string, contentType: string): Promise> => {
+ return await apiClient.post>(
+ AUTH_ENDPOINTS.IMAGE_UPLOAD(type),
+ { originalFileName: fileName, contentType }
+ );
+ },
+
+ uploadToS3: async (presignedUrl: string, file: File): Promise => {
+ const response = await fetch(presignedUrl, {
+ method: "PUT",
+ body: file,
+ headers: {
+ "Content-Type": file.type,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error("S3 upload failed");
+ }
+ },
+
+ logout: async () => {
+ try {
+ await apiClient.post(AUTH_ENDPOINTS.LOGOUT);
+ } catch (error) {
+ console.error("Backend logout failed:", error);
+ } finally {
+ // Best effort local cleanup
+ useAuthStore.getState().logout();
+ }
},
};
+
diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts
index 8b75e71..7ec7bab 100644
--- a/src/store/useAuthStore.ts
+++ b/src/store/useAuthStore.ts
@@ -5,16 +5,26 @@ import { User } from "@/types/auth";
interface AuthState {
user: User | null;
isLoggedIn: boolean;
+ isLoginModalOpen: boolean;
+ isInitialized: boolean;
login: (user: User) => void;
logout: () => void;
+ openLoginModal: () => void;
+ closeLoginModal: () => void;
+ setInitialized: (value: boolean) => void;
}
export const useAuthStore = create((set) => ({
user: null,
isLoggedIn: false,
- login: (user) => set({ user, isLoggedIn: true }),
+ isLoginModalOpen: false,
+ isInitialized: false,
+ login: (user) => set({ user, isLoggedIn: true, isLoginModalOpen: false, isInitialized: true }),
logout: () => {
Cookies.remove("accessToken");
- set({ user: null, isLoggedIn: false });
+ set({ user: null, isLoggedIn: false, isInitialized: true });
},
+ openLoginModal: () => set({ isLoginModalOpen: true }),
+ closeLoginModal: () => set({ isLoginModalOpen: false }),
+ setInitialized: (value) => set({ isInitialized: value }),
}));
diff --git a/src/types/auth.ts b/src/types/auth.ts
index 47ff70e..ef77a74 100644
--- a/src/types/auth.ts
+++ b/src/types/auth.ts
@@ -1,5 +1,10 @@
export interface User {
email: string;
+ nickname?: string;
+ description?: string;
+ profileImageUrl?: string;
+ categories?: string[];
+ profileCompleted?: boolean;
}
export interface LoginForm {
@@ -7,11 +12,33 @@ export interface LoginForm {
password: string;
}
-export interface LoginResponse {
+export interface ApiResponse {
isSuccess: boolean;
code: string;
message: string;
- result?: {
- accessToken: string;
- };
+ result?: T;
+}
+
+export interface LoginResponse extends ApiResponse<{
+ email: string;
+ accessToken?: string; // Optional if using cookies
+}> { }
+
+export interface EmailVerificationConfirm {
+ email: string;
+ verificationCode: string;
+}
+
+export interface AdditionalInfo {
+ nickname: string;
+ name: string;
+ phoneNumber: string;
+ description: string;
+ profileImageUrl: string;
+ categories: string[];
+}
+
+export interface SignupForm {
+ email: string;
+ password: string;
}
diff --git a/src/types/groups/grouphome.ts b/src/types/groups/grouphome.ts
index 4c547bf..a0c04ca 100644
--- a/src/types/groups/grouphome.ts
+++ b/src/types/groups/grouphome.ts
@@ -3,6 +3,7 @@ export type ClubCategoryCode =
| 'COMPUTER_IT'
| 'ESSAY'
| 'HISTORY_CULTURE'
+
| string;
export interface ClubCategory {