Skip to content

Commit 8473546

Browse files
committed
feat(game): ClubNameInput 동아리명 자동완성 및 유효성 검사 추가
- 입력 시 debounce(300ms) → getClubList 호출 → 드롭다운 자동완성 - submit 시 정확한 이름 일치 최종 검증, 없으면 에러 메시지 표시 - 에러 시 Input 테두리 강조, 버튼 "확인 중..." 로딩 상태 표시
1 parent cd420e4 commit 8473546

2 files changed

Lines changed: 130 additions & 18 deletions

File tree

frontend/src/pages/GamePage/components/ClubNameInput/ClubNameInput.styles.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,24 @@ export const Title = styled.h2`
1515
color: ${({ theme }) => theme.colors.gray[900]};
1616
`;
1717

18+
export const InputContainer = styled.div`
19+
position: relative;
20+
width: 100%;
21+
`;
22+
1823
export const InputRow = styled.div`
1924
display: flex;
2025
gap: 8px;
2126
width: 100%;
2227
`;
2328

24-
export const Input = styled.input`
29+
export const Input = styled.input<{ $hasError: boolean }>`
2530
flex: 1;
2631
padding: 12px 16px;
2732
font-size: 1rem;
28-
border: 2px solid ${({ theme }) => theme.colors.gray[300]};
33+
border: 2px solid
34+
${({ theme, $hasError }) =>
35+
$hasError ? theme.colors.primary[900] : theme.colors.gray[300]};
2936
border-radius: 10px;
3037
outline: none;
3138
transition: border-color 0.2s;
@@ -60,3 +67,40 @@ export const StartButton = styled.button`
6067
cursor: not-allowed;
6168
}
6269
`;
70+
71+
export const Dropdown = styled.ul`
72+
position: absolute;
73+
top: calc(100% + 4px);
74+
left: 0;
75+
right: 0;
76+
background: #fff;
77+
border: 1.5px solid ${({ theme }) => theme.colors.gray[200]};
78+
border-radius: 10px;
79+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
80+
list-style: none;
81+
max-height: 200px;
82+
overflow-y: auto;
83+
z-index: 10;
84+
`;
85+
86+
export const DropdownItem = styled.li`
87+
padding: 10px 16px;
88+
font-size: 0.95rem;
89+
color: ${({ theme }) => theme.colors.gray[800]};
90+
cursor: pointer;
91+
transition: background 0.15s;
92+
93+
&:hover {
94+
background: ${({ theme }) => theme.colors.gray[100]};
95+
}
96+
97+
& + & {
98+
border-top: 1px solid ${({ theme }) => theme.colors.gray[100]};
99+
}
100+
`;
101+
102+
export const ErrorMessage = styled.p`
103+
font-size: 0.875rem;
104+
color: ${({ theme }) => theme.colors.primary[900]};
105+
font-weight: 500;
106+
`;

frontend/src/pages/GamePage/components/ClubNameInput/ClubNameInput.tsx

Lines changed: 84 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useState } from 'react';
1+
import { useRef, useState } from 'react';
2+
import { getClubList } from '@/apis/club';
23
import * as S from './ClubNameInput.styles';
34

45
interface ClubNameInputProps {
@@ -7,10 +8,59 @@ interface ClubNameInputProps {
78

89
const ClubNameInput = ({ onStart }: ClubNameInputProps) => {
910
const [value, setValue] = useState('');
11+
const [suggestions, setSuggestions] = useState<string[]>([]);
12+
const [error, setError] = useState('');
13+
const [isValidating, setIsValidating] = useState(false);
14+
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(
15+
undefined,
16+
);
17+
18+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
19+
const v = e.target.value;
20+
setValue(v);
21+
setError('');
22+
23+
clearTimeout(debounceRef.current);
24+
25+
if (!v.trim()) {
26+
setSuggestions([]);
27+
return;
28+
}
29+
30+
debounceRef.current = setTimeout(async () => {
31+
try {
32+
const { clubs } = await getClubList(v.trim());
33+
setSuggestions(clubs.map((c) => c.name));
34+
} catch {
35+
setSuggestions([]);
36+
}
37+
}, 300);
38+
};
1039

11-
const handleSubmit = () => {
40+
const handleSelect = (name: string) => {
41+
setValue(name);
42+
setSuggestions([]);
43+
setError('');
44+
};
45+
46+
const handleSubmit = async () => {
1247
const trimmed = value.trim();
13-
if (trimmed) onStart(trimmed);
48+
if (!trimmed) return;
49+
50+
setIsValidating(true);
51+
try {
52+
const { clubs } = await getClubList(trimmed);
53+
const exact = clubs.find((c) => c.name === trimmed);
54+
if (!exact) {
55+
setError('존재하지 않는 동아리입니다.');
56+
return;
57+
}
58+
onStart(trimmed);
59+
} catch {
60+
setError('동아리 확인 중 오류가 발생했습니다.');
61+
} finally {
62+
setIsValidating(false);
63+
}
1464
};
1565

1666
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -20,19 +70,37 @@ const ClubNameInput = ({ onStart }: ClubNameInputProps) => {
2070
return (
2171
<S.Wrapper>
2272
<S.Title>동아리명을 입력해주세요</S.Title>
23-
<S.InputRow>
24-
<S.Input
25-
value={value}
26-
onChange={(e) => setValue(e.target.value)}
27-
onKeyDown={handleKeyDown}
28-
placeholder='예) RCY'
29-
maxLength={30}
30-
autoFocus
31-
/>
32-
<S.StartButton onClick={handleSubmit} disabled={!value.trim()}>
33-
시작
34-
</S.StartButton>
35-
</S.InputRow>
73+
<S.InputContainer>
74+
<S.InputRow>
75+
<S.Input
76+
value={value}
77+
onChange={handleChange}
78+
onKeyDown={handleKeyDown}
79+
placeholder='예) RCY'
80+
maxLength={30}
81+
autoFocus
82+
$hasError={!!error}
83+
/>
84+
<S.StartButton
85+
onClick={handleSubmit}
86+
disabled={!value.trim() || isValidating}
87+
>
88+
{isValidating ? '확인 중...' : '시작'}
89+
</S.StartButton>
90+
</S.InputRow>
91+
92+
{suggestions.length > 0 && (
93+
<S.Dropdown>
94+
{suggestions.map((name) => (
95+
<S.DropdownItem key={name} onClick={() => handleSelect(name)}>
96+
{name}
97+
</S.DropdownItem>
98+
))}
99+
</S.Dropdown>
100+
)}
101+
</S.InputContainer>
102+
103+
{error && <S.ErrorMessage>{error}</S.ErrorMessage>}
36104
</S.Wrapper>
37105
);
38106
};

0 commit comments

Comments
 (0)