diff --git a/package.json b/package.json index bc1b49e..ac8340c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@tailwindcss/vite": "^4.1.18", diff --git a/src/components/ControlledDateTimePicker.tsx b/src/components/ControlledDateTimePicker.tsx new file mode 100644 index 0000000..934b519 --- /dev/null +++ b/src/components/ControlledDateTimePicker.tsx @@ -0,0 +1,57 @@ +import { DateTimeInput } from '@/components/DateTimeInput'; +import { DateTimePicker } from '@/components/DateTimePicker'; +import type { Control, FieldValues, Path } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; + +interface ControlledDateTimePickerProps { + control: Control; + name: Path; + id?: string; + placeholder?: string; + disabled?: boolean; + onChange?: (date: Date | undefined) => void; +} + +export function ControlledDateTimePicker({ + control, + name, + id, + placeholder, + disabled, + onChange: onSideChange, +}: ControlledDateTimePickerProps) { + return ( + ( + { + field.onChange(date); + if (onSideChange) onSideChange(date); + }} + placeholder={placeholder} + disabled={disabled} + renderTrigger={({ value, open, setOpen }) => ( + { + if (date) { + field.onChange(date); + if (onSideChange) onSideChange(date); + } + }} + onCalendarClick={() => { + setOpen(!open); + }} + placeholder={placeholder} + disabled={disabled} + /> + )} + /> + )} + /> + ); +} diff --git a/src/components/EventForm.tsx b/src/components/EventForm.tsx new file mode 100644 index 0000000..2c96f94 --- /dev/null +++ b/src/components/EventForm.tsx @@ -0,0 +1,620 @@ +import { ControlledDateTimePicker } from '@/components/ControlledDateTimePicker'; +import { InputWithPlusMinusButtons } from '@/components/InputWithPlusMinusButtton'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { + Field, + FieldContent, + FieldDescription, + FieldGroup, + FieldLabel, + FieldSet, +} from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { formatEventDate } from '@/utils/date'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { ChevronLeftIcon, Loader2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const formSchema = z + .object({ + title: z + .string() + .trim() + .min(1, '제목을 입력해 주세요.') + .max(20, '제목은 20자 이내로 입력해 주세요.'), + capacity: z.number().min(1, '정원은 1 이상이어야 합니다.'), + isFromNow: z.boolean(), + isBounded: z.boolean(), + regiStartDate: z.date(), + regiEndDate: z.date(), + eventStartDate: z.date(), + eventEndDate: z.date().optional(), + location: z + .string() + .trim() + .max(20, '장소는 20자 이내로 입력해 주세요.') + .optional(), + description: z.string().trim().optional(), + }) + .superRefine((data, ctx) => { + // 1. 신청 마감 시간은 현재 시간 이후여야 함 + if (data.regiEndDate <= new Date()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: '신청 마감 시간은 현재 시간 이후여야 합니다.', + path: ['regiEndDate'], + }); + } + + // 2. 신청 기간 검증 + if (!data.isFromNow && data.regiStartDate >= data.regiEndDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: '신청 마감 시간이 신청 시작 시간보다 빠를 수 없습니다.', + path: ['regiEndDate'], + }); + } + + // 3. 모임 기간 검증 (종료 시간이 있을 때만) + if ( + data.isBounded && + data.eventEndDate && + data.eventStartDate >= data.eventEndDate + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: '모임 종료 시간이 모임 시작 시간보다 빠를 수 없습니다.', + path: ['eventEndDate'], + }); + } + }); + +export type FormValues = z.infer; + +interface EventFormProps { + pageTitle: string; + defaultValues: FormValues; + onSubmit: (data: FormValues) => Promise | void; + loading?: boolean; + onBack: () => void; + submitButtonText?: string; + saveDialogTitle?: string; + saveDialogDescription?: string; +} + +export function EventForm({ + pageTitle, + defaultValues, + onSubmit: handleFormSubmit, + loading = false, + onBack, + submitButtonText = '저장', + saveDialogTitle = '일정을 저장하시겠습니까?', + saveDialogDescription = '참여자가 생기는 경우, 기본 정보를 수정하기 어려울 수 있습니다.', +}: EventFormProps) { + const [step, setStep] = useState<1 | 2>(1); + const [showSaveDialog, setShowSaveDialog] = useState(false); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues, + mode: 'onChange', + }); + + const { + control, + handleSubmit, + trigger, + watch, + setValue, + formState: { errors, isSubmitting }, + getValues, + reset, + } = form; + + useEffect(() => { + reset(defaultValues); + }, [defaultValues, reset]); + + const isFromNow = watch('isFromNow'); + const isBounded = watch('isBounded'); + + const onNext = async () => { + // 1단계 필드만 검증 + const isValidStep1 = await trigger([ + 'title', + 'capacity', + 'regiStartDate', + 'regiEndDate', + ]); + + if (isValidStep1) { + // 추가적으로 zod superRefine의 에러도 확인해야 함 + const step1Errors = [ + errors.title, + errors.capacity, + errors.regiStartDate, + errors.regiEndDate, + ]; + + if (step1Errors.every((e) => !e)) { + setStep(2); + } + } + }; + + const onSubmit = async (data: FormValues) => { + // Step 2 Cross-validation + if (data.regiEndDate > data.eventStartDate) { + form.setError('regiEndDate', { + type: 'manual', + message: '모임 시작 시간이 신청 마감 시간보다 빠를 수 없습니다.', + }); + return; + } + + await handleFormSubmit(data); + }; + + const errorTextStyle = 'mt-1 text-xs text-red-500 font-medium'; + + return ( +
+ {/* Top navigation UI */} +
+
+ +

+ {pageTitle} +

+
+
+ + {/* Stepper / Tabs */} +
+
+ + +
+
+ +
+
+ {/* STEP 1: Basic Info */} + {step === 1 && ( + +
+ + {/* Name */} + + + 모임 이름 + * + + ( + + )} + /> + {errors.title && ( +

{errors.title.message}

+ )} +
+ + {/* Start recruiting now Toggle */} + + + 지금부터 모집하기 + + 일정을 만든 즉시 참가 신청을 시작해요. + + + ( + { + field.onChange(checked); + if (checked) { + setValue('regiStartDate', new Date()); + } + }} + /> + )} + /> + + + {/* Registration Start */} + {!isFromNow && ( + +
+ + 신청 시작 시간 + * + + + {errors.regiStartDate && ( +

+ {errors.regiStartDate.message} +

+ )} +
+
+ )} + + {/* Registration End */} + + + 신청 마감 시간 + * + + { + if (date) { + const newEventStart = new Date( + date.getTime() + 24 * 60 * 60 * 1000 + ); + setValue('eventStartDate', newEventStart); + + if (isBounded) { + setValue( + 'eventEndDate', + new Date(newEventStart.getTime() + 60 * 60 * 1000) + ); + } + } + }} + /> + {errors.regiEndDate && ( +

+ {errors.regiEndDate.message} +

+ )} +
+ + {/* Capacity */} + + + + 모임 정원 + * + + + 신청 마감일시 이전에 정원 초과 시, 대기자가 발생하며 + 취소 여석에 따라 참여자로 전환됩니다. + + {errors.capacity && ( +

+ {errors.capacity.message} +

+ )} +
+ ( + + )} + /> +
+
+
+ +
+ +
+
+ )} + + {/* STEP 2: Event Description */} + {step === 2 && ( + + {/* Summary Card */} +
+

+ {getValues('title')} +

+
+

+ 신청 기간: + {isFromNow + ? '지금부터' + : formatEventDate( + getValues('regiStartDate').toString() + )}{' '} + - {formatEventDate(getValues('regiEndDate').toString())} +

+

+ 일정 정원: + {getValues('capacity')}명 +

+
+
+ +
+ + {/* Event Start */} + +
+ + 모임 시작 시간 + * + + { + if (date && isBounded) { + const newEnd = new Date( + date.getTime() + 60 * 60 * 1000 + ); + setValue('eventEndDate', newEnd); + } + }} + /> + {errors.eventStartDate && ( +

+ {errors.eventStartDate.message} +

+ )} + {!errors.eventStartDate && errors.regiEndDate && ( +

+ {errors.regiEndDate.message} +

+ )} +
+
+ + {/* Event End Toggle */} + + + 해어지는 때도 입력하기 + + 모임이 언제 끝나는지 알려주세요. + + + ( + { + field.onChange(checked); + if (checked) { + const start = getValues('eventStartDate'); + const newEnd = start + ? new Date(start.getTime() + 60 * 60 * 1000) + : new Date(); + setValue('eventEndDate', newEnd); + } else { + setValue('eventEndDate', undefined); + } + }} + /> + )} + /> + + + {/* Event End Date Picker */} + {isBounded && ( + +
+ + 모임 종료 시간 + * + + + {errors.eventEndDate && ( +

+ {errors.eventEndDate.message} +

+ )} +
+
+ )} + + {/* Location */} + + 모임 장소 + ( + + )} + /> + + + {/* Description */} + + 설명 + ( +