sel-chat-coach / src /components /auth /RegisterForm.tsx
james-d-taboola's picture
feat: add early username availability check on registration step 1
4ac6deb
'use client';
import { useState, useRef } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import type { RegisterUserDto, TeachingGrade, Position, TeachingExperience, KnownStrategy } from '@/lib/types/models';
import {
TEACHING_GRADES,
POSITIONS,
TEACHING_EXPERIENCE,
KNOWN_STRATEGIES,
} from '@/lib/content/registration-options';
import { PRIVACY_NOTICE } from '@/lib/content/privacy-notice';
import CheckboxGroup from './form/CheckboxGroup';
import RadioGroup from './form/RadioGroup';
import ConfidenceScale from './form/ConfidenceScale';
import StepIndicator from './form/StepIndicator';
interface FormData {
username: string;
teachingGrades: TeachingGrade[];
teachingGradesOther: string;
positions: Position[];
positionsOther: string;
teachingExperience: TeachingExperience | '';
teachingExperienceOther: string;
confidenceLevel: number;
knownStrategies: KnownStrategy[];
knownStrategiesOther: string;
}
export default function RegisterForm() {
const formContainerRef = useRef<HTMLDivElement>(null);
const [currentStep, setCurrentStep] = useState(1);
const [showPrivacyModal, setShowPrivacyModal] = useState(false);
const [formData, setFormData] = useState<FormData>({
username: '',
teachingGrades: [],
teachingGradesOther: '',
positions: [],
positionsOther: '',
teachingExperience: '',
teachingExperienceOther: '',
confidenceLevel: 0,
knownStrategies: [],
knownStrategiesOther: '',
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isCheckingUsername, setIsCheckingUsername] = useState(false);
const { register } = useAuth();
const router = useRouter();
const validateStep1 = (): string | null => {
if (formData.username.length < 3) {
return '帳號至少需要 3 個字元';
}
if (formData.username.length > 50) {
return '帳號不能超過 50 個字元';
}
if (!/^[a-zA-Z0-9_.]+$/.test(formData.username)) {
return '帳號只能使用半形英文字母、數字、底線(_)和句點(.)';
}
// Teaching experience validation (moved from Step 2)
if (!formData.teachingExperience) {
return '請選擇教學年資';
}
if (formData.teachingExperience === 'other' && !formData.teachingExperienceOther.trim()) {
return '請說明教學年資';
}
// Conditional validation - only if has experience
if (formData.teachingExperience !== 'none') {
if (formData.teachingGrades.length === 0) {
return '請選擇至少一個任教年段';
}
if (formData.teachingGrades.includes('other') && !formData.teachingGradesOther.trim()) {
return '請說明其他任教年段';
}
if (formData.positions.length === 0) {
return '請選擇至少一個職位';
}
if (formData.positions.includes('other') && !formData.positionsOther.trim()) {
return '請說明其他職位';
}
}
return null;
};
const validateStep2 = (): string | null => {
if (formData.confidenceLevel === 0) {
return '請選擇對師生對話的信心程度';
}
if (formData.knownStrategies.length === 0) {
return '請選擇至少一個策略選項';
}
if (formData.knownStrategies.includes('other') && !formData.knownStrategiesOther.trim()) {
return '請說明其他策略';
}
return null;
};
const scrollToTop = () => {
formContainerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleNextStep = async () => {
const validationError = validateStep1();
if (validationError) {
setError(validationError);
scrollToTop();
return;
}
// Check username availability
setIsCheckingUsername(true);
try {
const response = await fetch('/api/auth/check-username', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: formData.username }),
});
const data = await response.json();
if (!data.available) {
setError('此帳號已被使用');
scrollToTop();
return;
}
} catch {
// If check fails, let registration handle it
} finally {
setIsCheckingUsername(false);
}
setError('');
setCurrentStep(2);
scrollToTop();
};
const handlePrevStep = () => {
setError('');
setCurrentStep(1);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const validationError = validateStep2();
if (validationError) {
setError(validationError);
scrollToTop();
return;
}
setIsLoading(true);
try {
const registerData: RegisterUserDto = {
username: formData.username,
teachingExperience: formData.teachingExperience || undefined,
teachingExperienceOther: formData.teachingExperienceOther || undefined,
// Only include teaching grades/positions if has experience
...(formData.teachingExperience !== 'none' && {
teachingGrades: formData.teachingGrades,
teachingGradesOther: formData.teachingGradesOther || undefined,
positions: formData.positions,
positionsOther: formData.positionsOther || undefined,
}),
confidenceLevel: formData.confidenceLevel,
knownStrategies: formData.knownStrategies,
knownStrategiesOther: formData.knownStrategiesOther || undefined,
};
await register(registerData);
router.push('/');
} catch (err) {
setError(err instanceof Error ? err.message : '註冊失敗');
} finally {
setIsLoading(false);
}
};
const updateFormData = <K extends keyof FormData>(key: K, value: FormData[K]) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '12px 16px',
border: '1px solid #e5e7eb',
borderRadius: '10px',
fontSize: '15px',
outline: 'none',
transition: 'border-color 0.2s, box-shadow 0.2s',
};
const handleInputFocus = (e: React.FocusEvent<HTMLInputElement>) => {
e.target.style.borderColor = '#2563eb';
e.target.style.boxShadow = '0 0 0 3px rgba(37, 99, 235, 0.1)';
};
const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
e.target.style.borderColor = '#e5e7eb';
e.target.style.boxShadow = 'none';
};
return (
<div
ref={formContainerRef}
style={{
width: '100%',
maxWidth: '420px',
margin: '0 auto',
padding: '32px',
backgroundColor: 'white',
borderRadius: '16px',
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.08)',
}}
>
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
<h1 style={{ fontSize: '28px', fontWeight: '700', marginBottom: '8px', color: '#1f2937' }}>
師生對話練習室
</h1>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}>
<p style={{ fontSize: '15px', color: '#6b7280', margin: 0 }}>建立新帳號</p>
<button
type="button"
onClick={() => setShowPrivacyModal(true)}
style={{
background: 'none',
border: 'none',
padding: '2px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label="資料蒐集說明"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="#9ca3af"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
</button>
</div>
</div>
{/* Privacy Modal */}
{showPrivacyModal && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '20px',
}}
onClick={() => setShowPrivacyModal(false)}
>
<div
style={{
backgroundColor: 'white',
borderRadius: '16px',
padding: '24px',
maxWidth: '360px',
width: '100%',
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.15)',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '16px' }}>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#2563eb"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#1f2937', margin: 0 }}>
{PRIVACY_NOTICE.title}
</h2>
</div>
<div style={{ fontSize: '14px', color: '#4b5563', lineHeight: '1.6' }}>
{PRIVACY_NOTICE.paragraphs.map((text, index) => (
<p
key={index}
style={{ marginBottom: index < PRIVACY_NOTICE.paragraphs.length - 1 ? '12px' : 0 }}
>
{text}
</p>
))}
</div>
<button
type="button"
onClick={() => setShowPrivacyModal(false)}
style={{
width: '100%',
marginTop: '20px',
padding: '12px',
backgroundColor: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
}}
>
{PRIVACY_NOTICE.confirmButton}
</button>
</div>
</div>
)}
{error && (
<div
style={{
marginBottom: '20px',
padding: '12px 16px',
backgroundColor: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: '8px',
color: '#dc2626',
fontSize: '14px',
}}
>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
{currentStep === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div>
<label
htmlFor="username"
style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
marginBottom: '8px',
color: '#374151',
}}
>
帳號
</label>
<input
id="username"
name="username"
type="text"
required
value={formData.username}
onChange={(e) => updateFormData('username', e.target.value)}
style={inputStyle}
placeholder="請輸入帳號"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
<p style={{ marginTop: '6px', fontSize: '13px', color: '#9ca3af' }}>
3-50 字元,僅限半形英文、數字、底線(_)、句點(.)
</p>
</div>
<RadioGroup
label="教學年資"
name="teachingExperience"
options={TEACHING_EXPERIENCE}
value={formData.teachingExperience}
onChange={(value) => updateFormData('teachingExperience', value)}
otherValue={formData.teachingExperienceOther}
onOtherChange={(value) => updateFormData('teachingExperienceOther', value)}
/>
{formData.teachingExperience && formData.teachingExperience !== 'none' && (
<>
<CheckboxGroup
label="目前任教的年段(可複選)"
options={TEACHING_GRADES}
value={formData.teachingGrades}
onChange={(value) => updateFormData('teachingGrades', value)}
otherValue={formData.teachingGradesOther}
onOtherChange={(value) => updateFormData('teachingGradesOther', value)}
/>
<CheckboxGroup
label="擔任的職位(可複選)"
options={POSITIONS}
value={formData.positions}
onChange={(value) => updateFormData('positions', value)}
otherValue={formData.positionsOther}
onOtherChange={(value) => updateFormData('positionsOther', value)}
/>
</>
)}
<button
type="button"
onClick={handleNextStep}
disabled={isCheckingUsername}
style={{
width: '100%',
padding: '14px',
backgroundColor: isCheckingUsername ? '#9ca3af' : '#2563eb',
color: 'white',
border: 'none',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isCheckingUsername ? 'not-allowed' : 'pointer',
transition: 'all 0.2s',
marginTop: '8px',
}}
onMouseEnter={(e) => {
if (!isCheckingUsername) e.currentTarget.style.backgroundColor = '#1d4ed8';
}}
onMouseLeave={(e) => {
if (!isCheckingUsername) e.currentTarget.style.backgroundColor = '#2563eb';
}}
>
{isCheckingUsername ? '確認中...' : '下一頁'}
</button>
</div>
)}
{currentStep === 2 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<ConfidenceScale
label="對師生對話的信心程度"
value={formData.confidenceLevel}
onChange={(value) => updateFormData('confidenceLevel', value)}
/>
<CheckboxGroup
label="您是否知道並使用以下策略(可複選)"
options={KNOWN_STRATEGIES}
value={formData.knownStrategies}
onChange={(value) => updateFormData('knownStrategies', value)}
otherValue={formData.knownStrategiesOther}
onOtherChange={(value) => updateFormData('knownStrategiesOther', value)}
/>
<div style={{ display: 'flex', gap: '12px', marginTop: '8px' }}>
<button
type="button"
onClick={handlePrevStep}
style={{
flex: 1,
padding: '14px',
backgroundColor: 'white',
color: '#374151',
border: '1px solid #d1d5db',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#f9fafb';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'white';
}}
>
上一頁
</button>
<button
type="submit"
disabled={isLoading}
style={{
flex: 1,
padding: '14px',
backgroundColor: isLoading ? '#9ca3af' : '#2563eb',
color: 'white',
border: 'none',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
if (!isLoading) e.currentTarget.style.backgroundColor = '#1d4ed8';
}}
onMouseLeave={(e) => {
if (!isLoading) e.currentTarget.style.backgroundColor = '#2563eb';
}}
>
{isLoading ? '建立中...' : '創建帳號'}
</button>
</div>
</div>
)}
</form>
<StepIndicator currentStep={currentStep} totalSteps={2} />
<div
style={{
marginTop: '24px',
paddingTop: '24px',
borderTop: '1px solid #e5e7eb',
textAlign: 'center',
}}
>
<a
href="/login"
style={{
color: '#2563eb',
fontSize: '14px',
textDecoration: 'none',
fontWeight: '500',
}}
>
已有帳號?登入
</a>
</div>
</div>
);
}