From 3206cc2ec03b69e3f5c635e6e7f28c87449838f8 Mon Sep 17 00:00:00 2001 From: ca5tlechan Date: Thu, 16 Jul 2026 14:34:59 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(frontend):=20Phase=206=20=ED=94=84?= =?UTF-8?q?=EB=A1=A0=ED=8A=B8=20=EA=B8=B0=EC=B4=88=20=E2=80=94=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=C2=B7=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8C=85=C2=B7=EC=9D=B8=EC=A6=9D=C2=B7=EC=95=B1=20?= =?UTF-8?q?=EC=85=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 디자인 시스템(index.css): 챌린지 네이비+골드 토큰, 라이트/다크, 공통 컴포넌트 - 라우팅(react-router): /login·/signup + 인증 보호된 앱 셸(홈/마이/랭킹/우리학교), 미인증 리다이렉트 - 인증: fetch 클라이언트(JWT localStorage 저장·첨부), AuthProvider(가입→자동로그인, /me 토큰검증), ProtectedRoute - 로그인/회원가입(학교 선택·필드검증), 홈(현재 세션 상태), 나머지 탭 자리표시 - PWA theme-color, lang=ko/title 브라우저 검증: 가입→자동로그인→홈, 탭 라우팅·활성 상태, 학교목록 API 연동. Co-Authored-By: Claude Opus 4.8 --- frontend/index.html | 8 +- frontend/package-lock.json | 60 +++++- frontend/package.json | 3 +- frontend/src/App.css | 184 ------------------ frontend/src/App.tsx | 122 ------------ frontend/src/assets/react.svg | 1 - frontend/src/components/AppLayout.tsx | 40 ++++ frontend/src/components/Placeholder.tsx | 15 ++ frontend/src/components/ProtectedRoute.tsx | 16 ++ frontend/src/index.css | 210 ++++++++++++--------- frontend/src/lib/api.ts | 92 +++++++++ frontend/src/lib/auth.tsx | 58 ++++++ frontend/src/main.tsx | 42 ++++- frontend/src/pages/HomePage.tsx | 52 +++++ frontend/src/pages/LoginPage.tsx | 62 ++++++ frontend/src/pages/MyPage.tsx | 5 + frontend/src/pages/RankingPage.tsx | 5 + frontend/src/pages/SchoolPage.tsx | 5 + frontend/src/pages/SignupPage.tsx | 106 +++++++++++ 19 files changed, 677 insertions(+), 409 deletions(-) delete mode 100644 frontend/src/App.css delete mode 100644 frontend/src/App.tsx delete mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/components/AppLayout.tsx create mode 100644 frontend/src/components/Placeholder.tsx create mode 100644 frontend/src/components/ProtectedRoute.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/auth.tsx create mode 100644 frontend/src/pages/HomePage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/MyPage.tsx create mode 100644 frontend/src/pages/RankingPage.tsx create mode 100644 frontend/src/pages/SchoolPage.tsx create mode 100644 frontend/src/pages/SignupPage.tsx diff --git a/frontend/index.html b/frontend/index.html index 0fca6f0..a397ee5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,12 @@ - + - - frontend + + + + 스터디카페 랭킹
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eec3ac7..31e723c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" }, "devDependencies": { "@types/node": "^24.13.2", @@ -3202,6 +3203,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-js-compat": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", @@ -5229,6 +5243,44 @@ "react": "^19.2.7" } }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5523,6 +5575,12 @@ "node": ">=20.0.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index a64f0c8..2312138 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" }, "devDependencies": { "@types/node": "^24.13.2", diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,184 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index a66b5ef..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' -import './App.css' - -function App() { - const [count, setCount] = useState(0) - - return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) -} - -export default App diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..5173bfc --- /dev/null +++ b/frontend/src/components/AppLayout.tsx @@ -0,0 +1,40 @@ +import { NavLink, Outlet } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +const HomeIcon = ( + +); +const MyIcon = ( + +); +const RankIcon = ( + +); +const SchoolIcon = ( + +); + +const tabs: { to: string; label: string; icon: ReactNode; end?: boolean }[] = [ + { to: '/', label: '홈', icon: HomeIcon, end: true }, + { to: '/my', label: '마이', icon: MyIcon }, + { to: '/ranking', label: '랭킹', icon: RankIcon }, + { to: '/school', label: '우리학교', icon: SchoolIcon }, +]; + +export default function AppLayout() { + return ( +
+
+ +
+ +
+ ); +} diff --git a/frontend/src/components/Placeholder.tsx b/frontend/src/components/Placeholder.tsx new file mode 100644 index 0000000..8a0672b --- /dev/null +++ b/frontend/src/components/Placeholder.tsx @@ -0,0 +1,15 @@ +export default function Placeholder({ title, message }: { title: string; message: string }) { + return ( + <> +
+

{title}

+
+
+
+ {message} +
곧 만나요
+
+
+ + ); +} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..9db2b9d --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,16 @@ +import { type ReactNode } from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../lib/auth'; + +export default function ProtectedRoute({ children }: { children: ReactNode }) { + const { user, ready } = useAuth(); + if (!ready) { + return ( +
+
+
+ ); + } + if (!user) return ; + return <>{children}; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5fb3313..57d8329 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,111 +1,139 @@ +/* ===== 스터디카페 랭킹 — 디자인 시스템 (챌린지 네이비 + 골드) ===== */ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; + --ground:#f1f2f4; --surface:#ffffff; --surface-2:#f3f4f7; + --ink:#182338; --ink-2:#3e4a60; --muted:#6f7788; --faint:#a0a7b4; + --line:#e3e5ea; --line-2:#eef0f3; + --primary:#1c4fa2; --primary-ink:#153c7d; --primary-soft:#e6eefa; + --gold:#e0a219; --gold-ink:#95680d; --gold-soft:#f8ebc7; + --active:#1f9d63; --danger:#c8402f; + --shadow:0 1px 2px rgba(16,28,54,.05), 0 8px 24px -12px rgba(16,28,54,.2); + --radius:20px; --radius-sm:14px; color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } } - @media (prefers-color-scheme: dark) { :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); + --ground:#0d1422; --surface:#151d2e; --surface-2:#1c2740; + --ink:#e9edf4; --ink-2:#bfc8d6; --muted:#8b98ac; --faint:#586377; + --line:#25324a; --line-2:#1e2940; + --primary:#5a90e6; --primary-ink:#83abef; --primary-soft:#1b2b49; + --gold:#efbd4b; --gold-ink:#f3c968; --gold-soft:#2e2611; + --active:#37b87c; --danger:#f0705f; + --shadow:0 1px 2px rgba(0,0,0,.34), 0 14px 32px -14px rgba(0,0,0,.6); } } -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; -} - +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body, #root { height: 100%; } body { - margin: 0; + font-family: -apple-system, "Apple SD Gothic Neo", "Pretendard", system-ui, "Segoe UI", Roboto, sans-serif; + background: var(--ground); + color: var(--ink); + -webkit-font-smoothing: antialiased; + line-height: 1.5; } +.num { font-variant-numeric: tabular-nums lining-nums; font-feature-settings: "tnum" 1, "lnum" 1; } +a { color: inherit; text-decoration: none; } +button { font: inherit; cursor: pointer; } +:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); +/* ===== 모바일 앱 셸 ===== */ +#root { display: flex; justify-content: center; } +.shell { + width: 100%; max-width: 460px; height: 100dvh; overflow: hidden; + background: var(--surface); + display: flex; flex-direction: column; + box-shadow: 0 0 0 1px var(--line); +} +.page { flex: 1; display: flex; flex-direction: column; min-height: 0; } +.app-body { flex: 1; overflow-y: auto; min-height: 0; padding: 4px 20px 24px; } +.topbar { + flex: 0 0 auto; background: var(--surface); + display: flex; align-items: center; justify-content: space-between; + padding: 16px 20px 12px; } +.topbar h1 { font-size: 22px; font-weight: 700; letter-spacing: -.02em; } +.topbar .hi { font-size: 12.5px; color: var(--muted); } +.topbar .sub { font-size: 12px; color: var(--faint); font-weight: 600; } -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } +/* ===== 하단 탭 ===== */ +.bottom-nav { + flex: 0 0 auto; + display: flex; border-top: 1px solid var(--line); background: var(--surface); + padding: 9px 6px calc(12px + env(safe-area-inset-bottom, 0px)); } -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } +.bottom-nav a { + flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; + font-size: 10.5px; font-weight: 600; color: var(--faint); } -p { - margin: 0; +.bottom-nav a svg { width: 22px; height: 22px; } +.bottom-nav a.active { color: var(--primary-ink); } + +/* ===== 카드 / 타일 ===== */ +.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; } +.card + .card { margin-top: 12px; } +.card.sub { background: var(--surface-2); border-color: transparent; } +.lbl { font-size: 10.5px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); } + +/* ===== 버튼 ===== */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + padding: 14px 18px; border-radius: var(--radius-sm); font-size: 15.5px; font-weight: 670; + border: none; background: var(--primary); color: #fff; transition: filter .12s, opacity .12s; } +.btn svg { width: 18px; height: 18px; } +.btn.full { width: 100%; } +.btn:hover { filter: brightness(1.06); } +.btn:active { filter: brightness(.94); } +.btn:disabled { opacity: .55; cursor: default; filter: none; } +.btn.ghost { background: var(--surface-2); color: var(--ink-2); } +.btn.gold { background: var(--gold); color: #3a2a06; } -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); +/* ===== 폼 ===== */ +.field { display: flex; flex-direction: column; gap: 7px; } +.field + .field { margin-top: 16px; } +.field label { font-size: 12.5px; font-weight: 640; color: var(--ink-2); } +.input, .select { + width: 100%; padding: 13px 14px; border-radius: 12px; font-size: 15px; + border: 1.5px solid var(--line); background: var(--surface); color: var(--ink); + transition: border-color .12s; } +.input::placeholder { color: var(--faint); } +.input:focus, .select:focus { outline: none; border-color: var(--primary); } +.input.err, .select.err { border-color: var(--danger); } +.hint-txt { font-size: 11.5px; color: var(--muted); } +.err-txt { font-size: 12px; color: var(--danger); font-weight: 600; } -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +/* ===== Auth 화면 ===== */ +.auth { display: flex; flex-direction: column; min-height: 100dvh; padding: 40px 26px 32px; justify-content: center; } +.auth .brand { display: flex; align-items: center; gap: 11px; margin-bottom: 8px; } +.auth .brand .logo { + width: 40px; height: 40px; border-radius: 12px; background: var(--primary); color: #fff; + display: grid; place-items: center; } +.auth .brand .logo svg { width: 22px; height: 22px; } +.auth .brand b { font-size: 17px; font-weight: 660; letter-spacing: -.01em; } +.auth h2 { font-size: 26px; font-weight: 730; letter-spacing: -.025em; margin: 20px 0 6px; } +.auth .lead { color: var(--muted); font-size: 14px; margin-bottom: 26px; } +.auth form { display: flex; flex-direction: column; } +.auth .actions { margin-top: 24px; display: flex; flex-direction: column; gap: 14px; } +.auth .switch { text-align: center; font-size: 13.5px; color: var(--muted); } +.auth .switch a { color: var(--primary-ink); font-weight: 650; } +.banner { + display: flex; gap: 8px; align-items: flex-start; font-size: 13px; font-weight: 600; + padding: 12px 14px; border-radius: 12px; background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); margin-bottom: 18px; +} + +/* ===== 상태/공용 ===== */ +.pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; padding: 5px 11px; border-radius: 999px; } +.pill.studying { background: var(--gold-soft); color: var(--gold-ink); } +.pill.idle { background: var(--surface-2); color: var(--muted); } +.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.dot.live { animation: pulse 2.4s ease-in-out infinite; } +@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.8); } } +.center-msg { text-align: center; color: var(--muted); padding: 48px 20px; font-size: 14px; } +.spinner { width: 22px; height: 22px; border: 2.5px solid var(--line); border-top-color: var(--primary); border-radius: 50%; animation: spin .7s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.loading-screen { min-height: 100dvh; display: grid; place-items: center; } +.soon { color: var(--faint); font-size: 13px; margin-top: 6px; } +@media (prefers-reduced-motion: reduce) { .dot.live, .spinner { animation: none; } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..fa627fe --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,92 @@ +// ===== 백엔드 API 클라이언트 + 타입 ===== + +export interface School { + id: number; + name: string; + shortName: string | null; +} + +export interface User { + id: number; + loginId: string; + displayName: string; + nameSeq: number; + schoolId: number | null; + schoolName: string | null; +} + +export interface LoginResponse { + token: string; + tokenType: string; + user: User; +} + +export interface CurrentSession { + active: boolean; + sessionId: number | null; + checkInAt: string | null; + cafeName: string | null; +} + +export interface SignupInput { + loginId: string; + password: string; + displayName: string; + schoolId: number | null; +} + +export interface ApiErrorBody { + status?: number; + error?: string; + message?: string; + fieldErrors?: Record; +} + +const TOKEN_KEY = 'scr.token'; +export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY); +export const setToken = (token: string | null): void => { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); +}; + +export class ApiError extends Error { + status: number; + body: ApiErrorBody | null; + constructor(status: number, body: ApiErrorBody | null) { + super(body?.message ?? `요청에 실패했어요 (${status})`); + this.status = status; + this.body = body; + } +} + +async function request(path: string, options: RequestInit = {}): Promise { + const token = getToken(); + const res = await fetch('/api' + path, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }, + }); + const text = await res.text(); + const body = text ? JSON.parse(text) : null; + if (!res.ok) throw new ApiError(res.status, body); + return body as T; +} + +export const authApi = { + login: (loginId: string, password: string) => + request('/auth/login', { + method: 'POST', + body: JSON.stringify({ loginId, password }), + }), + signup: (input: SignupInput) => + request('/auth/signup', { method: 'POST', body: JSON.stringify(input) }), + me: () => request('/users/me'), + schools: () => request('/schools'), +}; + +export const sessionApi = { + current: () => request('/sessions/current'), +}; diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx new file mode 100644 index 0000000..2f458a5 --- /dev/null +++ b/frontend/src/lib/auth.tsx @@ -0,0 +1,58 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import { authApi, getToken, setToken, type SignupInput, type User } from './api'; + +interface AuthContextValue { + user: User | null; + ready: boolean; // 초기 토큰 검증 완료 여부 + login: (loginId: string, password: string) => Promise; + signup: (input: SignupInput) => Promise; + logout: () => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [ready, setReady] = useState(false); + + useEffect(() => { + if (!getToken()) { + setReady(true); + return; + } + authApi + .me() + .then(setUser) + .catch(() => setToken(null)) // 만료/무효 토큰 정리 + .finally(() => setReady(true)); + }, []); + + const login = async (loginId: string, password: string) => { + const res = await authApi.login(loginId, password); + setToken(res.token); + setUser(res.user); + }; + + const signup = async (input: SignupInput) => { + await authApi.signup(input); + await login(input.loginId, input.password); // 가입 직후 자동 로그인 + }; + + const logout = () => { + setToken(null); + setUser(null); + }; + + return ( + + {children} + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth는 AuthProvider 안에서만 사용할 수 있어요.'); + return ctx; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..5f5a013 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,40 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'; +import './index.css'; +import { AuthProvider } from './lib/auth'; +import ProtectedRoute from './components/ProtectedRoute'; +import AppLayout from './components/AppLayout'; +import LoginPage from './pages/LoginPage'; +import SignupPage from './pages/SignupPage'; +import HomePage from './pages/HomePage'; +import MyPage from './pages/MyPage'; +import RankingPage from './pages/RankingPage'; +import SchoolPage from './pages/SchoolPage'; + +const router = createBrowserRouter([ + { path: '/login', element: }, + { path: '/signup', element: }, + { + element: ( + + + + ), + children: [ + { path: '/', element: }, + { path: '/my', element: }, + { path: '/ranking', element: }, + { path: '/school', element: }, + ], + }, + { path: '*', element: }, +]); createRoot(document.getElementById('root')!).render( - + + + , -) +); diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..1aa2b57 --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; +import { useAuth } from '../lib/auth'; +import { sessionApi, type CurrentSession } from '../lib/api'; + +function fmtTime(iso: string): string { + return new Date(iso).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false }); +} + +export default function HomePage() { + const { user, logout } = useAuth(); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + sessionApi.current().then(setSession).catch(() => { /* 표시만 생략 */ }).finally(() => setLoading(false)); + }, []); + + return ( + <> +
+
+
안녕하세요,
+

{user?.displayName}님

+
+ +
+ +
+ {loading ? ( +
불러오는 중…
+ ) : session?.active ? ( +
+ 공부 중 +
+ {session.cafeName}에서 공부하고 있어요 + {session.checkInAt && <> · {fmtTime(session.checkInAt)} 시작} +
+

QR 체크아웃 화면은 다음 업데이트에서 추가돼요.

+
+ ) : ( +
+ 대기 중 +
지금은 진행 중인 공부 기록이 없어요.
+

QR로 체크인하는 화면은 다음 업데이트에서 추가돼요.

+
+ )} +
+ + ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..6c25c74 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,62 @@ +import { useState, type FormEvent } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuth } from '../lib/auth'; +import { ApiError } from '../lib/api'; + +const Logo = ( + +); + +export default function LoginPage() { + const { login } = useAuth(); + const navigate = useNavigate(); + const [loginId, setLoginId] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setBusy(true); + try { + await login(loginId.trim(), password); + navigate('/', { replace: true }); + } catch (err) { + setError(err instanceof ApiError ? err.message : '로그인에 실패했어요. 다시 시도해 주세요.'); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ {Logo} + 스터디카페 랭킹 +
+

다시 오셨네요

+

아이디로 로그인하고 오늘도 기록을 쌓아요.

+ +
+ {error &&
{error}
} +
+ + setLoginId(e.target.value)} placeholder="아이디" /> +
+
+ + setPassword(e.target.value)} placeholder="비밀번호" /> +
+
+ +

아직 계정이 없나요? 회원가입

+
+
+
+ ); +} diff --git a/frontend/src/pages/MyPage.tsx b/frontend/src/pages/MyPage.tsx new file mode 100644 index 0000000..627820a --- /dev/null +++ b/frontend/src/pages/MyPage.tsx @@ -0,0 +1,5 @@ +import Placeholder from '../components/Placeholder'; + +export default function MyPage() { + return ; +} diff --git a/frontend/src/pages/RankingPage.tsx b/frontend/src/pages/RankingPage.tsx new file mode 100644 index 0000000..f20414d --- /dev/null +++ b/frontend/src/pages/RankingPage.tsx @@ -0,0 +1,5 @@ +import Placeholder from '../components/Placeholder'; + +export default function RankingPage() { + return ; +} diff --git a/frontend/src/pages/SchoolPage.tsx b/frontend/src/pages/SchoolPage.tsx new file mode 100644 index 0000000..c9ac0e3 --- /dev/null +++ b/frontend/src/pages/SchoolPage.tsx @@ -0,0 +1,5 @@ +import Placeholder from '../components/Placeholder'; + +export default function SchoolPage() { + return ; +} diff --git a/frontend/src/pages/SignupPage.tsx b/frontend/src/pages/SignupPage.tsx new file mode 100644 index 0000000..8a7555f --- /dev/null +++ b/frontend/src/pages/SignupPage.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuth } from '../lib/auth'; +import { ApiError, authApi, type School } from '../lib/api'; + +const Logo = ( + +); + +export default function SignupPage() { + const { signup } = useAuth(); + const navigate = useNavigate(); + const [schools, setSchools] = useState([]); + const [loginId, setLoginId] = useState(''); + const [password, setPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [schoolId, setSchoolId] = useState(''); // '' = 무소속 + const [error, setError] = useState(''); + const [fieldErrors, setFieldErrors] = useState>({}); + const [busy, setBusy] = useState(false); + + useEffect(() => { + authApi.schools().then(setSchools).catch(() => { /* 선택 목록 없이도 무소속 가입 가능 */ }); + }, []); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setFieldErrors({}); + setBusy(true); + try { + await signup({ + loginId: loginId.trim(), + password, + displayName: displayName.trim(), + schoolId: schoolId ? Number(schoolId) : null, + }); + navigate('/', { replace: true }); + } catch (err) { + if (err instanceof ApiError) { + setFieldErrors(err.body?.fieldErrors ?? {}); + setError(err.body?.fieldErrors ? '입력값을 확인해 주세요.' : err.message); + } else { + setError('회원가입에 실패했어요. 다시 시도해 주세요.'); + } + } finally { + setBusy(false); + } + }; + + return ( +
+
+ {Logo} + 스터디카페 랭킹 +
+

시작하기

+

몇 가지만 입력하면 바로 기록을 시작할 수 있어요.

+ +
+ {error &&
{error}
} + +
+ + setLoginId(e.target.value)} placeholder="3~30자" /> + {fieldErrors.loginId && {fieldErrors.loginId}} +
+ +
+ + setPassword(e.target.value)} placeholder="8자 이상" /> + {fieldErrors.password && {fieldErrors.password}} +
+ +
+ + setDisplayName(e.target.value)} placeholder="예: 김민현" /> + {fieldErrors.displayName + ? {fieldErrors.displayName} + : 랭킹에는 김O현처럼 가운데를 가려 표시돼요.} +
+ +
+ + + 목록에 없으면 무소속을 선택하세요. 학교 랭킹에만 영향을 줘요. +
+ +
+ +

이미 계정이 있나요? 로그인

+
+
+
+ ); +} From d13403bb4033854ca5a625fe2b8e2d86db9cf8f8 Mon Sep 17 00:00:00 2001 From: ca5tlechan Date: Thu, 16 Jul 2026 21:47:31 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(frontend):=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=E2=80=94=20=EB=8B=A4=ED=81=AC=20=EB=8C=80=EB=B9=84=C2=B7?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=A7=8C=EB=A3=8C=20=EC=A4=91=EC=95=99=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=C2=B7=EC=97=90=EB=9F=AC=20=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 다크모드 대비: --on-primary 토큰 추가. 밝은 파랑(#5a90e6) 위 흰색은 3.2:1로 WCAG AA 미달이라 어두운 전경색(#0d1422, 5.7:1)을 쓴다. .btn / .auth .logo 적용 - 인증 만료 중앙 처리: api.ts에 setUnauthorizedHandler 추가. 토큰을 실어 보낸 요청이 401/403이면 토큰을 지우고 AuthProvider가 user를 비운다. (로그인 실패의 401은 토큰 없이 보낸 요청이라 해당 없음) - 가입 성공 ≠ 자동 로그인 실패 구분: AutoLoginFailedError를 별도 모듈로 분리. 계정이 이미 만들어졌으므로 재가입이 아니라 로그인으로 안내하고 제출을 막는다 - 부팅 실패 구분: 네트워크/5xx는 토큰을 유지한 채 loadError + 다시 시도, 401/403만 로그아웃 처리. ProtectedRoute가 로그인으로 튕기지 않는다 - 홈/회원가입 에러 노출: 실패를 '기록 없음'·'무소속 가입'으로 조용히 삼키지 않고 안내 + 다시 시도 제공 - .dot 색을 currentcolor로 바꿔 pill 색과 항상 일치시킴 Co-Authored-By: Claude Opus 4.8 --- frontend/src/components/ProtectedRoute.tsx | 18 ++++++- frontend/src/index.css | 13 +++-- frontend/src/lib/api.ts | 14 +++++ frontend/src/lib/auth.tsx | 60 +++++++++++++++++----- frontend/src/lib/authErrors.ts | 7 +++ frontend/src/pages/HomePage.tsx | 33 ++++++++++-- frontend/src/pages/SignupPage.tsx | 45 +++++++++++++--- 7 files changed, 161 insertions(+), 29 deletions(-) create mode 100644 frontend/src/lib/authErrors.ts diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index 9db2b9d..f705fd4 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -3,7 +3,8 @@ import { Navigate } from 'react-router-dom'; import { useAuth } from '../lib/auth'; export default function ProtectedRoute({ children }: { children: ReactNode }) { - const { user, ready } = useAuth(); + const { user, ready, loadError, retry } = useAuth(); + if (!ready) { return (
@@ -11,6 +12,21 @@ export default function ProtectedRoute({ children }: { children: ReactNode }) {
); } + + // 네트워크/서버 오류 — 토큰이 아직 유효할 수 있으니 로그인으로 내보내지 않고 재시도를 준다. + if (loadError) { + return ( +
+
+ 연결에 문제가 있어 정보를 불러오지 못했어요. +
+ +
+
+
+ ); + } + if (!user) return ; return <>{children}; } diff --git a/frontend/src/index.css b/frontend/src/index.css index 57d8329..f472c63 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -4,6 +4,7 @@ --ink:#182338; --ink-2:#3e4a60; --muted:#6f7788; --faint:#a0a7b4; --line:#e3e5ea; --line-2:#eef0f3; --primary:#1c4fa2; --primary-ink:#153c7d; --primary-soft:#e6eefa; + --on-primary:#ffffff; /* --primary 배경 위 전경색 (대비 8:1) */ --gold:#e0a219; --gold-ink:#95680d; --gold-soft:#f8ebc7; --active:#1f9d63; --danger:#c8402f; --shadow:0 1px 2px rgba(16,28,54,.05), 0 8px 24px -12px rgba(16,28,54,.2); @@ -16,6 +17,7 @@ --ink:#e9edf4; --ink-2:#bfc8d6; --muted:#8b98ac; --faint:#586377; --line:#25324a; --line-2:#1e2940; --primary:#5a90e6; --primary-ink:#83abef; --primary-soft:#1b2b49; + --on-primary:#0d1422; /* 밝은 파랑 위엔 흰색(3.2:1)이 부족 → 어두운 전경색(5.7:1) */ --gold:#efbd4b; --gold-ink:#f3c968; --gold-soft:#2e2611; --active:#37b87c; --danger:#f0705f; --shadow:0 1px 2px rgba(0,0,0,.34), 0 14px 32px -14px rgba(0,0,0,.6); @@ -78,7 +80,7 @@ button { font: inherit; cursor: pointer; } .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 14px 18px; border-radius: var(--radius-sm); font-size: 15.5px; font-weight: 670; - border: none; background: var(--primary); color: #fff; transition: filter .12s, opacity .12s; + border: none; background: var(--primary); color: var(--on-primary); transition: filter .12s, opacity .12s; } .btn svg { width: 18px; height: 18px; } .btn.full { width: 100%; } @@ -107,7 +109,7 @@ button { font: inherit; cursor: pointer; } .auth { display: flex; flex-direction: column; min-height: 100dvh; padding: 40px 26px 32px; justify-content: center; } .auth .brand { display: flex; align-items: center; gap: 11px; margin-bottom: 8px; } .auth .brand .logo { - width: 40px; height: 40px; border-radius: 12px; background: var(--primary); color: #fff; + width: 40px; height: 40px; border-radius: 12px; background: var(--primary); color: var(--on-primary); display: grid; place-items: center; } .auth .brand .logo svg { width: 22px; height: 22px; } @@ -123,12 +125,17 @@ button { font: inherit; cursor: pointer; } padding: 12px 14px; border-radius: 12px; background: color-mix(in srgb, var(--danger) 12%, transparent); color: var(--danger); margin-bottom: 18px; } +.banner.info { background: var(--primary-soft); color: var(--primary-ink); } +.link-btn { + background: none; border: none; padding: 0; font-size: inherit; font-weight: 700; + color: var(--primary-ink); text-decoration: underline; +} /* ===== 상태/공용 ===== */ .pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; padding: 5px 11px; border-radius: 999px; } .pill.studying { background: var(--gold-soft); color: var(--gold-ink); } .pill.idle { background: var(--surface-2); color: var(--muted); } -.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.dot { width: 7px; height: 7px; border-radius: 50%; background: currentcolor; } .dot.live { animation: pulse 2.4s ease-in-out infinite; } @keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.8); } } .center-msg { text-align: center; color: var(--muted); padding: 48px 20px; font-size: 14px; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fa627fe..2a0bf88 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -49,6 +49,14 @@ export const setToken = (token: string | null): void => { else localStorage.removeItem(TOKEN_KEY); }; +type UnauthorizedHandler = () => void; +let unauthorizedHandler: UnauthorizedHandler | null = null; + +/** 인증 만료(401/403)를 앱 전역에서 한 번에 처리하기 위한 훅. AuthProvider 가 등록한다. */ +export const setUnauthorizedHandler = (handler: UnauthorizedHandler | null): void => { + unauthorizedHandler = handler; +}; + export class ApiError extends Error { status: number; body: ApiErrorBody | null; @@ -69,6 +77,12 @@ async function request(path: string, options: RequestInit = {}): Promise { ...options.headers, }, }); + // 토큰을 실어 보낸 요청이 401/403이면 세션 만료로 간주하고 인증 상태를 정리한다. + // (로그인 실패의 401은 토큰 없이 보낸 요청이라 여기 해당하지 않는다.) + if (token && (res.status === 401 || res.status === 403)) { + setToken(null); + unauthorizedHandler?.(); + } const text = await res.text(); const body = text ? JSON.parse(text) : null; if (!res.ok) throw new ApiError(res.status, body); diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx index 2f458a5..d45df7a 100644 --- a/frontend/src/lib/auth.tsx +++ b/frontend/src/lib/auth.tsx @@ -1,12 +1,18 @@ -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; -import { authApi, getToken, setToken, type SignupInput, type User } from './api'; +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; +import { + ApiError, authApi, getToken, setToken, setUnauthorizedHandler, + type SignupInput, type User, +} from './api'; +import { AutoLoginFailedError } from './authErrors'; interface AuthContextValue { user: User | null; - ready: boolean; // 초기 토큰 검증 완료 여부 + ready: boolean; // 초기 토큰 검증 완료 여부 + loadError: boolean; // 네트워크/서버 오류로 검증 실패 (토큰 유지, 재시도 가능) login: (loginId: string, password: string) => Promise; signup: (input: SignupInput) => Promise; logout: () => void; + retry: () => void; } const AuthContext = createContext(null); @@ -14,37 +20,67 @@ const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [ready, setReady] = useState(false); + const [loadError, setLoadError] = useState(false); - useEffect(() => { + const bootstrap = useCallback(async () => { if (!getToken()) { + setUser(null); + setLoadError(false); setReady(true); return; } - authApi - .me() - .then(setUser) - .catch(() => setToken(null)) // 만료/무효 토큰 정리 - .finally(() => setReady(true)); + setReady(false); + setLoadError(false); + try { + setUser(await authApi.me()); + } catch (err) { + if (err instanceof ApiError && (err.status === 401 || err.status === 403)) { + setToken(null); // 만료/무효 토큰만 정리 → 로그인 화면으로 + setUser(null); + } else { + setLoadError(true); // 네트워크/5xx 는 토큰 유지하고 재시도 가능하게 + } + } finally { + setReady(true); + } + }, []); + + useEffect(() => { + void bootstrap(); + }, [bootstrap]); + + // 인증 만료를 한 곳에서: api 계층이 401/403에서 토큰을 지우고, 여기서 사용자 상태를 비운다. + useEffect(() => { + setUnauthorizedHandler(() => setUser(null)); + return () => setUnauthorizedHandler(null); }, []); const login = async (loginId: string, password: string) => { const res = await authApi.login(loginId, password); setToken(res.token); setUser(res.user); + setLoadError(false); }; const signup = async (input: SignupInput) => { - await authApi.signup(input); - await login(input.loginId, input.password); // 가입 직후 자동 로그인 + await authApi.signup(input); // 여기서 실패하면 '가입 실패'로 그대로 전파 + try { + await login(input.loginId, input.password); + } catch { + throw new AutoLoginFailedError(); // 계정은 만들어졌으므로 재가입을 유도하면 안 된다 + } }; const logout = () => { setToken(null); setUser(null); + setLoadError(false); }; return ( - + void bootstrap() }} + > {children} ); diff --git a/frontend/src/lib/authErrors.ts b/frontend/src/lib/authErrors.ts new file mode 100644 index 0000000..32321e2 --- /dev/null +++ b/frontend/src/lib/authErrors.ts @@ -0,0 +1,7 @@ +/** 가입은 성공했는데 자동 로그인만 실패한 경우 — 재가입이 아니라 로그인으로 안내해야 한다. */ +export class AutoLoginFailedError extends Error { + constructor() { + super('가입은 완료됐어요. 아래 로그인에서 로그인해 주세요.'); + this.name = 'AutoLoginFailedError'; + } +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 1aa2b57..956427c 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useAuth } from '../lib/auth'; import { sessionApi, type CurrentSession } from '../lib/api'; @@ -6,15 +6,32 @@ function fmtTime(iso: string): string { return new Date(iso).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false }); } +const cardStyle = { display: 'flex', flexDirection: 'column', gap: 12 } as const; + export default function HomePage() { const { user, logout } = useAuth(); const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); - useEffect(() => { - sessionApi.current().then(setSession).catch(() => { /* 표시만 생략 */ }).finally(() => setLoading(false)); + const load = useCallback(async () => { + setLoading(true); + setFailed(false); + try { + setSession(await sessionApi.current()); + } catch { + // 실패를 '기록 없음'으로 숨기지 않는다 — 구분해서 재시도를 준다. + setFailed(true); + setSession(null); + } finally { + setLoading(false); + } }, []); + useEffect(() => { + void load(); + }, [load]); + return ( <>
@@ -30,8 +47,14 @@ export default function HomePage() {
{loading ? (
불러오는 중…
+ ) : failed ? ( +
+ 불러오지 못함 +
공부 상태를 불러오지 못했어요.
+ +
) : session?.active ? ( -
+
공부 중
{session.cafeName}에서 공부하고 있어요 @@ -40,7 +63,7 @@ export default function HomePage() {

QR 체크아웃 화면은 다음 업데이트에서 추가돼요.

) : ( -
+
대기 중
지금은 진행 중인 공부 기록이 없어요.

QR로 체크인하는 화면은 다음 업데이트에서 추가돼요.

diff --git a/frontend/src/pages/SignupPage.tsx b/frontend/src/pages/SignupPage.tsx index 8a7555f..a5ad86f 100644 --- a/frontend/src/pages/SignupPage.tsx +++ b/frontend/src/pages/SignupPage.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState, type FormEvent } from 'react'; +import { useCallback, useEffect, useState, type FormEvent } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../lib/auth'; +import { AutoLoginFailedError } from '../lib/authErrors'; import { ApiError, authApi, type School } from '../lib/api'; const Logo = ( @@ -11,21 +12,33 @@ export default function SignupPage() { const { signup } = useAuth(); const navigate = useNavigate(); const [schools, setSchools] = useState([]); + const [schoolsFailed, setSchoolsFailed] = useState(false); const [loginId, setLoginId] = useState(''); const [password, setPassword] = useState(''); const [displayName, setDisplayName] = useState(''); const [schoolId, setSchoolId] = useState(''); // '' = 무소속 const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); // 가입 성공 + 자동 로그인만 실패 const [fieldErrors, setFieldErrors] = useState>({}); const [busy, setBusy] = useState(false); - useEffect(() => { - authApi.schools().then(setSchools).catch(() => { /* 선택 목록 없이도 무소속 가입 가능 */ }); + const loadSchools = useCallback(async () => { + setSchoolsFailed(false); + try { + setSchools(await authApi.schools()); + } catch { + setSchoolsFailed(true); // 조용히 삼키지 않고 알린다 (무소속 가입은 계속 가능) + } }, []); + useEffect(() => { + void loadSchools(); + }, [loadSchools]); + const submit = async (e: FormEvent) => { e.preventDefault(); setError(''); + setNotice(''); setFieldErrors({}); setBusy(true); try { @@ -37,7 +50,9 @@ export default function SignupPage() { }); navigate('/', { replace: true }); } catch (err) { - if (err instanceof ApiError) { + if (err instanceof AutoLoginFailedError) { + setNotice(err.message); // 계정은 만들어졌으니 재가입이 아니라 로그인으로 안내 + } else if (err instanceof ApiError) { setFieldErrors(err.body?.fieldErrors ?? {}); setError(err.body?.fieldErrors ? '입력값을 확인해 주세요.' : err.message); } else { @@ -48,6 +63,8 @@ export default function SignupPage() { } }; + const done = Boolean(notice); // 가입 완료됨 → 다시 제출하면 중복 오류 + return (
@@ -59,6 +76,7 @@ export default function SignupPage() {
{error &&
{error}
} + {notice &&
{notice}
}
@@ -85,20 +103,31 @@ export default function SignupPage() {
- setSchoolId(e.target.value)} + disabled={schoolsFailed}> {schools.map((s) => ( ))} - 목록에 없으면 무소속을 선택하세요. 학교 랭킹에만 영향을 줘요. + {schoolsFailed ? ( + + 학교 목록을 불러오지 못했어요. 무소속으로는 가입할 수 있어요.{' '} + + + ) : ( + 목록에 없으면 무소속을 선택하세요. 학교 랭킹에만 영향을 줘요. + )}
- -

이미 계정이 있나요? 로그인

+

+ {done ? '가입이 끝났어요. ' : '이미 계정이 있나요? '} + 로그인 +

From d2dfcb9048d8944034ca95f08ea0f820e4721979 Mon Sep 17 00:00:00 2001 From: ca5tlechan Date: Thu, 16 Jul 2026 23:03:35 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(frontend):=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B2=BD=EC=9F=81=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=E2=80=94=20=EB=8A=A6=EA=B2=8C=20=EC=98=A8?= =?UTF-8?q?=20401=EC=9D=B4=20=EC=83=88=20=EC=84=B8=EC=85=98=EC=9D=84=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EC=A7=80=20=EC=95=8A=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 만료 토큰이 남은 채 /login 에 들어오면 부트스트랩의 me() 가 백그라운드로 떠 있는데, 그 401이 도착하기 전에 로그인에 성공하면 뒤늦은 401이 방금 발급받은 토큰을 지우고 로그아웃시켰다. (재현 확인: 로그인 직후 user=null, token=null) - api.ts: 보낸 토큰이 아직 현재 토큰일 때만(getToken() === token) 토큰을 정리하고 unauthorizedHandler 를 호출한다. 경쟁이 없는 정상 만료 처리는 그대로 동작 - auth.tsx: 부트스트랩에 세대(generation) 번호를 둬, 늦게 도착한 검증 결과가 setUser/setLoadError/setReady 를 덮어쓰지 못하게 한다. 로그인·로그아웃·만료 처리는 세대를 올려 진행 중인 검증을 무효화하고 ready 를 직접 확정한다 (retry 연타로 부트스트랩이 겹쳐도 마지막 것만 반영됨) - 401/403 시 토큰 정리 주체를 api 계층 하나로 통일 (bootstrap 의 중복 setToken 제거) Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/api.ts | 4 +++- frontend/src/lib/auth.tsx | 37 ++++++++++++++++++++++++++++++------- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2a0bf88..d50ab06 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -79,7 +79,9 @@ async function request(path: string, options: RequestInit = {}): Promise { }); // 토큰을 실어 보낸 요청이 401/403이면 세션 만료로 간주하고 인증 상태를 정리한다. // (로그인 실패의 401은 토큰 없이 보낸 요청이라 여기 해당하지 않는다.) - if (token && (res.status === 401 || res.status === 403)) { + // 응답이 늦게 오는 사이 다른 토큰으로 로그인했을 수 있으므로, 보낸 토큰이 아직 그대로일 때만 정리한다. + // 그러지 않으면 만료 토큰의 뒤늦은 401이 방금 발급받은 세션을 지운다. + if (token && (res.status === 401 || res.status === 403) && getToken() === token) { setToken(null); unauthorizedHandler?.(); } diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx index d45df7a..0a67b18 100644 --- a/frontend/src/lib/auth.tsx +++ b/frontend/src/lib/auth.tsx @@ -1,4 +1,6 @@ -import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; +import { + createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode, +} from 'react'; import { ApiError, authApi, getToken, setToken, setUnauthorizedHandler, type SignupInput, type User, @@ -22,7 +24,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [ready, setReady] = useState(false); const [loadError, setLoadError] = useState(false); + // 진행 중인 부트스트랩의 세대 번호. 로그인/로그아웃/만료로 인증 상태가 확정되면 + // 번호를 올려, 늦게 도착한 검증 결과가 확정된 상태를 덮어쓰지 못하게 한다. + const bootstrapSeq = useRef(0); + const invalidateBootstrap = useCallback(() => { + bootstrapSeq.current += 1; + }, []); + const bootstrap = useCallback(async () => { + const seq = bootstrapSeq.current + 1; + bootstrapSeq.current = seq; + const stale = () => bootstrapSeq.current !== seq; + if (!getToken()) { setUser(null); setLoadError(false); @@ -32,16 +45,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { setReady(false); setLoadError(false); try { - setUser(await authApi.me()); + const me = await authApi.me(); + if (stale()) return; + setUser(me); } catch (err) { + if (stale()) return; if (err instanceof ApiError && (err.status === 401 || err.status === 403)) { - setToken(null); // 만료/무효 토큰만 정리 → 로그인 화면으로 - setUser(null); + setUser(null); // 만료/무효 토큰 정리는 api 계층이 이미 했다 → 로그인 화면으로 } else { setLoadError(true); // 네트워크/5xx 는 토큰 유지하고 재시도 가능하게 } } finally { - setReady(true); + if (!stale()) setReady(true); } }, []); @@ -51,15 +66,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { // 인증 만료를 한 곳에서: api 계층이 401/403에서 토큰을 지우고, 여기서 사용자 상태를 비운다. useEffect(() => { - setUnauthorizedHandler(() => setUser(null)); + setUnauthorizedHandler(() => { + invalidateBootstrap(); + setUser(null); + setReady(true); + }); return () => setUnauthorizedHandler(null); - }, []); + }, [invalidateBootstrap]); const login = async (loginId: string, password: string) => { const res = await authApi.login(loginId, password); + invalidateBootstrap(); // 진행 중이던 이전 토큰 검증이 새 세션을 덮어쓰지 않도록 setToken(res.token); setUser(res.user); setLoadError(false); + setReady(true); }; const signup = async (input: SignupInput) => { @@ -72,9 +93,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { }; const logout = () => { + invalidateBootstrap(); setToken(null); setUser(null); setLoadError(false); + setReady(true); }; return ( From 56b5306c8fc8593851d403471439059033076bfc Mon Sep 17 00:00:00 2001 From: ca5tlechan Date: Thu, 16 Jul 2026 23:29:07 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(frontend):=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=8B=9C=20=EC=9D=B4=EC=A0=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=9D=B4=20=EB=82=A8=EA=B8=B4=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=EB=8F=84=20=ED=95=A8=EA=BB=98=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재시도를 연달아 눌러 먼저 뜬 검증이 5xx로 loadError를 남긴 뒤 다른 검증이 401로 돌아오면, 토큰은 정리됐는데도 ProtectedRoute가 loadError를 먼저 보고 로그인 대신 '연결 문제' 화면에 머물렀다. (재현 확인: 401 도착 후에도 loadError=true) 만료는 확정된 상태이므로 unauthorizedHandler에서 loadError도 해제한다. login/logout이 이미 하던 것과 동일한 처리다. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/auth.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx index 0a67b18..cb56a03 100644 --- a/frontend/src/lib/auth.tsx +++ b/frontend/src/lib/auth.tsx @@ -69,6 +69,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUnauthorizedHandler(() => { invalidateBootstrap(); setUser(null); + // 만료는 '확정된' 상태다. 이전 검증이 남긴 연결 오류를 지우지 않으면 + // 토큰이 정리됐는데도 화면이 로그인 대신 '연결 문제'에 머문다. + setLoadError(false); setReady(true); }); return () => setUnauthorizedHandler(null);