Skip to content
45 changes: 39 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@
"@hookform/resolvers": "^3.9.1",
"@tanstack/react-query": "^5.61.3",
"@tanstack/react-query-devtools": "^5.61.3",
"@types/react-datepicker": "^6.2.0",
"axios": "^1.7.8",
"next": "15.0.3",
"react": "^18.3.1",
"react-datepicker": "^7.5.0",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.5",
"react-hook-form": "^7.53.2",
"tailwind-merge": "^2.5.5",
"zod": "^3.23.8",
"zustand": "^5.0.1"
},
Expand Down
64 changes: 41 additions & 23 deletions src/app/bookclub/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@ import { useState } from 'react';
import Button from '@/components/button/Button';
import {
CreateClubFormField,
DatePickerContainer,
InputField,
RadioButtonGroup,
} from '@/features/club-create/components';
import { BookClubForm, bookClubSchema } from '@/features/club-create/types';
import 'react-datepicker/dist/react-datepicker.css';
import { useCreateBookClub } from '@/features/club-create/hooks/useCreateBookClub';

export default function CreateBookClub() {
const [selectedFileName, setSelectedFileName] = useState<string>('');
const { createBookClub } = useCreateBookClub();
const {
register,
handleSubmit,
control,
setValue,
formState: { errors },
watch,
} = useForm<BookClubForm>({
Expand All @@ -25,12 +31,19 @@ export default function CreateBookClub() {
// TODO:: 컨테이너별로 비즈니스 로직 작업 후 훅 분리
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
setSelectedFileName(file ? file.name : '');
if (file) {
console.log('선택된 파일:', file);
setSelectedFileName(file.name);
setValue('image', file);
} else {
setSelectedFileName('');
}
};

// TODO: API 연동, 훅 분리
const onSubmit = (data: BookClubForm) => {
console.log(data);
const formData = createBookClub(data);
console.log(formData);
};

return (
Expand Down Expand Up @@ -62,7 +75,10 @@ export default function CreateBookClub() {
/>
</CreateClubFormField>

<CreateClubFormField label="이미지" error={errors.image?.message}>
<CreateClubFormField
label="이미지"
error={errors.image?.message?.toString()}
>
<div className="flex w-full items-center gap-2">
<InputField
type="text"
Expand Down Expand Up @@ -102,55 +118,57 @@ export default function CreateBookClub() {
options={[
{
label: '자유책',
value: '자유책',
value: 'FREE',
description:
'읽고 싶은 책을 자유롭게 선택하고 각자의 생각을 나눠요.',
},
{
label: '지정책',
value: '지정책',
value: 'FIXED',
description: '한 권의 책을 선정해 깊이 있는 토론을 진행해요.',
},
]}
selectedValue={watch('bookType')}
register={register('bookType')}
selectedValue={watch('bookClubType')}
register={register('bookClubType')}
/>
</CreateClubFormField>

<CreateClubFormField label="온라인 / 오프라인">
<RadioButtonGroup
options={[
{ label: '온라인', value: '온라인' },
{ label: '오프라인', value: '오프라인' },
{ label: '온라인', value: 'ONLINE' },
{ label: '오프라인', value: 'OFFLINE' },
]}
selectedValue={watch('location')}
register={register('location')}
selectedValue={watch('meetingType')}
register={register('meetingType')}
/>
</CreateClubFormField>

<CreateClubFormField
<DatePickerContainer
control={control}
name="targetDate"
label="언제 만나나요?"
error={errors.startDate?.message}
>
<InputField type="datetime-local" register={register('startDate')} />
</CreateClubFormField>
error={errors.targetDate?.message}
placeholder="만나는 날짜를 선택해주세요!"
/>

<CreateClubFormField
<DatePickerContainer
control={control}
name="endDate"
label="언제 모임을 마감할까요?"
error={errors.endDate?.message}
>
<InputField type="datetime-local" register={register('endDate')} />
</CreateClubFormField>
placeholder="모임의 모집 마감 날짜를 선택해주세요!"
/>

<CreateClubFormField
label="모임 정원"
error={errors.maxParticipants?.message}
currentLength={watch('maxParticipants') || 0}
error={errors.memberLimit?.message}
currentLength={watch('memberLimit') || 0}
maxLength={20}
>
<InputField
type="number"
register={register('maxParticipants', { valueAsNumber: true })}
register={register('memberLimit', { valueAsNumber: true })}
placeholder="최소 3인이상 입력해주세요."
/>
</CreateClubFormField>
Expand Down
46 changes: 46 additions & 0 deletions src/features/club-create/components/DatePickerField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Control, Controller } from 'react-hook-form';
import DatePicker from 'react-datepicker';
import { ko } from 'date-fns/locale';
import { BookClubForm } from '../types';
import CreateClubFormField from '@/features/club-create/components/CreateClubFormField';
import InputField from '@/features/club-create/components/InputField';

interface DatePickerContainerProps {
control: Control<BookClubForm>;
name: 'targetDate' | 'endDate';
label: string;
error?: string;
placeholder: string;
}

export const DatePickerContainer = ({
control,
name,
label,
error,
placeholder,
}: DatePickerContainerProps) => {
return (
<CreateClubFormField label={label} error={error}>
<Controller
control={control}
name={name}
render={({ field }) => (
<DatePicker
selected={field.value}
onChange={field.onChange}
showTimeSelect
timeIntervals={10}
dateFormat="yyyy-MM-dd a HH:mm"
timeFormat="HH:mm"
locale={ko}
showTimeSelectOnly={false}
timeCaption="시간"
placeholderText={placeholder}
customInput={<InputField />}
/>
)}
/>
</CreateClubFormField>
);
};
1 change: 1 addition & 0 deletions src/features/club-create/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as CreateClubFormField } from './CreateClubFormField';
export { default as InputField } from './InputField';
export { default as RadioButtonGroup } from './RadioButtonGroup';
export { DatePickerContainer } from './DatePickerField';
47 changes: 47 additions & 0 deletions src/features/club-create/hooks/useCreateBookClub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { toKoreanTime } from '@/lib/utils/dateUtils';
import { BookClubForm } from '../types';

export const useCreateBookClub = () => {
const createBookClub = (data: BookClubForm) => {
const formData = new FormData();

const imageFile = data.image instanceof File ? data.image : null;
if (imageFile) {
formData.append('image', imageFile);
}

const bookClubData = {
title: data.title,
description: data.description,
bookClubType: data.bookClubType,
meetingType: data.meetingType,
town: data.town,
targetDate: toKoreanTime(data.targetDate),
endDate: toKoreanTime(data.endDate),
memberLimit: data.memberLimit,
};

formData.append(
'bookClub',
new Blob([JSON.stringify(bookClubData)], {
type: 'application/json',
}),
);

// TODO: API 호출 로직 추가
console.log('전송될 데이터:', {
이미지: imageFile
? {
이름: imageFile.name,
타입: imageFile.type,
크기: `${(imageFile.size / 1024).toFixed(2)}KB`,
}
: null,
북클럽_데이터: bookClubData,
});

return formData;
};

return { createBookClub };
};
18 changes: 11 additions & 7 deletions src/features/club-create/types/bookClubSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@ export const bookClubSchema = z.object({
.string()
.min(1, '상세 설명을 입력해주세요')
.max(30, '상세 설명은 최대 30자까지 가능합니다'),
image: z.string().optional(),
bookType: z.enum(['자유책', '지정책']),
location: z.enum(['온라인', '오프라인']),
place: z.string().optional(),
startDate: z.string().min(1, '시작 날짜를 선택해주세요'),
endDate: z.string().min(1, '종료 날짜를 선택해주세요'),
maxParticipants: z
image: z.any().optional(),
bookClubType: z.enum(['FREE', 'FIXED']),
meetingType: z.enum(['ONLINE', 'OFFLINE']),
town: z.string().optional(),
targetDate: z.date().refine((date) => !isNaN(date.getTime()), {
message: '유효한 날짜를 선택해주세요',
}),
endDate: z.date().refine((date) => !isNaN(date.getTime()), {
message: '유효한 날짜를 선택해주세요',
}),
memberLimit: z
.number()
.min(3, '최소 3명 이상 입력해주세요')
.max(20, '최대 20명까지 가능합니다'),
Expand Down
4 changes: 4 additions & 0 deletions src/lib/utils/dateUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const toKoreanTime = (date: Date): string => {
const kstDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return kstDate.toISOString().slice(0, 19);
};