[feat]: 회원가입 페이지 개선 및 폼 자동 포맷팅 기능 추가

주요 변경사항:
- 회원가입 폼에 사업자등록번호 자동 포맷팅 (000-00-00000)
- 핸드폰 번호 자동 포맷팅 (010-1111-1111 / 010-111-1111)
- 약관 전체 동의 체크박스 추가 및 개별 약관 연동
- 모든 입력 필드에 autocomplete 속성 추가 (브라우저 자동완성 지원)
- 회원가입 API 연동 및 백엔드 통신 구현
- LoginPage 폼 태그 추가 및 DOM 경고 수정
- LanguageSelect 언어 변경 시 전체 페이지 새로고침으로 변경
- 다국어 번역 키 추가 (ko, en, ja)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-11-10 17:25:56 +09:00
parent a2453d86f2
commit fa7f62383d
16 changed files with 872 additions and 184 deletions

View File

@@ -65,15 +65,19 @@ export function LoginPage() {
// 대시보드로 이동
router.push("/dashboard");
} catch (err: any) {
} catch (err) {
console.error('❌ 로그인 실패:', err);
if (err.status === 422) {
const error = err as { status?: number; message?: string };
if (error.status === 401 || error.status === 422) {
setError(t('invalidCredentials'));
} else if (err.status === 429) {
setError('너무 많은 로그인 시도가 있었습니다. 잠시 후 다시 시도해주세요.');
} else if (error.status === 429) {
setError('Too many login attempts. Please try again later.');
} else if (error.status && error.status >= 500) {
setError('Service temporarily unavailable. Please try again later.');
} else {
setError(err.message || t('invalidCredentials'));
setError(error.message || t('invalidCredentials'));
}
}
};
@@ -125,7 +129,7 @@ export function LoginPage() {
</div>
)}
<div className="space-y-4">
<form onSubmit={(e) => { e.preventDefault(); handleLogin(); }} className="space-y-4">
<div>
<Label htmlFor="userId" className="flex items-center space-x-2 mb-2">
<User className="w-4 h-4" />
@@ -133,11 +137,12 @@ export function LoginPage() {
</Label>
<Input
id="userId"
name="username"
type="text"
autoComplete="username"
placeholder={t('userIdPlaceholder')}
value={userId}
onChange={(e) => setUserId(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
className="clean-input"
/>
</div>
@@ -150,11 +155,12 @@ export function LoginPage() {
<div className="relative">
<Input
id="password"
name="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
placeholder={t('passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
className="clean-input pr-10"
/>
<button
@@ -181,19 +187,19 @@ export function LoginPage() {
/>
<span className="text-sm text-muted-foreground">{t('rememberMe')}</span>
</label>
<button className="text-sm text-primary hover:underline">
<button type="button" className="text-sm text-primary hover:underline">
{t('forgotPassword')}
</button>
</div>
</div>
<Button
onClick={handleLogin}
className="w-full rounded-xl bg-primary hover:bg-primary/90"
>
{t('login')}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
<Button
type="submit"
className="w-full rounded-xl bg-primary hover:bg-primary/90"
>
{t('login')}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -23,6 +24,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
export function SignupPage() {
const router = useRouter();
const t = useTranslations("auth");
const tSignup = useTranslations("signup");
const tValidation = useTranslations("validation");
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({
// 회사 정보
@@ -53,20 +57,116 @@ export function SignupPage() {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = () => {
// 회원가입 처리 (실제로는 API 호출)
const userData = {
...formData,
role: "CEO", // 기본 역할
};
// 사업자등록번호 자동 포맷팅 (000-00-00000)
const formatBusinessNumber = (value: string) => {
// 숫자만 추출
const numbers = value.replace(/[^\d]/g, '');
// Save user data to localStorage (client-side only)
if (typeof window !== "undefined") {
localStorage.setItem("user", JSON.stringify(userData));
// 최대 10자리까지만
const limited = numbers.slice(0, 10);
// 형식에 맞게 하이픈 추가
if (limited.length <= 3) {
return limited;
} else if (limited.length <= 5) {
return `${limited.slice(0, 3)}-${limited.slice(3)}`;
} else {
return `${limited.slice(0, 3)}-${limited.slice(3, 5)}-${limited.slice(5)}`;
}
};
// Navigate to dashboard
router.push("/dashboard");
const handleBusinessNumberChange = (value: string) => {
const formatted = formatBusinessNumber(value);
handleInputChange("businessNumber", formatted);
};
// 핸드폰 번호 자동 포맷팅 (010-1111-1111 or 010-111-1111)
const formatPhoneNumber = (value: string) => {
// 숫자만 추출
const numbers = value.replace(/[^\d]/g, '');
// 최대 11자리까지만
const limited = numbers.slice(0, 11);
// 형식에 맞게 하이픈 추가
if (limited.length <= 3) {
return limited;
} else if (limited.length <= 6) {
return `${limited.slice(0, 3)}-${limited.slice(3)}`;
} else if (limited.length === 10) {
// 10자리: 010-111-1111
return `${limited.slice(0, 3)}-${limited.slice(3, 6)}-${limited.slice(6)}`;
} else {
// 11자리: 010-1111-1111
return `${limited.slice(0, 3)}-${limited.slice(3, 7)}-${limited.slice(7)}`;
}
};
const handlePhoneNumberChange = (value: string) => {
const formatted = formatPhoneNumber(value);
handleInputChange("phone", formatted);
};
// 전체 동의 처리
const handleAgreeAll = (checked: boolean) => {
setFormData(prev => ({
...prev,
agreeTerms: checked,
agreePrivacy: checked,
}));
};
// 전체 동의 체크박스 상태 계산
const isAllAgreed = formData.agreeTerms && formData.agreePrivacy;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async () => {
setIsLoading(true);
setError("");
try {
// Prepare request body matching backend format
const requestBody = {
user_id: formData.userId,
name: formData.name,
email: formData.email,
phone: formData.phone,
password: formData.password,
password_confirmation: formData.passwordConfirm,
position: formData.position || "",
company_name: formData.companyName,
business_num: formData.businessNumber,
company_scale: formData.companySize,
industry: formData.industry,
};
const response = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
credentials: 'include',
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Registration failed');
setIsLoading(false);
return;
}
console.log('✅ Signup successful:', data);
// Navigate to login page after successful signup
router.push("/login?registered=true");
} catch (err) {
console.error('Signup error:', err);
setError('Network error. Please try again.');
setIsLoading(false);
}
};
const [stepErrors, setStepErrors] = useState<{ [key: string]: string }>({});
@@ -145,14 +245,14 @@ export function SignupPage() {
</div>
<div>
<h1 className="text-xl font-bold tracking-wide">SAM</h1>
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">{t("signupTitle")}</p>
</div>
</button>
<div className="flex items-center gap-3">
<ThemeSelect />
<LanguageSelect />
<Button variant="ghost" onClick={() => router.push("/login")} className="rounded-xl">
{t("login")}
</Button>
</div>
</div>
@@ -189,13 +289,13 @@ export function SignupPage() {
</div>
<div className="flex justify-between text-sm">
<span className={step >= 1 ? "text-foreground font-medium" : "text-muted-foreground"}>
{t("companyInfo")}
</span>
<span className={step >= 2 ? "text-foreground font-medium" : "text-muted-foreground"}>
{t("userInfo")}
</span>
<span className={step >= 3 ? "text-foreground font-medium" : "text-muted-foreground"}>
{t("planSelection")}
</span>
</div>
</div>
@@ -204,19 +304,21 @@ export function SignupPage() {
{step === 1 && (
<div className="clean-glass rounded-2xl p-8 clean-shadow space-y-6">
<div>
<h2 className="mb-2 text-foreground"> </h2>
<p className="text-muted-foreground">MES </p>
<h2 className="mb-2 text-foreground">{t("step1Title")}</h2>
<p className="text-muted-foreground">{t("step1Desc")}</p>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="companyName" className="flex items-center space-x-2 mb-2">
<Building2 className="w-4 h-4" />
<span> *</span>
<span>{t("companyName")} {t("required")}</span>
</Label>
<Input
id="companyName"
placeholder="예: 삼성전자"
name="company_name"
autoComplete="organization"
placeholder={t("companyNamePlaceholder")}
value={formData.companyName}
onChange={(e) => handleInputChange("companyName", e.target.value)}
className="clean-input"
@@ -226,13 +328,15 @@ export function SignupPage() {
<div>
<Label htmlFor="businessNumber" className="flex items-center space-x-2 mb-2">
<FileText className="w-4 h-4" />
<span> *</span>
<span>{t("businessNumber")} {t("required")}</span>
</Label>
<Input
id="businessNumber"
placeholder="000-00-00000"
name="business_number"
autoComplete="off"
placeholder={t("businessNumberPlaceholder")}
value={formData.businessNumber}
onChange={(e) => handleInputChange("businessNumber", e.target.value)}
onChange={(e) => handleBusinessNumberChange(e.target.value)}
className="clean-input"
/>
</div>
@@ -240,21 +344,21 @@ export function SignupPage() {
<div>
<Label htmlFor="industry" className="flex items-center space-x-2 mb-2">
<Briefcase className="w-4 h-4" />
<span> *</span>
<span>{t("industry")} {t("required")}</span>
</Label>
<Select value={formData.industry} onValueChange={(value) => handleInputChange("industry", value)}>
<SelectTrigger className="clean-input">
<SelectValue placeholder="업종을 선택하세요" />
<SelectValue placeholder={t("industryPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="electronics">/</SelectItem>
<SelectItem value="machinery">/</SelectItem>
<SelectItem value="automotive">/</SelectItem>
<SelectItem value="chemical">/</SelectItem>
<SelectItem value="food">/</SelectItem>
<SelectItem value="textile">/</SelectItem>
<SelectItem value="metal">/</SelectItem>
<SelectItem value="other"> </SelectItem>
<SelectItem value="electronics">{tSignup("industries.electronics")}</SelectItem>
<SelectItem value="machinery">{tSignup("industries.machinery")}</SelectItem>
<SelectItem value="automotive">{tSignup("industries.automotive")}</SelectItem>
<SelectItem value="chemical">{tSignup("industries.chemical")}</SelectItem>
<SelectItem value="food">{tSignup("industries.food")}</SelectItem>
<SelectItem value="textile">{tSignup("industries.textile")}</SelectItem>
<SelectItem value="metal">{tSignup("industries.metal")}</SelectItem>
<SelectItem value="other">{tSignup("industries.other")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -262,16 +366,16 @@ export function SignupPage() {
<div>
<Label htmlFor="companySize" className="flex items-center space-x-2 mb-2">
<Users className="w-4 h-4" />
<span> *</span>
<span>{t("companySize")} {t("required")}</span>
</Label>
<Select value={formData.companySize} onValueChange={(value) => handleInputChange("companySize", value)}>
<SelectTrigger className="clean-input">
<SelectValue placeholder="기업 규모를 선택하세요" />
<SelectValue placeholder={t("companySizePlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="small"> ( 10-50)</SelectItem>
<SelectItem value="medium"> ( 50-300)</SelectItem>
<SelectItem value="large"> ( 300 )</SelectItem>
<SelectItem value="small">{tSignup("companySizes.small")}</SelectItem>
<SelectItem value="medium">{tSignup("companySizes.medium")}</SelectItem>
<SelectItem value="large">{tSignup("companySizes.large")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -292,7 +396,7 @@ export function SignupPage() {
disabled={!isStep1Valid}
className="w-full rounded-xl bg-primary hover:bg-primary/90"
>
{t("nextStep")}
</Button>
</div>
)}
@@ -301,19 +405,21 @@ export function SignupPage() {
{step === 2 && (
<div className="clean-glass rounded-2xl p-8 clean-shadow space-y-6">
<div>
<h2 className="mb-2 text-foreground"> </h2>
<p className="text-muted-foreground"> </p>
<h2 className="mb-2 text-foreground">{t("step2Title")}</h2>
<p className="text-muted-foreground">{t("step2Desc")}</p>
</div>
<div className="space-y-4">
<form onSubmit={(e) => { e.preventDefault(); if (validateStep2()) setStep(3); }} className="space-y-4">
<div>
<Label htmlFor="name" className="flex items-center space-x-2 mb-2">
<User className="w-4 h-4"/>
<span> *</span>
<span>{t("name")} {t("required")}</span>
</Label>
<Input
id="name"
placeholder="홍길동"
name="name"
autoComplete="name"
placeholder={t("namePlaceholder")}
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
className="clean-input"
@@ -323,11 +429,13 @@ export function SignupPage() {
<div>
<Label htmlFor="position" className="flex items-center space-x-2 mb-2">
<Briefcase className="w-4 h-4"/>
<span></span>
<span>{t("position")} {t("optional")}</span>
</Label>
<Input
id="position"
placeholder="예: 생산관리팀장"
name="position"
autoComplete="organization-title"
placeholder={t("positionPlaceholder")}
value={formData.position}
onChange={(e) => handleInputChange("position", e.target.value)}
className="clean-input"
@@ -337,12 +445,14 @@ export function SignupPage() {
<div>
<Label htmlFor="email" className="flex items-center space-x-2 mb-2">
<Mail className="w-4 h-4"/>
<span> *</span>
<span>{t("email")} {t("required")}</span>
</Label>
<Input
id="email"
name="email"
type="email"
placeholder="example@company.com"
autoComplete="email"
placeholder={t("emailPlaceholder")}
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
className="clean-input"
@@ -352,13 +462,16 @@ export function SignupPage() {
<div>
<Label htmlFor="phone" className="flex items-center space-x-2 mb-2">
<Phone className="w-4 h-4"/>
<span> *</span>
<span>{t("phone")} {t("required")}</span>
</Label>
<Input
id="phone"
placeholder="010-0000-0000"
name="phone"
type="tel"
autoComplete="tel"
placeholder={t("phonePlaceholder")}
value={formData.phone}
onChange={(e) => handleInputChange("phone", e.target.value)}
onChange={(e) => handlePhoneNumberChange(e.target.value)}
className="clean-input"
/>
</div>
@@ -366,11 +479,13 @@ export function SignupPage() {
<div>
<Label htmlFor="userId" className="flex items-center space-x-2 mb-2">
<User className="w-4 h-4"/>
<span> *</span>
<span>{t("userId")} {t("required")}</span>
</Label>
<Input
id="userId"
placeholder="영문, 숫자 조합 6자 이상"
name="user_id"
autoComplete="username"
placeholder={t("userIdPlaceholder2")}
value={formData.userId}
onChange={(e) => handleInputChange("userId", e.target.value)}
className="clean-input"
@@ -380,12 +495,14 @@ export function SignupPage() {
<div>
<Label htmlFor="password" className="flex items-center space-x-2 mb-2">
<Lock className="w-4 h-4"/>
<span> *</span>
<span>{t("password")} {t("required")}</span>
</Label>
<Input
id="password"
name="password"
type="password"
placeholder="8자 이상 입력"
autoComplete="new-password"
placeholder={t("passwordPlaceholder2")}
value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)}
className="clean-input"
@@ -395,49 +512,48 @@ export function SignupPage() {
<div>
<Label htmlFor="passwordConfirm" className="flex items-center space-x-2 mb-2">
<Lock className="w-4 h-4"/>
<span> *</span>
<span>{t("passwordConfirm")} {t("required")}</span>
</Label>
<Input
id="passwordConfirm"
name="password_confirm"
type="password"
placeholder="비밀번호 재입력"
autoComplete="new-password"
placeholder={t("passwordConfirmPlaceholder")}
value={formData.passwordConfirm}
onChange={(e) => handleInputChange("passwordConfirm", e.target.value)}
className="clean-input"
/>
{formData.passwordConfirm && formData.password !== formData.passwordConfirm && (
<p className="text-sm text-destructive mt-1"> </p>
<p className="text-sm text-destructive mt-1">{tValidation("passwordMismatch")}</p>
)}
</div>
</div>
{stepErrors.step2 && (
<div className="bg-destructive/10 border border-destructive/30 rounded-xl p-4">
<p className="text-sm text-destructive text-center">{stepErrors.step2}</p>
{stepErrors.step2 && (
<div className="bg-destructive/10 border border-destructive/30 rounded-xl p-4">
<p className="text-sm text-destructive text-center">{stepErrors.step2}</p>
</div>
)}
<div className="flex space-x-4">
<Button
type="button"
variant="outline"
onClick={() => setStep(1)}
className="flex-1 rounded-xl"
>
<ArrowLeft className="w-4 h-4 mr-2"/>
{t("previousStep")}
</Button>
<Button
type="submit"
disabled={!isStep2Valid}
className="flex-1 rounded-xl bg-primary hover:bg-primary/90"
>
{t("nextStep")}
</Button>
</div>
)}
<div className="flex space-x-4">
<Button
variant="outline"
onClick={() => setStep(1)}
className="flex-1 rounded-xl"
>
<ArrowLeft className="w-4 h-4 mr-2"/>
</Button>
<Button
onClick={() => {
if (validateStep2()) {
setStep(3);
}
}}
disabled={!isStep2Valid}
className="flex-1 rounded-xl bg-primary hover:bg-primary/90"
>
</Button>
</div>
</form>
</div>
)}
@@ -521,29 +637,51 @@ export function SignupPage() {
</div>
*/}
<div className="space-y-3 pt-4 border-t border-border">
<label className="flex items-start space-x-3 cursor-pointer">
{/* 전체 동의 */}
<label className="flex items-center space-x-3 cursor-pointer pt-3 pb-3 pe-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
<input
type="checkbox"
checked={formData.agreeTerms}
onChange={(e) => handleInputChange("agreeTerms", e.target.checked)}
className="mt-1 w-4 h-4 rounded border-border"
checked={isAllAgreed}
onChange={(e) => handleAgreeAll(e.target.checked)}
className="w-5 h-5 rounded border-border flex-shrink-0"
/>
<span className="text-sm">
<span className="font-medium text-foreground">[]</span>
</span>
</label>
<label className="flex items-start space-x-3 cursor-pointer">
<input
type="checkbox"
checked={formData.agreePrivacy}
onChange={(e) => handleInputChange("agreePrivacy", e.target.checked)}
className="mt-1 w-4 h-4 rounded border-border"
/>
<span className="text-sm">
<span className="font-medium text-foreground">[]</span>
<span className="text-sm font-semibold text-foreground">
{t("agreeAll")}
</span>
</label>
{/* 개별 약관 */}
<div className="space-y-2">
<label className="flex items-center space-x-3 cursor-pointer">
<input
type="checkbox"
checked={formData.agreeTerms}
onChange={(e) => handleInputChange("agreeTerms", e.target.checked)}
className="w-4 h-4 rounded border-border flex-shrink-0"
/>
<span className="text-sm">
<span className="font-medium text-foreground">{t("required")}</span> {t("agreeTerms")}
</span>
</label>
<label className="flex items-center space-x-3 cursor-pointer">
<input
type="checkbox"
checked={formData.agreePrivacy}
onChange={(e) => handleInputChange("agreePrivacy", e.target.checked)}
className="w-4 h-4 rounded border-border flex-shrink-0"
/>
<span className="text-sm">
<span className="font-medium text-foreground">{t("required")}</span> {t("agreePrivacy")}
</span>
</label>
</div>
</div>
{error && (
<div className="bg-destructive/10 border border-destructive/30 rounded-xl p-4">
<p className="text-sm text-destructive text-center">{error}</p>
</div>
)}
</div>
<div className="flex space-x-4">
@@ -551,20 +689,21 @@ export function SignupPage() {
variant="outline"
onClick={() => setStep(2)}
className="flex-1 rounded-xl"
disabled={isLoading}
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("previousStep")}
</Button>
<Button
onClick={() => {
if (validateStep3()) {
handleSubmit();
void handleSubmit();
}
}}
disabled={!isStep3Valid}
disabled={!isStep3Valid || isLoading}
className="flex-1 rounded-xl bg-primary hover:bg-primary/90"
>
{isLoading ? t("processing") || "처리 중..." : t("complete")}
</Button>
</div>
</div>
@@ -573,12 +712,12 @@ export function SignupPage() {
{/* Login Link */}
<div className="text-center mt-6">
<p className="text-sm text-muted-foreground">
?{" "}
{t("alreadyHaveAccount")}{" "}
<button
onClick={() => router.push("/login")}
className="text-primary font-medium hover:underline"
>
{t("login")}
</button>
</p>
</div>