From 97e3173308fb5782191aa5ae6ea9ec4ab234e458 Mon Sep 17 00:00:00 2001 From: llmojoll Date: Wed, 30 Jul 2025 18:30:41 +0900 Subject: [PATCH 1/9] =?UTF-8?q?Feat=20:=20=EC=82=AD=EC=A0=9C=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC,=20=EC=99=80=EC=9D=B8=EB=93=B1=EB=A1=9D=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=EC=97=90=20=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 14 + package.json | 1 + .../Modal/DeleteModal/DeleteModal.tsx | 83 +++-- .../Modal/WineModal/AddWineModal.tsx | 331 +++++++++--------- .../common/BottomSheet/BottomSheet.tsx | 45 +++ src/components/ui/drawer.tsx | 99 ++++++ src/hooks/useMediaQuery.tsx | 25 ++ src/pages/index.tsx | 18 + 8 files changed, 422 insertions(+), 194 deletions(-) create mode 100644 src/components/common/BottomSheet/BottomSheet.tsx create mode 100644 src/components/ui/drawer.tsx create mode 100644 src/hooks/useMediaQuery.tsx diff --git a/package-lock.json b/package-lock.json index af73917b..ac0eef6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "use-sync-external-store": "^1.5.0", + "vaul": "^1.1.2", "zod": "^4.0.5", "zustand": "^5.0.5" }, @@ -10877,6 +10878,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", diff --git a/package.json b/package.json index 7cdb29a8..08557573 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "use-sync-external-store": "^1.5.0", + "vaul": "^1.1.2", "zod": "^4.0.5", "zustand": "^5.0.5" }, diff --git a/src/components/Modal/DeleteModal/DeleteModal.tsx b/src/components/Modal/DeleteModal/DeleteModal.tsx index ea6ff8bb..b6a3ffa8 100644 --- a/src/components/Modal/DeleteModal/DeleteModal.tsx +++ b/src/components/Modal/DeleteModal/DeleteModal.tsx @@ -2,8 +2,10 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { deleteReview, deleteWine, DeleteResponse } from '@/api/delete'; +import BasicBottomSheet from '@/components/common/BottomSheet/BottomSheet'; import BasicModal from '@/components/common/Modal/BasicModal'; import { Button } from '@/components/ui/button'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; interface DeleteModalProps { type: 'wine' | 'review'; @@ -14,6 +16,7 @@ interface DeleteModalProps { const DeleteModal = ({ type, id, showDeleteModal, setShowDeleteModal }: DeleteModalProps) => { const queryClient = useQueryClient(); + const isDesktop = useMediaQuery('(min-width: 640px)'); const deleteWineMutation = useMutation({ mutationFn: (id) => deleteWine(id), @@ -48,44 +51,52 @@ const DeleteModal = ({ type, id, showDeleteModal, setShowDeleteModal }: DeleteMo } }; - return ( -
- setShowDeleteModal(isOpen)} - showCloseButton={false} - buttons={ -
- - -
- } + const buttons = ( +
+ +
); + + return isDesktop ? ( + setShowDeleteModal(isOpen)} + showCloseButton={false} + > + + 정말로 삭제하시겠습니까? + + {buttons} + + ) : ( + + {buttons} + + ); }; export default DeleteModal; diff --git a/src/components/Modal/WineModal/AddWineModal.tsx b/src/components/Modal/WineModal/AddWineModal.tsx index c6a5bc3b..41d464f4 100644 --- a/src/components/Modal/WineModal/AddWineModal.tsx +++ b/src/components/Modal/WineModal/AddWineModal.tsx @@ -6,6 +6,8 @@ import { useForm } from 'react-hook-form'; import { uploadImage, postWine, PostWineRequest } from '@/api/addwine'; import CameraIcon from '@/assets/camera.svg'; import DropdownIcon from '@/assets/dropdowntriangle.svg'; +import BasicBottomSheet from '@/components/common/BottomSheet/BottomSheet'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; import SelectDropdown from '../../common/dropdown/SelectDropdown'; import Input from '../../common/Input'; @@ -30,8 +32,9 @@ const AddWineModal = ({ showRegisterModal, setShowRegisterModal }: AddWineModalP const fileInputRef = useRef(null); const [previewImage, setPreviewImage] = useState(null); const queryClient = useQueryClient(); + const isDesktop = useMediaQuery('(min-width: 640px)'); - const trigerFileSelect = () => { + const triggerFileSelect = () => { fileInputRef.current?.click(); }; @@ -118,169 +121,181 @@ const AddWineModal = ({ showRegisterModal, setShowRegisterModal }: AddWineModalP } }; //// - return ( -
- - - -
+ + const renderForm = () => ( +
+ {/* 이름 */} +

+ 와인 이름 +

+ clearErrors('wineName'), + })} + errorMessage={errors.wineName?.message} + id='wineName' + type='text' + variant='name' + placeholder='와인 이름 입력' + className='custom-text-md-regular md:custom-text-lg-regular' + /> + {/* 가격 */} +

가격

+ clearErrors('winePrice'), + })} + errorMessage={errors.winePrice?.message} + id='winePrice' + type='number' + variant='name' + placeholder='가격 입력' + className='[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none custom-text-md-regular md:custom-text-lg-regular' + /> + {/* 원산지 */} +

원산지

+ clearErrors('wineOrigin'), + })} + errorMessage={errors.wineOrigin?.message} + id='wineOrigin' + type='text' + variant='name' + placeholder='원산지 입력' + className='custom-text-md-regular md:custom-text-lg-regular' + /> + {/* 타입 */} +

타입

+ { + setCategory(value); + setValue('wineType', value, { shouldValidate: true }); + trigger('wineType'); + }} + placeholder='타입 선택' + trigger={ + } - > - -

- 와인 이름 -

- clearErrors('wineName'), - })} - errorMessage={errors.wineName?.message} - /> -

- 가격 -

- clearErrors('winePrice'), - })} - errorMessage={errors.winePrice?.message} - /> -

- 원산지 -

- clearErrors('wineOrigin'), - })} - errorMessage={errors.wineOrigin?.message} - /> -

- 타입 -

- { - setCategory(value); - setValue('wineType', value, { shouldValidate: true }); - trigger('wineType'); - }} - placeholder='타입 선택' - trigger={ - - } - /> - {errors.wineType?.message && ( -
-

{errors.wineType.message}

+ /> + {errors.wineType?.message && ( +
+

{errors.wineType.message}

+
+ )}{' '} + clearErrors('wineType'), + })} + id='wineType' + type='text' + className='hidden' + /> + {/* 사진 */} +

와인 사진

+ { + clearErrors('wineImage'); + handleImageChange(e); + }, + })} + id='wineImage' + type='file' + accept='image/*' + className='hidden' + ref={(e) => { + register('wineImage').ref(e); + fileInputRef.current = e; + }} + /> +
+
+ {previewImage ? ( + // eslint-disable-next-line @next/next/no-img-element + 미리보기 + ) : ( +
+
)} - clearErrors('wineType'), - })} - /> -

- 와인 사진 -

- { - clearErrors('wineImage'); - handleImageChange(e); - console.log(e.target.files?.[0]); - }, - })} - ref={(e) => { - register('wineImage').ref(e); - fileInputRef.current = e; - }} - /> -
-
- {previewImage ? ( - // eslint-disable-next-line @next/next/no-img-element - 미리보기 - ) : ( -
- -
- )} -
- {errors.wineImage?.message && ( -
-

{errors.wineImage.message}

-
- )} +
+ {errors.wineImage?.message && ( +
+

{errors.wineImage.message}

- - + )} +
+ + ); + + const renderButton = ( +
+ +
); + + return isDesktop ? ( + + {renderForm()} + + ) : ( + + {renderForm()} + + ); }; export default AddWineModal; diff --git a/src/components/common/BottomSheet/BottomSheet.tsx b/src/components/common/BottomSheet/BottomSheet.tsx new file mode 100644 index 00000000..5ad9343a --- /dev/null +++ b/src/components/common/BottomSheet/BottomSheet.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerFooter, +} from '@/components/ui/drawer'; + +interface BasicBottomSheetProps { + title?: string; + children?: React.ReactNode; + buttons?: React.ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const BasicBottomSheet = ({ + open, + onOpenChange, + title, + children, + buttons, +}: BasicBottomSheetProps) => { + return ( + + + {title && ( + + {title} + + )} + +
{children}
+ + {buttons && ( + {buttons} + )} +
+
+ ); +}; + +export default BasicBottomSheet; diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx new file mode 100644 index 00000000..cf92a013 --- /dev/null +++ b/src/components/ui/drawer.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; + +import { Drawer as DrawerPrimitive } from 'vaul'; + +import { cn } from '@/lib/utils'; + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) => ( + +); +Drawer.displayName = 'Drawer'; + +const DrawerTrigger = DrawerPrimitive.Trigger; + +const DrawerPortal = DrawerPrimitive.Portal; + +const DrawerClose = DrawerPrimitive.Close; + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)); +DrawerContent.displayName = 'DrawerContent'; + +const DrawerHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DrawerHeader.displayName = 'DrawerHeader'; + +const DrawerFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DrawerFooter.displayName = 'DrawerFooter'; + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerDescription.displayName = DrawerPrimitive.Description.displayName; + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/src/hooks/useMediaQuery.tsx b/src/hooks/useMediaQuery.tsx new file mode 100644 index 00000000..26313977 --- /dev/null +++ b/src/hooks/useMediaQuery.tsx @@ -0,0 +1,25 @@ +import { useState, useEffect } from 'react'; + +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(false); + + useEffect(() => { + //ssr시 실행안하도록 + if (typeof window === 'undefined') return; + + const media = window.matchMedia(query); + + const handleChange = () => { + setMatches(media.matches); + }; + + setMatches(media.matches); + + media.addEventListener('change', handleChange); + + return () => { + media.removeEventListener('change', handleChange); + }; + }, [query]); + return matches; +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 40e083f0..d485f3d2 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,12 +1,30 @@ +import { useState } from 'react'; + import Link from 'next/link'; import { ContentSection } from '@/components/home/ContentSection'; import { HeroSection } from '@/components/home/HeroSection'; +import DeleteModal from '@/components/Modal/DeleteModal/DeleteModal'; +import AddWineModal from '@/components/Modal/WineModal/AddWineModal'; import { Button } from '@/components/ui/button'; export default function Home() { + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showRegisterModal, setShowRegisterModal] = useState(false); return (
+ + + +
From 6fe813001213ebd04f54fc8e9957e8f6a1d86dd2 Mon Sep 17 00:00:00 2001 From: llmojoll Date: Wed, 30 Jul 2025 19:27:22 +0900 Subject: [PATCH 2/9] =?UTF-8?q?Feat=20:=20=EB=A6=AC=EB=B7=B0=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=ED=95=98=EA=B8=B0=EC=97=90=20=EB=B0=94=ED=85=80?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modal/DeleteModal/DeleteModal.tsx | 2 +- .../Modal/ReviewModal/AddReviewModal.tsx | 290 +++++++++--------- .../Modal/WineModal/AddWineModal.tsx | 2 +- .../{BottomSheet.tsx => BasicBottomSheet.tsx} | 4 +- src/pages/index.tsx | 4 +- 5 files changed, 160 insertions(+), 142 deletions(-) rename src/components/common/BottomSheet/{BottomSheet.tsx => BasicBottomSheet.tsx} (88%) diff --git a/src/components/Modal/DeleteModal/DeleteModal.tsx b/src/components/Modal/DeleteModal/DeleteModal.tsx index b6a3ffa8..bdec89b4 100644 --- a/src/components/Modal/DeleteModal/DeleteModal.tsx +++ b/src/components/Modal/DeleteModal/DeleteModal.tsx @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { deleteReview, deleteWine, DeleteResponse } from '@/api/delete'; -import BasicBottomSheet from '@/components/common/BottomSheet/BottomSheet'; +import BasicBottomSheet from '@/components/common/BottomSheet/BasicBottomSheet'; import BasicModal from '@/components/common/Modal/BasicModal'; import { Button } from '@/components/ui/button'; import { useMediaQuery } from '@/hooks/useMediaQuery'; diff --git a/src/components/Modal/ReviewModal/AddReviewModal.tsx b/src/components/Modal/ReviewModal/AddReviewModal.tsx index a947fa61..cabef0c3 100644 --- a/src/components/Modal/ReviewModal/AddReviewModal.tsx +++ b/src/components/Modal/ReviewModal/AddReviewModal.tsx @@ -6,7 +6,9 @@ import Image from 'next/image'; import { useForm } from 'react-hook-form'; import { postReview } from '@/api/addreview'; +import BasicBottomSheet from '@/components/common/BottomSheet/BasicBottomSheet'; import StarRating from '@/components/Modal/ReviewModal/StarRating'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; import { cn } from '@/lib/utils'; import BasicModal from '../../common/Modal/BasicModal'; @@ -85,6 +87,7 @@ const aromaMap: Record = { const AddReviewModal = ({ wineId, wineName }: { wineId: number; wineName: string }) => { const [showRegisterModal, setShowRegisterModal] = useState(false); const queryClient = useQueryClient(); + const isDesktop = useMediaQuery('(min-width: 640px)'); const { register, @@ -167,149 +170,162 @@ const AddReviewModal = ({ wineId, wineName }: { wineId: number; wineName: string }; //// + const renderButton = ( + + ); + + const renderForm = () => ( +
+
+ 리뷰 아이콘 +
+ {wineName} + + setValue('rating', rating)} /> + +
+
+