diff --git a/package-lock.json b/package-lock.json index ba06666..dc82dde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@svgr/webpack": "^8.1.0", "@tanstack/react-query": "^5.66.7", "@tanstack/react-query-devtools": "^5.66.7", + "@types/react-datepicker": "^7.0.0", "autoprefixer": "^10.4.20", "axios": "^1.7.9", "class-variance-authority": "^0.7.1", @@ -21,6 +22,7 @@ "lucide-react": "^0.475.0", "next": "14.1.0", "react": "^18.2.0", + "react-datepicker": "^8.1.0", "react-dom": "^18.2.0", "react-hook-form": "^7.54.2", "tailwind-merge": "^3.0.1", @@ -1995,6 +1997,20 @@ "@floating-ui/utils": "^0.2.9" } }, + "node_modules/@floating-ui/react": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.4.tgz", + "integrity": "sha512-05mXdkUiVh8NCEcYKQ2C9SV9IkZ9k/dFtYmaEIN2riLv80UHoXylgBM76cgPJYfLJM3dJz7UE5MOVH0FypMd2Q==", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.9", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@floating-ui/react-dom": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", @@ -4186,6 +4202,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-datepicker": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-7.0.0.tgz", + "integrity": "sha512-4tWwOUq589tozyQPBVEqGNng5DaZkomx5IVNuur868yYdgjH6RaL373/HKiVt1IDoNNXYiTGspm1F7kjrarM8Q==", + "deprecated": "This is a stub types definition. react-datepicker provides its own type definitions, so you do not need this installed.", + "dependencies": { + "react-datepicker": "*" + } + }, "node_modules/@types/react-dom": { "version": "18.3.5", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", @@ -5803,6 +5828,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -11141,6 +11175,20 @@ "node": ">=0.10.0" } }, + "node_modules/react-datepicker": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.1.0.tgz", + "integrity": "sha512-11gIOrBGK1MOvl4+wxGv4YxTqXf+uoRPtKstYhb/P1cBdRdOP1sL26VE31apmDnvw8wSYfJe9AWwWbKqmM9tzw==", + "dependencies": { + "@floating-ui/react": "^0.27.3", + "clsx": "^2.1.1", + "date-fns": "^4.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -12340,6 +12388,11 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, "node_modules/tailwind-merge": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.1.tgz", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e4bf0fb..c9138c7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import Header from '@/components/common/Header'; import { ToastProvider } from '@/components/common/ToastContext'; import ReactQueryProviders from '@/hooks/useReactQuery'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import type { Metadata } from 'next'; import localFont from 'next/font/local'; diff --git a/src/app/meeting/components/form/MeetingForm.tsx b/src/app/meeting/components/form/MeetingForm.tsx new file mode 100644 index 0000000..88eecef --- /dev/null +++ b/src/app/meeting/components/form/MeetingForm.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { + CategoryField, + DateField, + DescriptionField, + ImageField, + InfoMessage, + LocationField, + MemberLimitField, + PrivacyField, + RequireApprovalField, + SubmitButton, + TechStackField, + TitleField, +} from '@/app/meeting/components/form/form-filed'; +import useMeetingFormMutation from '@/hooks/mutations/useMeetingFormMutation'; +import { convertImageToBase64 } from '@/util/base64'; +import { useRouter } from 'next/navigation'; +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { CreateMeetingPayload } from 'types/meetingForm'; + +import { MEETING_TYPES } from '../../constants/meeting-form/meetingConstants'; + +interface MeetingFormProps { + mode: 'create' | 'edit'; + initialData?: Partial; + meetingId?: string; +} + +export default function MeetingForm({ + mode, + initialData = {}, + meetingId, +}: MeetingFormProps) { + const router = useRouter(); + const { createMeeting, isLoading } = useMeetingFormMutation(); + + // 날짜 YYYY-MM-DD 형식으로 변환 + const today = new Date(); + const formattedToday = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + + // 카테고리 라벨에서 ID 찾기 (URL 용) + const getCategoryId = (label: string) => { + const category = MEETING_TYPES.find((type) => type.label === label); + return category ? category.id : ''; + }; + + const defaultValues: CreateMeetingPayload = { + meetingTitle: '', + categoryTitle: '', + imageName: '', + imageEncodedBase64: '', + content: '', + location: '', + maxMember: 0, + startDate: formattedToday, + isPublic: true, + requireApproval: false, + skillArray: [], + ...initialData, + }; + + const methods = useForm({ + defaultValues, + }); + + const { handleSubmit } = methods; + + const onSubmit = async (data: CreateMeetingPayload) => { + try { + // 이미지 처리 + const fileInput = document.getElementById('image') as HTMLInputElement; + if (fileInput?.files && fileInput.files.length > 0) { + const imageData = await convertImageToBase64(fileInput.files[0]); + data.imageName = imageData.name; + data.imageEncodedBase64 = imageData.base64; + } + + if (mode === 'create') { + // 모임 생성 + const result = await createMeeting.mutateAsync(data); + + // 성공 시 상세 페이지로 이동 + const categoryId = getCategoryId(data.categoryTitle); + router.push(`/meeting/${categoryId}/${result.data.meetingId}`); + } else if (mode === 'edit' && meetingId) { + // 모임 수정 (TODO: API 구현 시 수정) + // const result = await updateMeeting.mutateAsync({ id: meetingId, data }); + + // 수정 성공 시 상세 페이지로 이동 + const categoryId = getCategoryId(data.categoryTitle); + router.push(`/meeting/${categoryId}/${meetingId}`); + } + } catch (error) {} + }; + + return ( + +
+

+ {mode === 'create' ? '모임 생성하기' : '모임 수정하기'} +

+ +
+ + + + + + + + + + + + + +
+
+ ); +} diff --git a/src/app/meeting/components/form/form-filed/CategoryField.tsx b/src/app/meeting/components/form/form-filed/CategoryField.tsx new file mode 100644 index 0000000..5df9645 --- /dev/null +++ b/src/app/meeting/components/form/form-filed/CategoryField.tsx @@ -0,0 +1,84 @@ +import { MEETING_TYPES } from '@/app/meeting/constants/meeting-form/meetingConstants'; +import { cn } from '@/util/cn'; +import { Check } from 'lucide-react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { CreateMeetingPayload } from 'types/meetingForm'; + +import { meetingTypeValidation } from '../validation'; + +interface CategoryFieldProps { + required?: boolean; +} + +const CategoryField = ({ required = true }: CategoryFieldProps) => { + const { + control, + formState: { errors }, + } = useFormContext(); + + const validation = required + ? meetingTypeValidation + : { ...meetingTypeValidation, required: false }; + + return ( +
+ + ( +
+
+ {MEETING_TYPES.map((type) => ( +
field.onChange(type.label)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + field.onChange(type.label); + } + }} + tabIndex={0} + role="radio" + aria-checked={field.value === type.label} + > +
+ {type.icon} + {type.label} +
+ {field.value === type.label && ( + + )} +
+ ))} +
+ {errors.categoryTitle && ( +

+ {errors.categoryTitle.message} +

+ )} +
+ )} + /> +
+ ); +}; + +export default CategoryField; diff --git a/src/app/meeting/components/form/form-filed/DateField.tsx b/src/app/meeting/components/form/form-filed/DateField.tsx new file mode 100644 index 0000000..70f10f1 --- /dev/null +++ b/src/app/meeting/components/form/form-filed/DateField.tsx @@ -0,0 +1,33 @@ +import { DatePicker } from '@/components/ui/form/DatePicker'; +import { useFormContext } from 'react-hook-form'; +import { CreateMeetingPayload } from 'types/meetingForm'; + +import { startDateValidation } from '../validation'; + +interface DateFieldProps { + required?: boolean; +} + +const DateField = ({ required = true }: DateFieldProps) => { + const { + formState: { errors }, + } = useFormContext(); + + const validation = required + ? startDateValidation + : { ...startDateValidation, required: false }; + + return ( + + ); +}; + +export default DateField; diff --git a/src/app/meeting/components/form/form-filed/DescriptionField.tsx b/src/app/meeting/components/form/form-filed/DescriptionField.tsx new file mode 100644 index 0000000..9b48f9e --- /dev/null +++ b/src/app/meeting/components/form/form-filed/DescriptionField.tsx @@ -0,0 +1,44 @@ +import { cn } from '@/util/cn'; +import { useFormContext } from 'react-hook-form'; +import { CreateMeetingPayload } from 'types/meetingForm'; + +import { descriptionValidation } from '../validation'; + +interface DescriptionFieldProps { + required?: boolean; +} + +const DescriptionField = ({ required = true }: DescriptionFieldProps) => { + const { + register, + formState: { errors }, + } = useFormContext(); + + const validation = required + ? descriptionValidation + : { ...descriptionValidation, required: false }; + + return ( +
+ +