Skip to content

Commit 878c203

Browse files
youngminssclaude
andauthored
refactor: 모임 생성 폼을 zod 기반 validation으로 리팩토링 (#65)
* feat: CreateMeetingForm zod 스키마 추가 모임 생성 폼의 validation을 zod 기반으로 관리하기 위한 스키마 정의 - peopleCount, scheduledDate, timeSlot, region 필드 정의 - 기존 validateDateInput 유틸 함수 재사용 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: 모임 생성 폼을 zod 기반 validation으로 리팩토링 - useCreateMeetingForm에 zodResolver 적용 및 submit 로직 통합 - Step 컴포넌트에서 rules 객체 제거 - page.tsx에서 submit 로직 제거하여 간소화 - CreateMeetingForm 인터페이스를 스키마 타입으로 대체 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: DATE_ERROR_MESSAGES 중복 제거 및 formState 활용 - DATE_ERROR_MESSAGES를 유틸로 추출하여 공유 - DateStep에서 fieldState.error 사용으로 변경 - DateStepFooter에서 formState.errors 기반으로 버튼 활성화 판단 - 스키마에서 10자리 미만일 때는 validation 건너뛰도록 수정 (이전 동작 유지) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: useCreateMeetingForm 조건문 포맷팅 정리 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 557f4b8 commit 878c203

File tree

12 files changed

+139
-112
lines changed

12 files changed

+139
-112
lines changed

app/gathering/create/page.tsx

Lines changed: 3 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,14 @@ import {
1818
useCreateMeetingForm,
1919
useCreateMeetingFunnel,
2020
} from "#/hooks/gathering";
21-
import { useCreateGathering } from "#/hooks/apis/gathering";
2221
import { Toaster } from "#/components/toast";
23-
import { isApiError } from "#/utils/api";
24-
import { toast } from "#/utils/toast";
25-
import type { CreateMeetingForm } from "#/types/gathering";
2622

2723
export default function GatheringCreatePage() {
2824
const router = useRouter();
29-
const form = useCreateMeetingForm();
25+
const { methods, onSubmit, isPending } = useCreateMeetingForm();
3026
const { step, direction, next, back, isFirstStep } =
3127
useCreateMeetingFunnel();
3228

33-
const { mutate: createGathering, isPending } = useCreateGathering();
34-
3529
const handleBackward = () => {
3630
if (isFirstStep) {
3731
router.push("/");
@@ -40,40 +34,6 @@ export default function GatheringCreatePage() {
4034
}
4135
};
4236

43-
const handleComplete = (accessKey: string) => {
44-
router.push(`/gathering/create/complete/${accessKey}`);
45-
};
46-
47-
const onSubmit = (formData: CreateMeetingForm) => {
48-
if (
49-
!formData.peopleCount ||
50-
!formData.region ||
51-
!formData.scheduledDate ||
52-
!formData.timeSlot
53-
) {
54-
return;
55-
}
56-
57-
createGathering(
58-
{
59-
peopleCount: formData.peopleCount,
60-
region: formData.region,
61-
scheduledDate: formData.scheduledDate.replace(/\./g, "-"),
62-
timeSlot: formData.timeSlot,
63-
},
64-
{
65-
onSuccess: (response) => {
66-
handleComplete(response.data.accessKey);
67-
},
68-
onError: (error) => {
69-
if (isApiError(error)) {
70-
toast.warning(error.message);
71-
}
72-
},
73-
},
74-
);
75-
};
76-
7737
const renderContent = () => {
7838
switch (step) {
7939
case "people":
@@ -101,9 +61,9 @@ export default function GatheringCreatePage() {
10161
};
10262

10363
return (
104-
<FormProvider {...form}>
64+
<FormProvider {...methods}>
10565
<Layout.Root>
106-
<form onSubmit={form.handleSubmit(onSubmit)}>
66+
<form onSubmit={onSubmit}>
10767
<Layout.Header>
10868
{step !== "people" && (
10969
<BackwardButton onClick={handleBackward} />
Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
"use client";
22

33
import { useForm } from "react-hook-form";
4-
import type { CreateMeetingForm } from "#/types/gathering";
4+
import { zodResolver } from "@hookform/resolvers/zod";
5+
import { useRouter } from "next/navigation";
6+
import {
7+
createMeetingFormSchema,
8+
type CreateMeetingFormSchema,
9+
} from "#/schemas/gathering";
10+
import { useCreateGathering } from "#/hooks/apis/gathering";
11+
import { isApiError } from "#/utils/api";
12+
import { toast } from "#/utils/toast";
513

6-
export const useCreateMeetingForm = () => {
7-
const form = useForm<CreateMeetingForm>({
14+
export function useCreateMeetingForm() {
15+
const router = useRouter();
16+
const { mutate: createGathering, isPending } = useCreateGathering();
17+
18+
const methods = useForm<CreateMeetingFormSchema>({
819
mode: "onChange",
20+
resolver: zodResolver(createMeetingFormSchema),
921
defaultValues: {
1022
peopleCount: null,
1123
scheduledDate: "",
@@ -14,5 +26,41 @@ export const useCreateMeetingForm = () => {
1426
},
1527
});
1628

17-
return form;
18-
};
29+
const handleSubmit = methods.handleSubmit((data) => {
30+
if (
31+
!data.peopleCount ||
32+
!data.timeSlot ||
33+
!data.scheduledDate ||
34+
!data.region
35+
) {
36+
return;
37+
}
38+
39+
createGathering(
40+
{
41+
peopleCount: data.peopleCount,
42+
region: data.region,
43+
scheduledDate: data.scheduledDate.replace(/\./g, "-"),
44+
timeSlot: data.timeSlot,
45+
},
46+
{
47+
onSuccess: (response) => {
48+
router.push(
49+
`/gathering/create/complete/${response.data.accessKey}`,
50+
);
51+
},
52+
onError: (error) => {
53+
if (isApiError(error)) {
54+
toast.warning(error.message);
55+
}
56+
},
57+
},
58+
);
59+
});
60+
61+
return {
62+
methods,
63+
onSubmit: handleSubmit,
64+
isPending,
65+
};
66+
}

src/pageComponents/gathering/create/DateStep.tsx

Lines changed: 15 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,52 +8,26 @@ import { StepIndicator } from "#/components/stepIndicator";
88
import { Button } from "#/components/button";
99
import { InputField } from "#/components/inputField";
1010
import { Chip } from "#/components/chip";
11-
import {
12-
formatDateInput,
13-
isValidDateFormat,
14-
validateDateInput,
15-
type DateValidationError,
16-
} from "#/utils/gathering/create";
17-
import type { CreateMeetingForm, TimeSlot } from "#/types/gathering";
18-
19-
const scheduledDateRules = {
20-
validate: (value: string | undefined) =>
21-
!isNil(value) && isValidDateFormat(value),
22-
};
23-
24-
const timeSlotRules = {
25-
validate: (value: TimeSlot | null) => !isNil(value),
26-
};
27-
28-
const DATE_ERROR_MESSAGES: Record<
29-
Exclude<DateValidationError, null>,
30-
string
31-
> = {
32-
INVALID_FORMAT: "날짜 형식을 확인해주세요 (예: 2026.01.31)",
33-
INVALID_DATE: "존재하지 않는 날짜예요",
34-
PAST_DATE: "이미 지난 날짜예요",
35-
};
11+
import { formatDateInput } from "#/utils/gathering/create";
12+
import type { CreateMeetingFormSchema } from "#/schemas/gathering";
13+
import type { TimeSlot } from "#/types/gathering";
3614

3715
export const DateStepContent = () => {
38-
const { control } = useFormContext<CreateMeetingForm>();
16+
const { control } = useFormContext<CreateMeetingFormSchema>();
3917

40-
const { field: scheduledDateField } = useController({
18+
const {
19+
field: scheduledDateField,
20+
fieldState: { error: scheduledDateError },
21+
} = useController({
4122
control,
4223
name: "scheduledDate",
43-
rules: scheduledDateRules,
4424
});
4525

4626
const { field: timeSlotField } = useController({
4727
control,
4828
name: "timeSlot",
49-
rules: timeSlotRules,
5029
});
5130

52-
const dateError =
53-
scheduledDateField.value?.length === 10
54-
? validateDateInput(scheduledDateField.value)
55-
: null;
56-
5731
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
5832
const formatted = formatDateInput(e.target.value);
5933
scheduledDateField.onChange(formatted);
@@ -78,11 +52,7 @@ export const DateStepContent = () => {
7852
<InputField
7953
placeholder="날짜를 입력해주세요"
8054
helperText="예) 2026.01.28"
81-
errorText={
82-
dateError
83-
? DATE_ERROR_MESSAGES[dateError]
84-
: undefined
85-
}
55+
errorText={scheduledDateError?.message}
8656
inputMode="numeric"
8757
showClearButton
8858
value={scheduledDateField.value || ""}
@@ -120,13 +90,17 @@ interface DateStepFooterProps {
12090
}
12191

12292
export const DateStepFooter = ({ onNext }: DateStepFooterProps) => {
123-
const { control } = useFormContext<CreateMeetingForm>();
93+
const {
94+
control,
95+
formState: { errors },
96+
} = useFormContext<CreateMeetingFormSchema>();
12497
const scheduledDate = useWatch({ control, name: "scheduledDate" });
12598
const timeSlot = useWatch({ control, name: "timeSlot" });
12699

127100
const isValid =
128101
!isNil(scheduledDate) &&
129-
isValidDateFormat(scheduledDate) &&
102+
scheduledDate.length > 0 &&
103+
!errors.scheduledDate &&
130104
!isNil(timeSlot);
131105

132106
return (

src/pageComponents/gathering/create/PeopleStep.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,13 @@ import { Layout } from "#/components/layout";
77
import { StepIndicator } from "#/components/stepIndicator";
88
import { Button } from "#/components/button";
99
import { PeopleCountGrid } from "./PeopleCountGrid";
10-
import type { CreateMeetingForm } from "#/types/gathering";
11-
12-
const rules = {
13-
validate: (value: number | null) => !isNil(value),
14-
};
10+
import type { CreateMeetingFormSchema } from "#/schemas/gathering";
1511

1612
export const PeopleStepContent = () => {
17-
const { control } = useFormContext<CreateMeetingForm>();
13+
const { control } = useFormContext<CreateMeetingFormSchema>();
1814
const { field } = useController({
1915
control,
2016
name: "peopleCount",
21-
rules,
2217
});
2318

2419
const handleChange = (count: number | null) => {
@@ -43,7 +38,7 @@ interface PeopleStepFooterProps {
4338
}
4439

4540
export const PeopleStepFooter = ({ onNext }: PeopleStepFooterProps) => {
46-
const { control } = useFormContext<CreateMeetingForm>();
41+
const { control } = useFormContext<CreateMeetingFormSchema>();
4742
const peopleCount = useWatch({ control, name: "peopleCount" });
4843

4944
const isValid = !isNil(peopleCount);

src/pageComponents/gathering/create/RegionChip.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
import { useFormContext, useController } from "react-hook-form";
44

55
import { Chip } from "#/components/chip";
6-
import type { CreateMeetingForm, Region } from "#/types/gathering";
6+
import type { CreateMeetingFormSchema } from "#/schemas/gathering";
7+
import type { Region } from "#/types/gathering";
78

89
interface RegionChipProps {
910
value: Region;
1011
label: string;
1112
}
1213

1314
export const RegionChip = ({ value, label }: RegionChipProps) => {
14-
const { control } = useFormContext<CreateMeetingForm>();
15+
const { control } = useFormContext<CreateMeetingFormSchema>();
1516
const { field } = useController({
1617
name: "region",
1718
control,

src/pageComponents/gathering/create/RegionStep.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Button } from "#/components/button/Button";
88
import { DotsLoader } from "#/components/dotsLoader";
99
import { REGION_OPTIONS } from "#/constants/gathering/opinion";
1010
import { RegionChip } from "./RegionChip";
11-
import type { CreateMeetingForm } from "#/types/gathering";
11+
import type { CreateMeetingFormSchema } from "#/schemas/gathering";
1212
import { isNil } from "es-toolkit";
1313

1414
export const RegionStepContent = () => {
@@ -34,7 +34,7 @@ interface RegionStepFooterProps {
3434
}
3535

3636
export const RegionStepFooter = ({ isPending }: RegionStepFooterProps) => {
37-
const { control } = useFormContext<CreateMeetingForm>();
37+
const { control } = useFormContext<CreateMeetingFormSchema>();
3838
const region = useWatch({ control, name: "region" });
3939

4040
const isValid = !isNil(region);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { z } from "zod";
2+
import type { TimeSlot, Region } from "#/types/gathering";
3+
import {
4+
validateDateInput,
5+
DATE_ERROR_MESSAGES,
6+
} from "#/utils/gathering/create";
7+
8+
const timeSlotSchema = z.enum([
9+
"LUNCH",
10+
"DINNER",
11+
] satisfies readonly TimeSlot[]);
12+
13+
const regionSchema = z.enum([
14+
"HONGDAE",
15+
"GANGNAM",
16+
"GONGDEOK",
17+
] satisfies readonly Region[]);
18+
19+
const scheduledDateSchema = z.string().check((ctx) => {
20+
// 10자리(yyyy.MM.dd) 입력 완료 시에만 validation 수행
21+
// 빈 문자열이거나 입력 중일 때는 에러 표시하지 않음
22+
if (ctx.value.length < 10) {
23+
return;
24+
}
25+
26+
const error = validateDateInput(ctx.value);
27+
if (error) {
28+
ctx.issues.push({
29+
code: "custom",
30+
message: DATE_ERROR_MESSAGES[error],
31+
input: ctx.value,
32+
path: [],
33+
});
34+
}
35+
});
36+
37+
export const createMeetingFormSchema = z.object({
38+
peopleCount: z.number().nullable(),
39+
scheduledDate: scheduledDateSchema,
40+
timeSlot: timeSlotSchema.nullable(),
41+
region: regionSchema.nullable(),
42+
});
43+
44+
export type CreateMeetingFormSchema = z.infer<typeof createMeetingFormSchema>;

src/schemas/gathering/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,8 @@ export {
44
distanceRangeToKm,
55
type OpinionFormSchema,
66
} from "./opinionForm.schema";
7+
8+
export {
9+
createMeetingFormSchema,
10+
type CreateMeetingFormSchema,
11+
} from "./createMeetingForm.schema";
Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1 @@
1-
import { Region } from "./region";
2-
import { TimeSlot } from "./timeSlot";
3-
41
export type CreateMeetingStep = "people" | "date" | "region";
5-
6-
export interface CreateMeetingForm {
7-
peopleCount: number | null;
8-
scheduledDate: string;
9-
timeSlot: TimeSlot | null;
10-
region: Region | null;
11-
}

src/types/gathering/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type { CreateMeetingStep, CreateMeetingForm } from "./createMeetingForm";
1+
export type { CreateMeetingStep } from "./createMeetingForm";
22
export type { OpinionForm, OpinionStep } from "./createOpinionForm";
33
export type { DistanceRange } from "./distance";
44
export type { FoodCategory } from "./foodCategory";

0 commit comments

Comments
 (0)