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;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string[]>;
|
||||
values?: {
|
||||
email: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
};
|
||||
};
|
||||
|
||||
const ROLE_TO_PROFILE_TYPE: Record<string, ProfileType> = {
|
||||
@@ -61,11 +67,19 @@ export async function registerAction(
|
||||
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);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
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 } });
|
||||
if (existing) {
|
||||
return { success: false, error: '이미 가입된 이메일입니다' };
|
||||
return { success: false, error: '이미 가입된 이메일입니다', values: submittedValues };
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
|
||||
@@ -161,7 +175,7 @@ export async function registerAction(
|
||||
'code' in err &&
|
||||
(err as { code: string }).code === 'P2002'
|
||||
) {
|
||||
return { success: false, error: '이미 가입된 이메일입니다' };
|
||||
return { success: false, error: '이미 가입된 이메일입니다', values: submittedValues };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function RegisterPage() {
|
||||
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"
|
||||
>
|
||||
<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 className="font-medium text-ink">{role.label}</div>
|
||||
<div className="text-sm text-ink-muted">{role.desc}</div>
|
||||
@@ -79,6 +79,7 @@ export default function RegisterPage() {
|
||||
name="name"
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
{state.fieldErrors?.name && (
|
||||
@@ -95,6 +96,7 @@ export default function RegisterPage() {
|
||||
name="phone"
|
||||
type="tel"
|
||||
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"
|
||||
/>
|
||||
{state.fieldErrors?.phone && (
|
||||
@@ -111,6 +113,7 @@ export default function RegisterPage() {
|
||||
name="email"
|
||||
type="email"
|
||||
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"
|
||||
/>
|
||||
{state.fieldErrors?.email && (
|
||||
|
||||
Vendored
+27
-7
@@ -10,10 +10,22 @@ const prisma = createPrismaClient();
|
||||
|
||||
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(
|
||||
_prev: { success: boolean; message: string } | null,
|
||||
_prev: VendorActionState,
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<VendorActionState> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.dbId) {
|
||||
return { success: false, message: '로그인이 필요합니다.' };
|
||||
@@ -25,17 +37,25 @@ export async function applyVendorCertificationAction(
|
||||
const businessRegistrationNumber = formData.get('businessRegistrationNumber') as string | null;
|
||||
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)) {
|
||||
return { success: false, message: '업체 유형을 선택해주세요.' };
|
||||
return { success: false, message: '업체 유형을 선택해주세요.', values: submittedValues };
|
||||
}
|
||||
if (!businessName?.trim()) {
|
||||
return { success: false, message: '업체명을 입력해주세요.' };
|
||||
return { success: false, message: '업체명을 입력해주세요.', values: submittedValues };
|
||||
}
|
||||
if (!contactName?.trim()) {
|
||||
return { success: false, message: '담당자명을 입력해주세요.' };
|
||||
return { success: false, message: '담당자명을 입력해주세요.', values: submittedValues };
|
||||
}
|
||||
if (coverageRegionCodes.length === 0) {
|
||||
return { success: false, message: '서비스 가능 지역을 하나 이상 선택해주세요.' };
|
||||
return { success: false, message: '서비스 가능 지역을 하나 이상 선택해주세요.', values: submittedValues };
|
||||
}
|
||||
|
||||
const result = await applyVendorCertificationService(prisma, {
|
||||
@@ -48,7 +68,7 @@ export async function applyVendorCertificationAction(
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return { success: false, message: result.error.message ?? '인증 신청에 실패했습니다.' };
|
||||
return { success: false, message: result.error.message ?? '인증 신청에 실패했습니다.', values: submittedValues };
|
||||
}
|
||||
|
||||
revalidatePath('/vendors');
|
||||
|
||||
+7
-4
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { applyVendorCertificationAction } from './actions';
|
||||
import { applyVendorCertificationAction, type VendorActionState } from './actions';
|
||||
|
||||
const REGION_MAP: Record<string, string> = {
|
||||
'강남권 (역삼/선릉/논현)': 'KR.BETA.GANGNAM_CORE',
|
||||
@@ -10,10 +10,8 @@ const REGION_MAP: Record<string, string> = {
|
||||
|
||||
const REGIONS = Object.keys(REGION_MAP);
|
||||
|
||||
type ActionState = { success: boolean; message: string } | null;
|
||||
|
||||
export default function VendorApplicationForm() {
|
||||
const [state, formAction, isPending] = useActionState<ActionState, FormData>(
|
||||
const [state, formAction, isPending] = useActionState<VendorActionState, FormData>(
|
||||
applyVendorCertificationAction,
|
||||
null,
|
||||
);
|
||||
@@ -43,6 +41,7 @@ export default function VendorApplicationForm() {
|
||||
type="text"
|
||||
placeholder="예: (주)클린철거"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -51,6 +50,7 @@ export default function VendorApplicationForm() {
|
||||
<select
|
||||
name="vendorType"
|
||||
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"
|
||||
>
|
||||
<option value="">선택</option>
|
||||
@@ -69,6 +69,7 @@ export default function VendorApplicationForm() {
|
||||
type="text"
|
||||
placeholder="담당자 이름"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -78,6 +79,7 @@ export default function VendorApplicationForm() {
|
||||
name="businessRegistrationNumber"
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -92,6 +94,7 @@ export default function VendorApplicationForm() {
|
||||
type="checkbox"
|
||||
name="coverageRegions"
|
||||
value={REGION_MAP[region]}
|
||||
defaultChecked={state?.values?.coverageRegionCodes?.includes(REGION_MAP[region]!)}
|
||||
className="rounded border-ink/10 accent-warm-500"
|
||||
/>
|
||||
{region}
|
||||
|
||||
Reference in New Issue
Block a user