Spaces:
Running
Running
| '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> | |
| ); | |
| } | |