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>
This commit is contained in:
2026-04-12 19:51:55 +09:00
parent 4e0716c1cb
commit 0b999f0526
9 changed files with 100 additions and 89 deletions

View File

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

View File

@@ -147,4 +147,4 @@
.admin-main {
padding: 20px 12px 60px;
}
}
}

View File

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

View File

@@ -14,6 +14,18 @@ export default function LoginPage() {
const location = useLocation();
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) => {
e.preventDefault();
setError('');
@@ -84,17 +96,7 @@ export default function LoginPage() {
<button
type="button"
className="btn-ssafy"
onClick={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을 가져올 수 없습니다.');
}
}}
onClick={handleSSAFYLogin}
>
SSAFY 계정으로 로그인
</button>

View File

@@ -5,6 +5,13 @@ import './AuthPage.css';
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() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -18,13 +25,6 @@ export default function RegisterPage() {
const showUsernameError = 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 confirmMismatch = confirm.length > 0 && password !== confirm;
const confirmMatch = confirm.length > 0 && password === confirm;
@@ -34,7 +34,7 @@ export default function RegisterPage() {
setError('');
const trimmed = username.trim().toLowerCase();
if (!trimmed) {
if (trimmed.length === 0) {
setError('아이디를 입력해주세요.');
return;
}
@@ -42,7 +42,7 @@ export default function RegisterPage() {
setError('아이디는 3자 이상이어야 합니다.');
return;
}
if (!/^[a-z0-9_-]+$/.test(trimmed)) {
if (!USERNAME_REGEX.test(trimmed)) {
setError('아이디는 영문 소문자, 숫자, _, -만 사용 가능합니다.');
return;
}
@@ -70,7 +70,7 @@ export default function RegisterPage() {
<div className="login-page">
<div className="login-panel">
<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>
<p className="game-subtitle">MULTIPLAYER</p>
</div>
@@ -128,12 +128,10 @@ export default function RegisterPage() {
className={confirmMatch ? 'input-valid' : confirmMismatch ? 'input-invalid' : ''}
aria-describedby="confirm-hint"
/>
{confirmMismatch && (
<span id="confirm-hint" className="input-hint input-hint-error">비밀번호가 일치하지 않습니다</span>
)}
{confirmMatch && (
<span id="confirm-hint" className="input-hint input-hint-success">비밀번호가 일치합니다 {'\u2713'}</span>
)}
<span id="confirm-hint" className={`input-hint ${confirmMismatch ? 'input-hint-error' : confirmMatch ? 'input-hint-success' : ''}`}>
{confirmMismatch && '비밀번호가 일치하지 않습니다'}
{confirmMatch && `비밀번호가 일치합니다 \u2713`}
</span>
</div>
{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 { useAuth } from '../context/useAuth';
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() {
const [error, setError] = useState('');
const navigate = useNavigate();
@@ -39,7 +40,7 @@ export default function SSAFYCallbackPage() {
return (
<div style={{ textAlign: 'center', marginTop: '4rem' }}>
<p style={{ color: '#e74c3c' }}>{error}</p>
<button onClick={() => navigate('/login', { replace: true })}>
<button className="btn-login" onClick={() => navigate('/login', { replace: true })}>
로그인 페이지로 돌아가기
</button>
</div>

View File

@@ -367,51 +367,6 @@
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 {
height: 32px;
@@ -470,11 +425,6 @@
align-items: stretch;
}
.wallet-summary-stats {
flex-direction: column;
gap: 8px;
}
.market-item {
flex-direction: column;
align-items: stretch;