fix: 폼 에러 시 사용자 입력값 유지 (회원가입, 업체 인증 신청)
useActionState 사용하는 폼에서 서버 에러 반환 시 입력값이 초기화되는 문제 수정. 서버 액션이 에러 시 submitted values를 함께 반환하고, 폼 input에 defaultValue 바인딩. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,12 @@ export type RegisterFormState = {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
fieldErrors?: Record<string, string[]>;
|
fieldErrors?: Record<string, string[]>;
|
||||||
|
values?: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_TO_PROFILE_TYPE: Record<string, ProfileType> = {
|
const ROLE_TO_PROFILE_TYPE: Record<string, ProfileType> = {
|
||||||
@@ -61,11 +67,19 @@ export async function registerAction(
|
|||||||
kakaoNotification: formData.get('kakaoNotification') === 'on',
|
kakaoNotification: formData.get('kakaoNotification') === 'on',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submittedValues = {
|
||||||
|
email: String(raw.email || ''),
|
||||||
|
name: String(raw.name || ''),
|
||||||
|
phone: String(raw.phone || ''),
|
||||||
|
role: String(raw.role || ''),
|
||||||
|
};
|
||||||
|
|
||||||
const parsed = registerSchema.safeParse(raw);
|
const parsed = registerSchema.safeParse(raw);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
|
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
|
||||||
|
values: submittedValues,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +88,7 @@ export async function registerAction(
|
|||||||
|
|
||||||
const existing = await prisma.user.findFirst({ where: { emailNormalized } });
|
const existing = await prisma.user.findFirst({ where: { emailNormalized } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return { success: false, error: '이미 가입된 이메일입니다' };
|
return { success: false, error: '이미 가입된 이메일입니다', values: submittedValues };
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
|
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
|
||||||
@@ -161,7 +175,7 @@ export async function registerAction(
|
|||||||
'code' in err &&
|
'code' in err &&
|
||||||
(err as { code: string }).code === 'P2002'
|
(err as { code: string }).code === 'P2002'
|
||||||
) {
|
) {
|
||||||
return { success: false, error: '이미 가입된 이메일입니다' };
|
return { success: false, error: '이미 가입된 이메일입니다', values: submittedValues };
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function RegisterPage() {
|
|||||||
key={role.value}
|
key={role.value}
|
||||||
className="flex cursor-pointer items-center gap-3 rounded-xl border-2 border-ink/5 p-4 transition-all hover:border-warm-400 hover:bg-warm-50"
|
className="flex cursor-pointer items-center gap-3 rounded-xl border-2 border-ink/5 p-4 transition-all hover:border-warm-400 hover:bg-warm-50"
|
||||||
>
|
>
|
||||||
<input type="radio" name="role" value={role.value} required className="h-4 w-4" />
|
<input type="radio" name="role" value={role.value} required className="h-4 w-4" defaultChecked={state.values?.role === role.value} />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-ink">{role.label}</div>
|
<div className="font-medium text-ink">{role.label}</div>
|
||||||
<div className="text-sm text-ink-muted">{role.desc}</div>
|
<div className="text-sm text-ink-muted">{role.desc}</div>
|
||||||
@@ -79,6 +79,7 @@ export default function RegisterPage() {
|
|||||||
name="name"
|
name="name"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
|
defaultValue={state.values?.name}
|
||||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink placeholder:text-ink-muted focus:border-warm-500 focus:outline-none focus:ring-2 focus:ring-warm-500/20"
|
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink placeholder:text-ink-muted focus:border-warm-500 focus:outline-none focus:ring-2 focus:ring-warm-500/20"
|
||||||
/>
|
/>
|
||||||
{state.fieldErrors?.name && (
|
{state.fieldErrors?.name && (
|
||||||
@@ -95,6 +96,7 @@ export default function RegisterPage() {
|
|||||||
name="phone"
|
name="phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
placeholder="010-1234-5678"
|
placeholder="010-1234-5678"
|
||||||
|
defaultValue={state.values?.phone}
|
||||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink placeholder:text-ink-muted focus:border-warm-500 focus:outline-none focus:ring-2 focus:ring-warm-500/20"
|
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink placeholder:text-ink-muted focus:border-warm-500 focus:outline-none focus:ring-2 focus:ring-warm-500/20"
|
||||||
/>
|
/>
|
||||||
{state.fieldErrors?.phone && (
|
{state.fieldErrors?.phone && (
|
||||||
@@ -111,6 +113,7 @@ export default function RegisterPage() {
|
|||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
|
defaultValue={state.values?.email}
|
||||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink placeholder:text-ink-muted focus:border-warm-500 focus:outline-none focus:ring-2 focus:ring-warm-500/20"
|
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink placeholder:text-ink-muted focus:border-warm-500 focus:outline-none focus:ring-2 focus:ring-warm-500/20"
|
||||||
/>
|
/>
|
||||||
{state.fieldErrors?.email && (
|
{state.fieldErrors?.email && (
|
||||||
|
|||||||
Vendored
+27
-7
@@ -10,10 +10,22 @@ const prisma = createPrismaClient();
|
|||||||
|
|
||||||
const VALID_VENDOR_TYPES = new Set<string>(['DEMOLITION', 'INTERIOR', 'ACQUISITION']);
|
const VALID_VENDOR_TYPES = new Set<string>(['DEMOLITION', 'INTERIOR', 'ACQUISITION']);
|
||||||
|
|
||||||
|
export type VendorActionState = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
values?: {
|
||||||
|
businessName: string;
|
||||||
|
vendorType: string;
|
||||||
|
contactName: string;
|
||||||
|
businessRegistrationNumber: string;
|
||||||
|
coverageRegionCodes: string[];
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
|
||||||
export async function applyVendorCertificationAction(
|
export async function applyVendorCertificationAction(
|
||||||
_prev: { success: boolean; message: string } | null,
|
_prev: VendorActionState,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<VendorActionState> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.dbId) {
|
if (!session?.user?.dbId) {
|
||||||
return { success: false, message: '로그인이 필요합니다.' };
|
return { success: false, message: '로그인이 필요합니다.' };
|
||||||
@@ -25,17 +37,25 @@ export async function applyVendorCertificationAction(
|
|||||||
const businessRegistrationNumber = formData.get('businessRegistrationNumber') as string | null;
|
const businessRegistrationNumber = formData.get('businessRegistrationNumber') as string | null;
|
||||||
const coverageRegionCodes = formData.getAll('coverageRegions') as string[];
|
const coverageRegionCodes = formData.getAll('coverageRegions') as string[];
|
||||||
|
|
||||||
|
const submittedValues = {
|
||||||
|
businessName: businessName || '',
|
||||||
|
vendorType: vendorType || '',
|
||||||
|
contactName: contactName || '',
|
||||||
|
businessRegistrationNumber: businessRegistrationNumber || '',
|
||||||
|
coverageRegionCodes,
|
||||||
|
};
|
||||||
|
|
||||||
if (!vendorType || !VALID_VENDOR_TYPES.has(vendorType)) {
|
if (!vendorType || !VALID_VENDOR_TYPES.has(vendorType)) {
|
||||||
return { success: false, message: '업체 유형을 선택해주세요.' };
|
return { success: false, message: '업체 유형을 선택해주세요.', values: submittedValues };
|
||||||
}
|
}
|
||||||
if (!businessName?.trim()) {
|
if (!businessName?.trim()) {
|
||||||
return { success: false, message: '업체명을 입력해주세요.' };
|
return { success: false, message: '업체명을 입력해주세요.', values: submittedValues };
|
||||||
}
|
}
|
||||||
if (!contactName?.trim()) {
|
if (!contactName?.trim()) {
|
||||||
return { success: false, message: '담당자명을 입력해주세요.' };
|
return { success: false, message: '담당자명을 입력해주세요.', values: submittedValues };
|
||||||
}
|
}
|
||||||
if (coverageRegionCodes.length === 0) {
|
if (coverageRegionCodes.length === 0) {
|
||||||
return { success: false, message: '서비스 가능 지역을 하나 이상 선택해주세요.' };
|
return { success: false, message: '서비스 가능 지역을 하나 이상 선택해주세요.', values: submittedValues };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await applyVendorCertificationService(prisma, {
|
const result = await applyVendorCertificationService(prisma, {
|
||||||
@@ -48,7 +68,7 @@ export async function applyVendorCertificationAction(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return { success: false, message: result.error.message ?? '인증 신청에 실패했습니다.' };
|
return { success: false, message: result.error.message ?? '인증 신청에 실패했습니다.', values: submittedValues };
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath('/vendors');
|
revalidatePath('/vendors');
|
||||||
|
|||||||
+7
-4
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useActionState } from 'react';
|
import { useActionState } from 'react';
|
||||||
import { applyVendorCertificationAction } from './actions';
|
import { applyVendorCertificationAction, type VendorActionState } from './actions';
|
||||||
|
|
||||||
const REGION_MAP: Record<string, string> = {
|
const REGION_MAP: Record<string, string> = {
|
||||||
'강남권 (역삼/선릉/논현)': 'KR.BETA.GANGNAM_CORE',
|
'강남권 (역삼/선릉/논현)': 'KR.BETA.GANGNAM_CORE',
|
||||||
@@ -10,10 +10,8 @@ const REGION_MAP: Record<string, string> = {
|
|||||||
|
|
||||||
const REGIONS = Object.keys(REGION_MAP);
|
const REGIONS = Object.keys(REGION_MAP);
|
||||||
|
|
||||||
type ActionState = { success: boolean; message: string } | null;
|
|
||||||
|
|
||||||
export default function VendorApplicationForm() {
|
export default function VendorApplicationForm() {
|
||||||
const [state, formAction, isPending] = useActionState<ActionState, FormData>(
|
const [state, formAction, isPending] = useActionState<VendorActionState, FormData>(
|
||||||
applyVendorCertificationAction,
|
applyVendorCertificationAction,
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -43,6 +41,7 @@ export default function VendorApplicationForm() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="예: (주)클린철거"
|
placeholder="예: (주)클린철거"
|
||||||
required
|
required
|
||||||
|
defaultValue={state?.values?.businessName}
|
||||||
className="mt-1 w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
className="mt-1 w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,6 +50,7 @@ export default function VendorApplicationForm() {
|
|||||||
<select
|
<select
|
||||||
name="vendorType"
|
name="vendorType"
|
||||||
required
|
required
|
||||||
|
defaultValue={state?.values?.vendorType}
|
||||||
className="mt-1 w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
className="mt-1 w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">선택</option>
|
<option value="">선택</option>
|
||||||
@@ -69,6 +69,7 @@ export default function VendorApplicationForm() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="담당자 이름"
|
placeholder="담당자 이름"
|
||||||
required
|
required
|
||||||
|
defaultValue={state?.values?.contactName}
|
||||||
className="mt-1 w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
className="mt-1 w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,6 +79,7 @@ export default function VendorApplicationForm() {
|
|||||||
name="businessRegistrationNumber"
|
name="businessRegistrationNumber"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="123-45-67890"
|
placeholder="123-45-67890"
|
||||||
|
defaultValue={state?.values?.businessRegistrationNumber}
|
||||||
className="mt-1 w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
className="mt-1 w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,6 +94,7 @@ export default function VendorApplicationForm() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="coverageRegions"
|
name="coverageRegions"
|
||||||
value={REGION_MAP[region]}
|
value={REGION_MAP[region]}
|
||||||
|
defaultChecked={state?.values?.coverageRegionCodes?.includes(REGION_MAP[region]!)}
|
||||||
className="rounded border-ink/10 accent-warm-500"
|
className="rounded border-ink/10 accent-warm-500"
|
||||||
/>
|
/>
|
||||||
{region}
|
{region}
|
||||||
|
|||||||
Reference in New Issue
Block a user