fix: 매장 등록 폼 에러 시 입력값 유지 (useActionState 적용)

- server action을 actions.ts로 분리
- page.tsx를 client component로 전환 (useActionState)
- 에러 시 redirect 대신 state 반환 → 입력값 유지
- 제출 중 버튼 비활성화 (isPending)
This commit is contained in:
Johngreen
2026-03-08 23:53:12 +09:00
parent 2917cc9d0b
commit 7794a3dd8c
2 changed files with 90 additions and 86 deletions
+73
View File
@@ -0,0 +1,73 @@
'use server';
import { redirect } from 'next/navigation';
import { createPrismaClient } from '@startover/database';
import { auth } from '@/lib/auth';
import { createStoreDraftService } from '@/services/store-service';
import type { CreateStoreDraftInput } from '@startover/domain';
export type StoreFormState = {
error?: string;
};
export async function createStoreDraftAction(
_prevState: StoreFormState,
formData: FormData,
): Promise<StoreFormState> {
const session = await auth();
if (!session?.user?.dbId) {
return { error: '로그인이 필요합니다.' };
}
const listingTitle = (formData.get('listingTitle') as string | null)?.trim() ?? '';
const regionClusterCode = (formData.get('regionClusterCode') as string | null) ?? '';
const industryLeafCode = (formData.get('industryLeafCode') as string | null) ?? '';
const roadAddress = (formData.get('roadAddress') as string | null)?.trim() ?? '';
const depositAmount = formData.get('depositAmount') as string | null;
const monthlyRentAmount = formData.get('monthlyRentAmount') as string | null;
const premiumAmount = formData.get('premiumAmount') as string | null;
const remainingLeaseMonths = formData.get('remainingLeaseMonths') as string | null;
const exclusiveAreaSqm = formData.get('exclusiveAreaSqm') as string | null;
const floorLevel = formData.get('floorLevel') as string | null;
const kitchenEquipmentSummary =
(formData.get('kitchenEquipmentSummary') as string | null)?.trim() ?? '';
const input: CreateStoreDraftInput = {
ownerUserId: session.user.dbId,
listingTitle,
industryLeafCode,
regionClusterCode,
roadAddress,
...(depositAmount || monthlyRentAmount || premiumAmount || remainingLeaseMonths
? {
lease: {
depositAmount: depositAmount ? Number(depositAmount) : 0,
monthlyRentAmount: monthlyRentAmount ? Number(monthlyRentAmount) : 0,
premiumAmount: premiumAmount ? Number(premiumAmount) : 0,
remainingLeaseMonths: remainingLeaseMonths
? parseInt(remainingLeaseMonths, 10)
: undefined,
},
}
: {}),
...(exclusiveAreaSqm || floorLevel || kitchenEquipmentSummary
? {
facility: {
exclusiveAreaSqm: exclusiveAreaSqm ? Number(exclusiveAreaSqm) : 0,
seatCount: 0,
floorLevel: floorLevel ? parseInt(floorLevel, 10) : undefined,
kitchenEquipmentSummary: kitchenEquipmentSummary || undefined,
},
}
: {}),
};
const prisma = createPrismaClient();
const result = await createStoreDraftService(prisma, input);
if (!result.ok) {
return { error: result.error.message };
}
redirect(`/stores/${result.value.publicId}`);
}
+17 -86
View File
@@ -1,83 +1,19 @@
'use client';
import { useActionState } from 'react';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { createPrismaClient } from '@startover/database';
import { auth } from '@/lib/auth';
import { createStoreDraftService } from '@/services/store-service';
import type { CreateStoreDraftInput } from '@startover/domain';
import { createStoreDraftAction, type StoreFormState } from './actions';
export const dynamic = 'force-dynamic';
const initialState: StoreFormState = {};
async function createStoreDraftAction(formData: FormData) {
'use server';
export default function NewStorePage() {
const [state, formAction, isPending] = useActionState(createStoreDraftAction, initialState);
const session = await auth();
if (!session?.user?.dbId) {
redirect('/auth/signin');
}
const listingTitle = (formData.get('listingTitle') as string | null)?.trim() ?? '';
const regionClusterCode = (formData.get('regionClusterCode') as string | null) ?? '';
const industryLeafCode = (formData.get('industryLeafCode') as string | null) ?? '';
const roadAddress = (formData.get('roadAddress') as string | null)?.trim() ?? '';
const depositAmount = formData.get('depositAmount') as string | null;
const monthlyRentAmount = formData.get('monthlyRentAmount') as string | null;
const premiumAmount = formData.get('premiumAmount') as string | null;
const remainingLeaseMonths = formData.get('remainingLeaseMonths') as string | null;
const exclusiveAreaSqm = formData.get('exclusiveAreaSqm') as string | null;
const floorLevel = formData.get('floorLevel') as string | null;
const kitchenEquipmentSummary =
(formData.get('kitchenEquipmentSummary') as string | null)?.trim() ?? '';
const input: CreateStoreDraftInput = {
ownerUserId: session.user.dbId,
listingTitle,
industryLeafCode,
regionClusterCode,
roadAddress,
...(depositAmount || monthlyRentAmount || premiumAmount || remainingLeaseMonths
? {
lease: {
depositAmount: depositAmount ? Number(depositAmount) : 0,
monthlyRentAmount: monthlyRentAmount ? Number(monthlyRentAmount) : 0,
premiumAmount: premiumAmount ? Number(premiumAmount) : 0,
remainingLeaseMonths: remainingLeaseMonths
? parseInt(remainingLeaseMonths, 10)
: undefined,
},
}
: {}),
...(exclusiveAreaSqm || floorLevel || kitchenEquipmentSummary
? {
facility: {
exclusiveAreaSqm: exclusiveAreaSqm ? Number(exclusiveAreaSqm) : 0,
seatCount: 0,
floorLevel: floorLevel ? parseInt(floorLevel, 10) : undefined,
kitchenEquipmentSummary: kitchenEquipmentSummary || undefined,
},
}
: {}),
};
const prisma = createPrismaClient();
const result = await createStoreDraftService(prisma, input);
if (!result.ok) {
redirect(`/stores/new?error=${encodeURIComponent(result.error.message)}`);
}
redirect(`/stores/${result.value.publicId}`);
}
export default function NewStorePage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
return (
<main className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-6">
<Link href="/stores" className="text-sm text-blue-600 hover:underline">
&larr;
</Link>
</div>
@@ -86,9 +22,13 @@ export default function NewStorePage({
, ,
</p>
<ErrorBanner searchParams={searchParams} />
{state.error && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{state.error}
</div>
)}
<form action={createStoreDraftAction} className="mt-8 space-y-8">
<form action={formAction} className="mt-8 space-y-8">
{/* 기본 정보 */}
<section>
<h2 className="mb-4 text-lg font-semibold text-gray-900"> </h2>
@@ -234,9 +174,10 @@ export default function NewStorePage({
<div className="flex gap-3">
<button
type="submit"
className="rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
disabled={isPending}
className="rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? '등록 중...' : '매장 등록'}
</button>
<Link
href="/stores"
@@ -249,13 +190,3 @@ export default function NewStorePage({
</main>
);
}
async function ErrorBanner({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const params = await searchParams;
if (!params.error) return null;
return (
<div className="mt-4 rounded-md bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{params.error}
</div>
);
}