Compare commits

...

4 Commits

Author SHA1 Message Date
0b999f0526 refactor: pages/wallet 리팩토링 — CSS 분리, 유효성 검사 개선, 테스트 통과
Some checks failed
Client CI/CD / test (push) Successful in 9s
Client CI/CD / deploy (push) Failing after 2m13s
- WalletSummary.css 신규 분리 (WalletPage.css 결합도 제거)
- InventoryTab: useCallback + useEffect([load]) 패턴으로 통일
- LoginPage: handleSSAFYLogin 함수 분리
- RegisterPage: 유효성 검사 메시지 세분화 (빈값/짧음/문자 오류 구분), 테스트 52개 전부 통과
- SSAFYCallbackPage: AuthPage.css 임포트 + 버튼 클래스 적용
- AuthPage.css: .login-logo 클래스 추가
- AdminPage.css: 후행 공백 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:51:55 +09:00
4e0716c1cb refactor: components/ 정리
- ConfirmProvider useMemo 불필요한 래핑 제거
- DownloadAdmin useCallback 적용, toast 중복 제거, eslint-disable 정리
- UserAdmin useCallback 적용, PAGE_SIZE 컴포넌트 밖으로 이동, 페이지네이션 버튼 가독성 개선
- UploadForm 에러 처리 fail 헬퍼로 중복 제거
- DownloadSection 후행 빈 줄 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:17:17 +09:00
9a8102fb19 refactor: AuthContext 중복 코드 추출
login, setUserFromSSAFY의 토큰 저장 + 유저 상태 업데이트 로직을
applySession 헬퍼로 추출

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:11:23 +09:00
90b75413f1 refactor: api/ 주석 정리 및 소소한 개선
- client.js JSDoc 추가 (apiFetch, tryRefresh, apiUpload, localizeError)
- auth.js 단순 함수 JSDoc 제거, createLaunchTicket why 주석 유지
- chain.js BASE 중복 선언 이유 주석 추가, 단순 함수 JSDoc 제거
- announcements.js 후행 빈 줄 제거
- users.js getUsers 쿼리스트링 → URLSearchParams 변경

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:47:26 +09:00
20 changed files with 206 additions and 165 deletions

View File

@@ -32,9 +32,12 @@ export async function ssafyCallback(code, state) {
// 토큰을 리프레시하고 새 access token을 반환 (동시 호출 방지 포함) // 토큰을 리프레시하고 새 access token을 반환 (동시 호출 방지 포함)
export { tryRefresh as refreshToken } from './client'; export { tryRefresh as refreshToken } from './client';
// 게임 런처용 일회용 티켓 발급 (JWT를 URL에 노출하지 않기 위해 사용) /**
* 게임 런처용 일회용 티켓 발급
* JWT를 URL에 직접 노출하지 않기 위해 단기 티켓으로 대체
* @returns {Promise<string>} 티켓 문자열
*/
export async function createLaunchTicket() { export async function createLaunchTicket() {
const data = await apiFetch('/api/auth/launch-ticket', { method: 'POST' }); const data = await apiFetch('/api/auth/launch-ticket', { method: 'POST' });
return data.ticket; return data.ticket;
} }

View File

@@ -1,8 +1,11 @@
import { apiFetch } from './client'; import { apiFetch } from './client';
// exportWalletKey는 비밀번호 오류 시 서버가 401을 반환하므로
// apiFetch의 401 자동 refresh/로그아웃을 우회하기 위해 BASE를 직접 참조
const BASE = import.meta.env.VITE_API_BASE_URL || ''; const BASE = import.meta.env.VITE_API_BASE_URL || '';
// --- 지갑 --- // --- 지갑 ---
export async function getBalance() { export async function getBalance() {
return apiFetch('/api/chain/balance'); return apiFetch('/api/chain/balance');
} }
@@ -11,8 +14,13 @@ export async function getWallet() {
return apiFetch('/api/chain/wallet'); return apiFetch('/api/chain/wallet');
} }
// 키 내보내기는 비밀번호 오류 시 서버가 401을 반환하므로, /**
// apiFetch의 401 자동 refresh/로그아웃을 우회하기 위해 직접 fetch한다. * 개인키 내보내기
* apiFetch를 우회해 직접 fetch 사용 — 비밀번호 오류(401)를 로그아웃 없이 처리하기 위함
* @param {string} password
* @returns {Promise<{privateKey: string}>}
* @throws {Error} 비밀번호 오류 시 status 401
*/
export async function exportWalletKey(password) { export async function exportWalletKey(password) {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const headers = { 'Content-Type': 'application/json' }; const headers = { 'Content-Type': 'application/json' };
@@ -33,6 +41,7 @@ export async function exportWalletKey(password) {
} }
// --- 자산 --- // --- 자산 ---
export async function getAssets() { export async function getAssets() {
return apiFetch('/api/chain/assets'); return apiFetch('/api/chain/assets');
} }
@@ -42,11 +51,13 @@ export async function getAsset(id) {
} }
// --- 인벤토리 --- // --- 인벤토리 ---
export async function getInventory() { export async function getInventory() {
return apiFetch('/api/chain/inventory'); return apiFetch('/api/chain/inventory');
} }
// --- 마켓 --- // --- 마켓 ---
export async function getMarketListings() { export async function getMarketListings() {
return apiFetch('/api/chain/market'); return apiFetch('/api/chain/market');
} }
@@ -55,6 +66,12 @@ export async function getListing(id) {
return apiFetch(`/api/chain/market/${id}`); return apiFetch(`/api/chain/market/${id}`);
} }
/**
* Idempotency-Key 헤더를 붙인 POST 요청
* 중복 제출(네트워크 재시도 등)로 인한 이중 처리를 서버에서 방지
* @param {string} path
* @param {object} body
*/
function idempotentPost(path, body) { function idempotentPost(path, body) {
return apiFetch(path, { return apiFetch(path, {
method: 'POST', method: 'POST',

View File

@@ -1,6 +1,10 @@
const BASE = import.meta.env.VITE_API_BASE_URL || ''; const BASE = import.meta.env.VITE_API_BASE_URL || '';
/** 네트워크 에러 메시지를 한국어로 변환 */ /**
* 네트워크 에러 메시지를 한국어로 변환
* @param {string} message
* @returns {string}
*/
function localizeError(message) { function localizeError(message) {
if (typeof message !== 'string') return message; if (typeof message !== 'string') return message;
if (message.includes('Failed to fetch')) return '서버에 연결할 수 없습니다'; if (message.includes('Failed to fetch')) return '서버에 연결할 수 없습니다';
@@ -9,9 +13,14 @@ function localizeError(message) {
return message; return message;
} }
// 동시 401 발생 시 refresh를 한 번만 실행하기 위한 Promise 공유
let refreshingPromise = null; let refreshingPromise = null;
/**
* 리프레시 토큰으로 액세스 토큰 갱신
* 동시 401 발생 시 refresh를 한 번만 실행하기 위해 Promise를 공유
* @returns {Promise<string>} 새 액세스 토큰
* @throws {Error} refresh_failed — 리프레시 토큰 만료 또는 서버 오류
*/
export async function tryRefresh() { export async function tryRefresh() {
if (refreshingPromise) return refreshingPromise; if (refreshingPromise) return refreshingPromise;
@@ -40,7 +49,7 @@ async function doFetch(path, options, token) {
return fetch(BASE + path, { ...options, headers, credentials: 'include' }); return fetch(BASE + path, { ...options, headers, credentials: 'include' });
} }
/** 에러 코드별 기본 한국어 메시지 */ /** @type {Record<string, string>} 에러 코드별 기본 한국어 메시지 */
const ERROR_MESSAGES = { const ERROR_MESSAGES = {
bad_request: '잘못된 요청입니다', bad_request: '잘못된 요청입니다',
unauthorized: '로그인이 필요합니다', unauthorized: '로그인이 필요합니다',
@@ -66,12 +75,20 @@ async function parseError(res) {
return err; return err;
} }
// 204 No Content는 null 반환, 나머지는 JSON 파싱
async function parseResponse(res) { async function parseResponse(res) {
if (res.status === 204) return null; if (res.status === 204) return null;
return res.json(); return res.json();
} }
/**
* 인증 포함 API 요청 래퍼
* - 401 응답 시 토큰 자동 갱신 후 재시도
* - 네트워크 에러 / 5xx 응답 시 GET·HEAD 요청만 최대 2회 재시도 (exponential backoff)
* @param {string} path - API 경로 (예: '/api/chain/balance')
* @param {RequestInit} [options={}] - fetch 옵션
* @returns {Promise<any>} 응답 JSON (204는 null)
* @throws {Error} HTTP 에러 또는 네트워크 에러
*/
export async function apiFetch(path, options = {}, _retryCount = 0) { export async function apiFetch(path, options = {}, _retryCount = 0) {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
let res; let res;

View File

@@ -1,7 +1,8 @@
import { apiFetch } from './client'; import { apiFetch } from './client';
export function getUsers(offset = 0, limit = 20) { export function getUsers(offset = 0, limit = 20) {
return apiFetch(`/api/users?offset=${offset}&limit=${limit}`); const params = new URLSearchParams({ offset, limit });
return apiFetch(`/api/users?${params}`);
} }
export function updateUserRole(id, role) { export function updateUserRole(id, role) {

View File

@@ -1,33 +1,24 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getDownloadInfo } from '../../api/download'; import { getDownloadInfo } from '../../api/download';
import { useToast } from '../toast/useToast';
import UploadForm from './UploadForm'; import UploadForm from './UploadForm';
import s from './AdminCommon.module.css'; import s from './AdminCommon.module.css';
export default function DownloadAdmin() { export default function DownloadAdmin() {
const toast = useToast();
const [info, setInfo] = useState(null); const [info, setInfo] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const load = () => { const load = useCallback(() => {
setLoading(true); setLoading(true);
setLoadError(''); setLoadError('');
getDownloadInfo() getDownloadInfo()
.then((data) => { .then((data) => setInfo(data))
setInfo(data); .catch(() => setLoadError('배포 정보를 불러올 수 없습니다.'))
setLoadError('');
})
.catch((err) => {
console.error('다운로드 정보 로드 실패:', err);
setLoadError('배포 정보를 불러올 수 없습니다.');
toast.error('배포 정보를 불러올 수 없습니다.');
})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; }, []);
// eslint-disable-next-line react-hooks/set-state-in-effect, react-hooks/exhaustive-deps -- initial data fetch on mount // eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { load(); }, []); useEffect(() => { load(); }, [load]);
if (loading) { if (loading) {
return ( return (

View File

@@ -22,10 +22,11 @@ export default function UploadForm({ title, hint, accept, endpoint, onSuccess })
if (!file) return; if (!file) return;
const path = `${endpoint}?filename=${encodeURIComponent(file.name)}`; const path = `${endpoint}?filename=${encodeURIComponent(file.name)}`;
setUploading(true); setUploading(true);
setError(''); setError('');
const fail = (msg) => { setError(msg); toast.error(msg); setProgress(0); };
try { try {
const { status, body } = await apiUpload(path, file, (p) => setProgress(p)); const { status, body } = await apiUpload(path, file, (p) => setProgress(p));
if (status >= 200 && status < 300) { if (status >= 200 && status < 300) {
@@ -35,28 +36,16 @@ export default function UploadForm({ title, hint, accept, endpoint, onSuccess })
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
setProgress(0); setProgress(0);
} else if (status === 413) { } else if (status === 413) {
const msg = '파일 크기가 너무 큽니다. 더 작은 파일을 선택해주세요.'; fail('파일 크기가 너무 큽니다. 더 작은 파일을 선택해주세요.');
setError(msg);
toast.error(msg);
} else if (status === 409) { } else if (status === 409) {
const msg = '동일한 파일이 이미 존재합니다.'; fail('동일한 파일이 이미 존재합니다.');
setError(msg);
toast.error(msg);
} else if (status >= 500) { } else if (status >= 500) {
const msg = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; fail('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
setError(msg);
toast.error(msg);
} else { } else {
const msg = body.error || '업로드에 실패했습니다.'; fail(body.error || '업로드에 실패했습니다.');
setError(msg);
toast.error(msg);
setProgress(0);
} }
} catch { } catch {
const msg = '네트워크 오류가 발생했습니다.'; fail('네트워크 오류가 발생했습니다.');
setError(msg);
toast.error(msg);
setProgress(0);
} finally { } finally {
setUploading(false); setUploading(false);
} }

View File

@@ -1,12 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getUsers, updateUserRole, deleteUser } from '../../api/users'; import { getUsers, updateUserRole, deleteUser } from '../../api/users';
import { useAuth } from '../../context/useAuth'; import { useAuth } from '../../context/useAuth';
import { useToast } from '../toast/useToast'; import { useToast } from '../toast/useToast';
import { useConfirm } from '../confirm/useConfirm'; import { useConfirm } from '../confirm/useConfirm';
import s from './AdminCommon.module.css'; import s from './AdminCommon.module.css';
export default function UserAdmin() {
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
export default function UserAdmin() {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState(false); const [fetchError, setFetchError] = useState(false);
@@ -16,7 +17,7 @@ export default function UserAdmin() {
const toast = useToast(); const toast = useToast();
const confirm = useConfirm(); const confirm = useConfirm();
const load = (off = offset) => { const load = useCallback((off = 0) => {
setLoading(true); setLoading(true);
setFetchError(false); setFetchError(false);
getUsers(off, PAGE_SIZE) getUsers(off, PAGE_SIZE)
@@ -24,14 +25,12 @@ export default function UserAdmin() {
setUsers(data); setUsers(data);
setHasMore(data.length === PAGE_SIZE); setHasMore(data.length === PAGE_SIZE);
}) })
.catch((err) => { .catch(() => setFetchError(true))
console.error('유저 목록 로드 실패:', err);
setFetchError(true);
})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; }, []);
// eslint-disable-next-line react-hooks/set-state-in-effect, react-hooks/exhaustive-deps -- initial data fetch on mount
useEffect(() => { load(0); }, []); // eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { load(0); }, [load]);
const handleRoleToggle = async (u) => { const handleRoleToggle = async (u) => {
const newRole = u.role === 'admin' ? 'user' : 'admin'; const newRole = u.role === 'admin' ? 'user' : 'admin';
@@ -86,10 +85,26 @@ export default function UserAdmin() {
))} ))}
</ul> </ul>
<div className={s.listActions} style={{ justifyContent: 'center', marginTop: '1rem' }}> <div className={s.listActions} style={{ justifyContent: 'center', marginTop: '1rem' }}>
<button className={s.btnSecondary} disabled={offset === 0} onClick={() => { const prev = Math.max(0, offset - PAGE_SIZE); setOffset(prev); load(prev); }}> <button
className={s.btnSecondary}
disabled={offset === 0}
onClick={() => {
const prev = Math.max(0, offset - PAGE_SIZE);
setOffset(prev);
load(prev);
}}
>
이전 이전
</button> </button>
<button className={s.btnSecondary} disabled={!hasMore} onClick={() => { const next = offset + PAGE_SIZE; setOffset(next); load(next); }}> <button
className={s.btnSecondary}
disabled={!hasMore}
onClick={() => {
const next = offset + PAGE_SIZE;
setOffset(next);
load(next);
}}
>
다음 다음
</button> </button>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
import { ConfirmContext } from './confirmContextValue'; import { ConfirmContext } from './confirmContextValue';
import './Confirm.css'; import './Confirm.css';
@@ -25,10 +25,8 @@ export function ConfirmProvider({ children }) {
setDialog(null); setDialog(null);
}, []); }, []);
const value = useMemo(() => confirm, [confirm]);
return ( return (
<ConfirmContext.Provider value={value}> <ConfirmContext.Provider value={confirm}>
{children} {children}
{dialog && ( {dialog && (
<div className="confirm-overlay" onClick={handleCancel}> <div className="confirm-overlay" onClick={handleCancel}>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getInventory } from '../../api/chain'; import { getInventory } from '../../api/chain';
import { useToast } from '../toast/useToast'; import { useToast } from '../toast/useToast';
@@ -7,14 +7,16 @@ export default function InventoryTab() {
const [inventory, setInventory] = useState(null); const [inventory, setInventory] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { const load = useCallback(() => {
let cancelled = false; setLoading(true);
getInventory() getInventory()
.then((data) => { if (!cancelled) setInventory(data); }) .then(setInventory)
.catch(() => { if (!cancelled) toast.error('인벤토리를 불러오지 못했습니다.'); }) .catch(() => toast.error('인벤토리를 불러오지 못했습니다.'))
.finally(() => { if (!cancelled) setLoading(false); }); .finally(() => setLoading(false));
return () => { cancelled = true; }; }, [toast]);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { load(); }, [load]);
if (loading) return <div className="wallet-spinner">불러오는 ...</div>; if (loading) return <div className="wallet-spinner">불러오는 ...</div>;

View File

@@ -0,0 +1,51 @@
.wallet-summary {
background: rgba(186, 205, 176, 0.06);
border: 1px solid rgba(186, 205, 176, 0.15);
border-radius: 10px;
padding: 20px 24px;
margin-bottom: 32px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.wallet-summary:hover {
border-color: rgba(186, 205, 176, 0.3);
background: rgba(186, 205, 176, 0.09);
}
.wallet-summary-title {
font-size: 0.75rem;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 0.08em;
margin: 0 0 12px;
}
.wallet-summary-balance {
font-size: 1.8rem;
font-weight: 700;
color: #BACDB0;
margin: 0;
}
.wallet-summary-stats {
display: flex;
gap: 24px;
margin-top: 12px;
}
.wallet-summary-stat {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
}
.wallet-summary-stat strong {
color: rgba(255, 255, 255, 0.8);
}
@media (max-width: 768px) {
.wallet-summary-stats {
flex-direction: column;
gap: 8px;
}
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getBalance, getAssets, getInventory } from '../../api/chain'; import { getBalance, getAssets, getInventory } from '../../api/chain';
import '../../pages/WalletPage.css'; import './WalletSummary.css';
export default function WalletSummary() { export default function WalletSummary() {
const navigate = useNavigate(); const navigate = useNavigate();

View File

@@ -15,27 +15,26 @@ export function AuthProvider({ children }) {
const username = localStorage.getItem('username'); const username = localStorage.getItem('username');
if (!token) return null; if (!token) return null;
const payload = decodeTokenPayload(token); const payload = decodeTokenPayload(token);
const role = payload?.role || 'user'; return { token, username, role: payload?.role || 'user' };
return { token, username, role };
}); });
// 토큰 저장 + 유저 상태 업데이트 공통 처리 (login, setUserFromSSAFY에서 공유)
const applySession = useCallback((data) => {
localStorage.setItem('token', data.token);
localStorage.setItem('username', data.username);
const role = decodeTokenPayload(data.token)?.role || 'user';
setUser({ token: data.token, username: data.username, role });
}, []);
const login = useCallback(async (username, password) => { const login = useCallback(async (username, password) => {
const data = await apiLogin(username, password); const data = await apiLogin(username, password);
localStorage.setItem('token', data.token); applySession(data);
localStorage.setItem('username', data.username); }, [applySession]);
const decoded = decodeTokenPayload(data.token);
const role = decoded?.role || 'user';
setUser({ token: data.token, username: data.username, role });
}, []);
// SSAFY OAuth 콜백에서 받은 토큰으로 로그인 처리 // SSAFY OAuth 콜백에서 받은 토큰으로 로그인 처리
const setUserFromSSAFY = useCallback((data) => { const setUserFromSSAFY = useCallback((data) => {
localStorage.setItem('token', data.token); applySession(data);
localStorage.setItem('username', data.username); }, [applySession]);
const decoded = decodeTokenPayload(data.token);
const role = decoded?.role || 'user';
setUser({ token: data.token, username: data.username, role });
}, []);
// 로컬 세션만 정리 (토큰 만료·강제 로그아웃 시) // 로컬 세션만 정리 (토큰 만료·강제 로그아웃 시)
const clearSession = useCallback(() => { const clearSession = useCallback(() => {

View File

@@ -151,6 +151,13 @@
border-color: rgba(186, 205, 176, 0.5); border-color: rgba(186, 205, 176, 0.5);
} }
.login-logo {
max-width: 120px;
height: auto;
margin: 0 auto 16px;
display: block;
}
.login-back { .login-back {
display: block; display: block;
text-align: center; text-align: center;

View File

@@ -14,6 +14,18 @@ export default function LoginPage() {
const location = useLocation(); const location = useLocation();
const justRegistered = location.state?.registered; const justRegistered = location.state?.registered;
const handleSSAFYLogin = async () => {
try {
const data = await getSSAFYLoginURL();
if (!data.url || !data.url.startsWith('https://')) {
throw new Error('유효하지 않은 로그인 URL입니다.');
}
window.location.href = data.url;
} catch {
setError('SSAFY 로그인 URL을 가져올 수 없습니다.');
}
};
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
@@ -84,17 +96,7 @@ export default function LoginPage() {
<button <button
type="button" type="button"
className="btn-ssafy" className="btn-ssafy"
onClick={async () => { onClick={handleSSAFYLogin}
try {
const data = await getSSAFYLoginURL();
if (!data.url || !data.url.startsWith('https://')) {
throw new Error('유효하지 않은 로그인 URL입니다.');
}
window.location.href = data.url;
} catch {
setError('SSAFY 로그인 URL을 가져올 수 없습니다.');
}
}}
> >
SSAFY 계정으로 로그인 SSAFY 계정으로 로그인
</button> </button>

View File

@@ -5,6 +5,13 @@ import './AuthPage.css';
const USERNAME_REGEX = /^[a-z0-9_-]{3,50}$/; const USERNAME_REGEX = /^[a-z0-9_-]{3,50}$/;
function getPasswordStrength(pw) {
if (pw.length === 0) return { label: '', level: '' };
if (pw.length < 6) return { label: '약함', level: 'weak' };
if (pw.length < 10) return { label: '중간', level: 'medium' };
return { label: '강함', level: 'strong' };
}
export default function RegisterPage() { export default function RegisterPage() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -18,13 +25,6 @@ export default function RegisterPage() {
const showUsernameError = usernameTouched && username.length > 0 && !isUsernameValid; const showUsernameError = usernameTouched && username.length > 0 && !isUsernameValid;
const showUsernameValid = usernameTouched && username.length > 0 && isUsernameValid; const showUsernameValid = usernameTouched && username.length > 0 && isUsernameValid;
const getPasswordStrength = (pw) => {
if (pw.length === 0) return { label: '', level: '' };
if (pw.length < 6) return { label: '약함', level: 'weak' };
if (pw.length < 10) return { label: '중간', level: 'medium' };
return { label: '강함', level: 'strong' };
};
const passwordStrength = getPasswordStrength(password); const passwordStrength = getPasswordStrength(password);
const confirmMismatch = confirm.length > 0 && password !== confirm; const confirmMismatch = confirm.length > 0 && password !== confirm;
const confirmMatch = confirm.length > 0 && password === confirm; const confirmMatch = confirm.length > 0 && password === confirm;
@@ -34,7 +34,7 @@ export default function RegisterPage() {
setError(''); setError('');
const trimmed = username.trim().toLowerCase(); const trimmed = username.trim().toLowerCase();
if (!trimmed) { if (trimmed.length === 0) {
setError('아이디를 입력해주세요.'); setError('아이디를 입력해주세요.');
return; return;
} }
@@ -42,7 +42,7 @@ export default function RegisterPage() {
setError('아이디는 3자 이상이어야 합니다.'); setError('아이디는 3자 이상이어야 합니다.');
return; return;
} }
if (!/^[a-z0-9_-]+$/.test(trimmed)) { if (!USERNAME_REGEX.test(trimmed)) {
setError('아이디는 영문 소문자, 숫자, _, -만 사용 가능합니다.'); setError('아이디는 영문 소문자, 숫자, _, -만 사용 가능합니다.');
return; return;
} }
@@ -70,7 +70,7 @@ export default function RegisterPage() {
<div className="login-page"> <div className="login-page">
<div className="login-panel"> <div className="login-panel">
<div className="login-header"> <div className="login-header">
<img src="/images/logo.webp" alt="One of the Plans" style={{maxWidth: 120, height: 'auto', margin: '0 auto 16px', display: 'block'}} /> <img src="/images/logo.webp" alt="One of the Plans" className="login-logo" />
<h1 className="game-title">One of the plans</h1> <h1 className="game-title">One of the plans</h1>
<p className="game-subtitle">MULTIPLAYER</p> <p className="game-subtitle">MULTIPLAYER</p>
</div> </div>
@@ -128,12 +128,10 @@ export default function RegisterPage() {
className={confirmMatch ? 'input-valid' : confirmMismatch ? 'input-invalid' : ''} className={confirmMatch ? 'input-valid' : confirmMismatch ? 'input-invalid' : ''}
aria-describedby="confirm-hint" aria-describedby="confirm-hint"
/> />
{confirmMismatch && ( <span id="confirm-hint" className={`input-hint ${confirmMismatch ? 'input-hint-error' : confirmMatch ? 'input-hint-success' : ''}`}>
<span id="confirm-hint" className="input-hint input-hint-error">비밀번호가 일치하지 않습니다</span> {confirmMismatch && '비밀번호가 일치하지 않습니다'}
)} {confirmMatch && `비밀번호가 일치합니다 \u2713`}
{confirmMatch && ( </span>
<span id="confirm-hint" className="input-hint input-hint-success">비밀번호가 일치합니다 {'\u2713'}</span>
)}
</div> </div>
{error && <p className="login-error" role="alert">{error}</p>} {error && <p className="login-error" role="alert">{error}</p>}

View File

@@ -2,8 +2,9 @@ import { useEffect, useState, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../context/useAuth'; import { useAuth } from '../context/useAuth';
import { ssafyCallback } from '../api/auth'; import { ssafyCallback } from '../api/auth';
import './AuthPage.css';
// Inline styles are intentional for this simple callback/loading page — not worth a separate CSS file. // Inline styles are intentional for this simple callback/loading page — layout only, AuthPage.css handles buttons.
export default function SSAFYCallbackPage() { export default function SSAFYCallbackPage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
@@ -39,7 +40,7 @@ export default function SSAFYCallbackPage() {
return ( return (
<div style={{ textAlign: 'center', marginTop: '4rem' }}> <div style={{ textAlign: 'center', marginTop: '4rem' }}>
<p style={{ color: '#e74c3c' }}>{error}</p> <p style={{ color: '#e74c3c' }}>{error}</p>
<button onClick={() => navigate('/login', { replace: true })}> <button className="btn-login" onClick={() => navigate('/login', { replace: true })}>
로그인 페이지로 돌아가기 로그인 페이지로 돌아가기
</button> </button>
</div> </div>

View File

@@ -367,51 +367,6 @@
background: rgba(186, 205, 176, 0.08); background: rgba(186, 205, 176, 0.08);
} }
/* Wallet summary card (HomePage) */
.wallet-summary {
background: rgba(186, 205, 176, 0.06);
border: 1px solid rgba(186, 205, 176, 0.15);
border-radius: 10px;
padding: 20px 24px;
margin-bottom: 32px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.wallet-summary:hover {
border-color: rgba(186, 205, 176, 0.3);
background: rgba(186, 205, 176, 0.09);
}
.wallet-summary-title {
font-size: 0.75rem;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 0.08em;
margin: 0 0 12px;
}
.wallet-summary-balance {
font-size: 1.8rem;
font-weight: 700;
color: #BACDB0;
margin: 0;
}
.wallet-summary-stats {
display: flex;
gap: 24px;
margin-top: 12px;
}
.wallet-summary-stat {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
}
.wallet-summary-stat strong {
color: rgba(255, 255, 255, 0.8);
}
.wallet-logo { .wallet-logo {
height: 32px; height: 32px;
@@ -470,11 +425,6 @@
align-items: stretch; align-items: stretch;
} }
.wallet-summary-stats {
flex-direction: column;
gap: 8px;
}
.market-item { .market-item {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;