fix: 매장 등록 폼 에러 시 입력값 유지 (useActionState 적용)
- server action을 actions.ts로 분리 - page.tsx를 client component로 전환 (useActionState) - 에러 시 redirect 대신 state 반환 → 입력값 유지 - 제출 중 버튼 비활성화 (isPending)
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
@@ -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">
|
||||
← 매장 목록으로
|
||||
← 매장 목록으로
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user