diff --git a/package.json b/package.json index 3655817..a22c969 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "next": "15.3.5", "react": "^19.0.0", "react-datepicker": "^8.4.0", + "react-daum-postcode": "^3.2.0", "react-dom": "^19.0.0", "react-kakao-maps-sdk": "^1.2.0", "tailwind-merge": "^3.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa44db3..3748b45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: react-datepicker: specifier: ^8.4.0 version: 8.4.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-daum-postcode: + specifier: ^3.2.0 + version: 3.2.0(react@19.1.0) react-dom: specifier: ^19.0.0 version: 19.1.0(react@19.1.0) @@ -1769,6 +1772,11 @@ packages: react: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-daum-postcode@3.2.0: + resolution: {integrity: sha512-NHY8TUicZXMqykbKYT8kUo2PEU7xu1DFsdRmyWJrLEUY93Xhd3rEdoJ7vFqrvs+Grl9wIm9Byxh3bI+eZxepMQ==} + peerDependencies: + react: '>=16.8.0' + react-dom@19.1.0: resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} peerDependencies: @@ -3782,6 +3790,10 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + react-daum-postcode@3.2.0(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 diff --git a/src/app/(with-header)/myactivity/components/AddressInput.tsx b/src/app/(with-header)/myactivity/components/AddressInput.tsx new file mode 100644 index 0000000..261bca0 --- /dev/null +++ b/src/app/(with-header)/myactivity/components/AddressInput.tsx @@ -0,0 +1,79 @@ +'use client'; + +import Modal from '@/components/Modal'; +import DaumPostcode from 'react-daum-postcode'; +import { useState } from 'react'; +import Input from '@/components/Input'; +import Button from '@/components/Button'; + +interface AddressInputProps { + onAddressChange: (address: string) => void; + address: string; +} + +interface PostcodeData { + address: string; + addressType: 'R' | 'J'; + bname: string; + buildingName: string; + zonecode: string; + userSelectedType: string; +} + +export default function AddressInput({ + onAddressChange, + address, +}: AddressInputProps) { + const [isOpen, setIsOpen] = useState(false); + + const handleComplete = (data: PostcodeData) => { + let fullAddress = data.address; + let extraAddress = ''; + + if (data.addressType === 'R') { + if (data.bname !== '') { + extraAddress += data.bname; + } + if (data.buildingName !== '') { + extraAddress += + extraAddress !== '' ? `, ${data.buildingName}` : data.buildingName; + } + fullAddress += extraAddress !== '' ? ` (${extraAddress})` : ''; + } + + onAddressChange(fullAddress); + setIsOpen(false); + }; + + return ( +
+ setIsOpen(true)} + readOnly + /> + + + + 주소 검색 + + + + + + + + + + +
+ ); +} diff --git a/src/app/(with-header)/myactivity/components/CategoryInput.tsx b/src/app/(with-header)/myactivity/components/CategoryInput.tsx new file mode 100644 index 0000000..2a82f3b --- /dev/null +++ b/src/app/(with-header)/myactivity/components/CategoryInput.tsx @@ -0,0 +1,34 @@ +interface CategoryProps { + category?: string; + + onCategoryChange: (value: string) => void; +} + +export default function CategoryInput({ + category, + onCategoryChange, +}: CategoryProps) { + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/app/(with-header)/myactivity/components/FormSection.tsx b/src/app/(with-header)/myactivity/components/FormSection.tsx new file mode 100644 index 0000000..cb33e7c --- /dev/null +++ b/src/app/(with-header)/myactivity/components/FormSection.tsx @@ -0,0 +1,27 @@ +import type React from 'react'; + +interface FormSectionProps { + title: string; + children: React.ReactNode; + description?: string; +} + +export function FormSection({ + title, + children, + description, +}: FormSectionProps) { + return ( +
+
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ {children} +
+ ); +} diff --git a/src/app/(with-header)/myactivity/components/ImagePreview.tsx b/src/app/(with-header)/myactivity/components/ImagePreview.tsx new file mode 100644 index 0000000..8c16632 --- /dev/null +++ b/src/app/(with-header)/myactivity/components/ImagePreview.tsx @@ -0,0 +1,39 @@ +'use client'; + +import IconClose from '@assets/svg/close'; + +interface ImagePreviewProps { + image: File | string; + onRemove: () => void; + alt: string; + className?: string; +} + +export function ImagePreview({ + image, + onRemove, + alt, + className = '', +}: ImagePreviewProps) { + const src = typeof image === 'string' ? image : URL.createObjectURL(image); + + return ( +
+
+ {alt} +
+ +
+ ); +} diff --git a/src/app/(with-header)/myactivity/components/ImageSection.tsx b/src/app/(with-header)/myactivity/components/ImageSection.tsx new file mode 100644 index 0000000..8b41662 --- /dev/null +++ b/src/app/(with-header)/myactivity/components/ImageSection.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { MainImageSelect } from './MainImageSelect'; +import { SubImageSelect } from './SubImageSelect'; + +interface ImagesSectionProps { + mainImage: string | File | null; + subImage: (string | File)[]; + onMainImageSelect: (file: File) => void; + onMainImageRemove: () => void; + onSubImageAdd: (files: File[]) => void; + onSubImageRemove: (index: number) => void; +} + +export function ImageSection({ + mainImage, + subImage, + onMainImageSelect, + onMainImageRemove, + onSubImageAdd, + onSubImageRemove, +}: ImagesSectionProps) { + return ( +
+ + + +
+ ); +} diff --git a/src/app/(with-header)/myactivity/components/ImageUpload.tsx b/src/app/(with-header)/myactivity/components/ImageUpload.tsx new file mode 100644 index 0000000..12a9bb9 --- /dev/null +++ b/src/app/(with-header)/myactivity/components/ImageUpload.tsx @@ -0,0 +1,50 @@ +'use client'; + +import type React from 'react'; + +interface ImageUploadProps { + onImageSelect: (file: File) => void; + multiple?: boolean; + className?: string; + children?: React.ReactNode; +} + +export function ImageUpload({ + onImageSelect, + multiple = false, + className = '', + children, +}: ImageUploadProps) { + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + onImageSelect(file); + } + }; + + return ( + + ); +} diff --git a/src/app/(with-header)/myactivity/components/InfoSection.tsx b/src/app/(with-header)/myactivity/components/InfoSection.tsx new file mode 100644 index 0000000..509a110 --- /dev/null +++ b/src/app/(with-header)/myactivity/components/InfoSection.tsx @@ -0,0 +1,85 @@ +'use client'; + +import Input from '@/components/Input'; +import AddressInput from './AddressInput'; +import CategoryInput from './CategoryInput'; + +interface InfoSectionProps { + title?: string; + category?: string; + price?: number; + description?: string; + address?: string; + onTitleChange: (value: string) => void; + onCategoryChange: (value: string) => void; + onPriceChange: (value: string) => void; + onDescriptionChange: (value: string) => void; + onAddressChange: (value: string) => void; +} + +export function InfoSection({ + title = '', + category = '', + price = 0, + description = '', + address = '', + onTitleChange, + onCategoryChange, + onPriceChange, + onDescriptionChange, + onAddressChange, +}: InfoSectionProps) { + return ( +
+
+
+ onTitleChange(e.target.value)} + /> +
+ + + +
+
+ onPriceChange(e.target.value)} + /> + + 원 + +
+
+
+ +
+ onDescriptionChange(e.target.value)} + /> +
+ +
+ +
+
+ ); +} diff --git a/src/app/(with-header)/myactivity/components/MainImageSelect.tsx b/src/app/(with-header)/myactivity/components/MainImageSelect.tsx new file mode 100644 index 0000000..781fc43 --- /dev/null +++ b/src/app/(with-header)/myactivity/components/MainImageSelect.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { ImageUpload } from './ImageUpload'; +import { ImagePreview } from './ImagePreview'; + +interface MainImageSelectProps { + mainImage: File | string | null; + onImageSelect: (file: File) => void; + onImageRemove: () => void; +} + +export function MainImageSelect({ + mainImage, + onImageSelect, + onImageRemove, +}: MainImageSelectProps) { + return ( +
+

배너 이미지

+

+ 체험을 대표하는 메인 이미지를 등록해주세요. +

+ +
+ + + {mainImage && ( + + )} +
+ +

+ * 메인 이미지는 1장만 등록할 수 있습니다. +

+
+ ); +} diff --git a/src/app/(with-header)/myactivity/components/ReservationForm.tsx b/src/app/(with-header)/myactivity/components/ReservationForm.tsx new file mode 100644 index 0000000..dbe8021 --- /dev/null +++ b/src/app/(with-header)/myactivity/components/ReservationForm.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +import type React from 'react'; +import { InfoSection } from './InfoSection'; +import { ScheduleSelectForm } from './ScheduleSelectForm'; +import { ImageSection } from './ImageSection'; +import Button from '@/components/Button'; + +interface DateSlot { + date: string; + startTime: string; + endTime: string; +} +const mockData = { + title: '함께 배우면 즐거운 스트릿댄스', + category: '투어', + description: '둠칫 둠칫 두둠칫', + address: '서울특별시 강남구 테헤란로 427', + price: 10000, + schedules: [ + { date: '2023-12-01', startTime: '12:00', endTime: '13:00' }, + { date: '2023-12-05', startTime: '12:00', endTime: '13:00' }, + { date: '2023-12-05', startTime: '13:00', endTime: '14:00' }, + { date: '2023-12-05', startTime: '14:00', endTime: '15:00' }, + ], + bannerImageUrl: '/test/image1.png', + subImageUrls: [ + '/test/image2.png', + '/test/image3.png', + '/test/image4.png', + '/test/image5.png', + ], +}; + +export default function ReservationForm() { + const [dates, setDates] = useState([ + { date: '', startTime: '', endTime: '' }, + ]); + const [mainImage, setMainImage] = useState(null); + const [subImage, setSubImage] = useState<(File | string)[]>([]); + + const [title, setTitle] = useState(''); + const [category, setCategory] = useState(''); + const [price, setPrice] = useState(0); + const [description, setDescription] = useState(''); + const [address, setAddress] = useState(''); + + useEffect(() => { + // mock데이터로 수정페이지용 테스트 + setTimeout(() => { + setTitle(mockData.title); + setCategory(mockData.category); + setPrice(mockData.price); + setDescription(mockData.description); + setAddress(mockData.address); + setDates(mockData.schedules); + setMainImage(mockData.bannerImageUrl); + setSubImage(mockData.subImageUrls); + }, 500); + }, []); + + const handleAddDate = () => { + setDates([...dates, { date: '', startTime: '', endTime: '' }]); + }; + + const handleRemoveDate = (index: number) => { + setDates(dates.filter((_, i) => i !== index)); + }; + + const handleDateChange = ( + index: number, + field: keyof DateSlot, + value: string, + ) => { + const updatedDates = dates.map((date, i) => + i === index ? { ...date, [field]: value } : date, + ); + setDates(updatedDates); + }; + + const handleMainImageSelect = (file: File) => { + setMainImage(file); + }; + + const handleMainImageRemove = () => { + setMainImage(null); + }; + + const handleSubImagesAdd = (newFiles: File[]) => { + const remainingSlots = 4 - subImage.length; + const filesToAdd = newFiles.slice(0, remainingSlots); + setSubImage([...subImage, ...filesToAdd]); + }; + + const handleSubImageRemove = (index: number) => { + setSubImage(subImage.filter((_, i) => i !== index)); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + console.log('title:', title); + console.log('category:', category); + console.log('price:', price); + console.log('description:', description); + console.log('address:', address); + console.log('dates:', dates); + console.log('mainImage:', mainImage); + console.log('subImage:', subImage); + }; + + return ( +
+
+
+
+

+ 내 체험 등록 +

+
+ +
+
+ setPrice(Number(value))} + onDescriptionChange={setDescription} + onAddressChange={setAddress} + /> + + + + + +
+
+ ); +} diff --git a/src/app/(with-header)/myactivity/components/ScheduleSelect.tsx b/src/app/(with-header)/myactivity/components/ScheduleSelect.tsx new file mode 100644 index 0000000..2cf78f9 --- /dev/null +++ b/src/app/(with-header)/myactivity/components/ScheduleSelect.tsx @@ -0,0 +1,78 @@ +'use client'; + +import Input from '@/components/Input'; +import IconClose from '@assets/svg/close'; + +interface ScheduleSelectProps { + index: number; + isRemovable: boolean; + onAddDate: () => void; + onRemove: (index: number) => void; + onDateChange: (index: number, value: string) => void; + onStartTimeChange: (index: number, value: string) => void; + onEndTimeChange: (index: number, value: string) => void; + date: string; + startTime: string; + endTime: string; +} + +export function ScheduleSelect({ + index, + isRemovable, + + onRemove, + onDateChange, + onStartTimeChange, + onEndTimeChange, + date, + startTime, + endTime, +}: ScheduleSelectProps) { + return ( +
+
+
+ onDateChange(index, e.target.value)} + /> +
+ +
+ onStartTimeChange(index, e.target.value)} + /> +
+ +
+ onEndTimeChange(index, e.target.value)} + /> +
+ +
+ {isRemovable && ( + + )} +
+
+
+ ); +} diff --git a/src/app/(with-header)/myactivity/components/ScheduleSelectForm.tsx b/src/app/(with-header)/myactivity/components/ScheduleSelectForm.tsx new file mode 100644 index 0000000..af2254e --- /dev/null +++ b/src/app/(with-header)/myactivity/components/ScheduleSelectForm.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { ScheduleSelect } from './ScheduleSelect'; + +interface ScheduleType { + date: string; + startTime: string; + endTime: string; +} + +interface ScheduleSelectFormProps { + dates: ScheduleType[]; + onAddDate: () => void; + onRemoveDate: (index: number) => void; + onDateChange: ( + index: number, + field: keyof ScheduleType, + value: string, + ) => void; +} + +export function ScheduleSelectForm({ + dates, + onAddDate, + onRemoveDate, + onDateChange, +}: ScheduleSelectFormProps) { + return ( +
+
+

예약 가능한 시간

+ +
+ + {dates.map((dateSlot, idx) => ( +
+ 1} + onAddDate={onAddDate} + onRemove={onRemoveDate} + onDateChange={(index, value) => onDateChange(index, 'date', value)} + onStartTimeChange={(index, value) => + onDateChange(index, 'startTime', value) + } + onEndTimeChange={(index, value) => + onDateChange(index, 'endTime', value) + } + date={dateSlot.date} + startTime={dateSlot.startTime} + endTime={dateSlot.endTime} + /> +
+ ))} +
+ ); +} diff --git a/src/app/(with-header)/myactivity/components/SubImageSelect.tsx b/src/app/(with-header)/myactivity/components/SubImageSelect.tsx new file mode 100644 index 0000000..b7e43d7 --- /dev/null +++ b/src/app/(with-header)/myactivity/components/SubImageSelect.tsx @@ -0,0 +1,47 @@ +import { ImagePreview } from './ImagePreview'; +import { ImageUpload } from './ImageUpload'; + +interface SubImageSelectProps { + subImage: (string | File)[]; + onImagesAdd: (files: File[]) => void; + onImageRemove: (index: number) => void; +} + +export function SubImageSelect({ + subImage, + onImagesAdd, + onImageRemove, +}: SubImageSelectProps) { + const handleImageUpload = (file: File) => { + if (subImage.length < 4) { + onImagesAdd([file]); + } + }; + + return ( +
+

소개 이미지

+

+ 체험의 상세한 모습을 보여주는 이미지들을 등록해주세요. +

+ +
+ {subImage.length < 4 && ( + + )} + {subImage.map((img, idx) => ( + onImageRemove(idx)} + alt={`소개 이미지 ${idx + 1}`} + /> + ))} +
+ +

+ * 이미지는 최대 4장까지 등록 가능합니다. +

+
+ ); +} diff --git a/src/app/(with-header)/myactivity/page.tsx b/src/app/(with-header)/myactivity/page.tsx new file mode 100644 index 0000000..aba1cad --- /dev/null +++ b/src/app/(with-header)/myactivity/page.tsx @@ -0,0 +1,9 @@ +import ReservationForm from './components/ReservationForm'; + +export default function Page() { + return ( +
+ +
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 985b284..38a8ebe 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -67,6 +67,7 @@ html { font-family: 'Pretendard-Regular', sans-serif; + scrollbar-gutter: stable; } }