-
Notifications
You must be signed in to change notification settings - Fork 1
Fix/158 리팩토링 #159
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix/158 리팩토링 #159
Changes from 4 commits
18ca00c
9d222c3
9c7f66a
2b0e3c3
c5a2456
14d8e19
93acc70
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,32 +4,41 @@ import Image from 'next/image'; | |
| import React, { useState } from 'react'; | ||
| import { ImageGridProps } from '@/types/activityDetailType'; | ||
| import { AnimatePresence, motion } from 'framer-motion'; | ||
| import Modal from '@/components/Modal'; | ||
| import { DEFAULT_BG } from '@/constants/AvatarConstants'; | ||
|
|
||
| function ImageGrid({ mainImage, subImages }: ImageGridProps) { | ||
| const images = [mainImage, ...subImages]; | ||
|
|
||
| const [image, setImage] = useState([mainImage, ...subImages]); | ||
| const [currentIndex, setCurrentIndex] = useState(0); | ||
| const [direction, setDirection] = useState(0); | ||
| const [isOpen, setIsOpen] = useState(false); | ||
| const [selectedImage, setSelectedImage] = useState<string | null>(null); | ||
|
|
||
| const handleImageClick = (image: string) => { | ||
| setSelectedImage(image); | ||
| setIsOpen(true); | ||
| }; | ||
|
|
||
| const handleImageError = (index: number) => { | ||
| setImage((prev) => prev.map((src, i) => (i === index ? DEFAULT_BG : src))); | ||
| }; | ||
|
|
||
| const prevSlide = () => { | ||
| setDirection(-1); | ||
| setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1)); | ||
| setCurrentIndex((prev) => (prev === 0 ? image.length - 1 : prev - 1)); | ||
| }; | ||
|
|
||
| const nextSlide = () => { | ||
| setDirection(1); | ||
| setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1)); | ||
| setCurrentIndex((prev) => (prev === image.length - 1 ? 0 : prev + 1)); | ||
| }; | ||
|
|
||
| const variants = { | ||
| enter: (direction: number) => ({ | ||
| x: direction > 0 ? 300 : -300, | ||
| opacity: 0, | ||
| }), | ||
| center: { | ||
| x: 0, | ||
| opacity: 1, | ||
| }, | ||
| center: { x: 0, opacity: 1 }, | ||
| exit: (direction: number) => ({ | ||
| x: direction > 0 ? -300 : 300, | ||
| opacity: 0, | ||
|
|
@@ -55,11 +64,13 @@ function ImageGrid({ mainImage, subImages }: ImageGridProps) { | |
| className='absolute inset-0' | ||
| > | ||
| <Image | ||
| src={images[currentIndex]} | ||
| alt={` ${currentIndex + 1}`} | ||
| src={image[currentIndex]} | ||
| alt={`${currentIndex + 1}`} | ||
| fill | ||
| className='rounded-lg object-cover' | ||
| priority | ||
| unoptimized | ||
| onError={() => handleImageError(currentIndex)} | ||
| /> | ||
| </motion.div> | ||
| </AnimatePresence> | ||
|
|
@@ -81,7 +92,7 @@ function ImageGrid({ mainImage, subImages }: ImageGridProps) { | |
| </button> | ||
|
|
||
| <div className='absolute bottom-2 left-1/2 flex -translate-x-1/2 gap-1'> | ||
| {images.map((_, i) => ( | ||
| {image.map((_, i) => ( | ||
| <div | ||
| key={i} | ||
| className={`h-10 w-10 rounded-full ${ | ||
|
|
@@ -94,28 +105,55 @@ function ImageGrid({ mainImage, subImages }: ImageGridProps) { | |
|
|
||
| {/* PC/태블릿 */} | ||
| <div className='hidden h-[500px] grid-cols-4 grid-rows-4 gap-6 md:grid'> | ||
| <div className='relative col-span-2 row-span-4 hover:animate-pulse'> | ||
| <div | ||
| onClick={() => handleImageClick(mainImage)} | ||
| className='relative col-span-2 row-span-4 hover:animate-pulse' | ||
| > | ||
| <Image | ||
| src={mainImage} | ||
| src={image[0]} | ||
| alt='메인이미지' | ||
| fill | ||
| className='rounded-lg object-cover' | ||
| onError={() => handleImageError(0)} | ||
| /> | ||
| </div> | ||
|
Comment on lines
+108
to
119
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. a11y: 클릭 가능한 정적 요소(div) 교체 및 클릭 소스 일관성 수정
아래 diff를 적용하면 접근성 경고를 해결하고, 클릭 동작의 일관성을 맞출 수 있습니다. - <div
- onClick={() => handleImageClick(mainImage)}
- className='relative col-span-2 row-span-4 hover:animate-pulse'
- >
+ <button
+ type='button'
+ aria-label='메인 이미지 확대 보기'
+ onClick={() => handleImageClick(image[0])}
+ className='relative col-span-2 row-span-4 hover:animate-pulse focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2'
+ >
<Image
src={image[0]}
alt='메인이미지'
fill
className='rounded-lg object-cover'
+ sizes="(min-width: 768px) 50vw, 100vw"
onError={() => handleImageError(0)}
/>
- </div>
+ </button>
- {image.slice(1, 5).map((image, index) => (
- <div
- key={index + 1}
- onClick={() => handleImageClick(image)}
- className='relative col-span-1 row-span-2 h-full hover:animate-pulse'
- >
- <Image
- src={image}
- alt={`서브이미지 ${index + 1}`}
- fill
- className='rounded-lg object-cover'
- onError={() => handleImageError(index + 1)}
- />
- </div>
- ))}
+ {image.slice(1, 5).map((src, index) => (
+ <button
+ type='button'
+ key={index + 1}
+ aria-label={`서브 이미지 ${index + 1} 확대 보기`}
+ onClick={() => handleImageClick(src)}
+ className='relative col-span-1 row-span-2 h-full hover:animate-pulse focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2'
+ >
+ <Image
+ src={src}
+ alt={`서브이미지 ${index + 1}`}
+ fill
+ className='rounded-lg object-cover'
+ sizes="(min-width: 768px) 25vw, 100vw"
+ onError={() => handleImageError(index + 1)}
+ />
+ </button>
+ ))}Also applies to: 120-134 🧰 Tools🪛 Biome (2.1.2)[error] 109-113: Static Elements should not be interactive. To add interactivity such as a mouse or key event listener to a static element, give the element an appropriate role value. (lint/a11y/noStaticElementInteractions) [error] 109-113: Enforce to have the onClick mouse event with the onKeyUp, the onKeyDown, or the onKeyPress keyboard event. Actions triggered using mouse events should have corresponding keyboard events to account for keyboard-only navigation. (lint/a11y/useKeyWithClickEvents) 🤖 Prompt for AI Agents |
||
| {subImages.slice(0, 4).map((image, index) => ( | ||
| {image.slice(1, 5).map((image, index) => ( | ||
| <div | ||
| key={index} | ||
| key={index + 1} | ||
| onClick={() => handleImageClick(image)} | ||
| className='relative col-span-1 row-span-2 h-full hover:animate-pulse' | ||
| > | ||
| <Image | ||
| src={image} | ||
| alt={`서브이미지 ${index + 1}`} | ||
| fill | ||
| className='rounded-lg object-cover' | ||
| onError={() => handleImageError(index + 1)} | ||
| /> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| <Modal onOpenChange={setIsOpen} isOpen={isOpen}> | ||
| <Modal.Content className='rounded-md'> | ||
| <Modal.Header> | ||
| <Modal.Close /> | ||
| </Modal.Header> | ||
| <Modal.Item className='flex items-center justify-center'> | ||
| <div className='relative aspect-square w-[1200px]'> | ||
| {selectedImage && ( | ||
| <Image | ||
| src={selectedImage} | ||
| alt='확대 이미지' | ||
| fill | ||
| className='rounded-lg object-cover p-18' | ||
| /> | ||
| )} | ||
| </div> | ||
| </Modal.Item> | ||
|
|
||
| <Modal.Footer></Modal.Footer> | ||
| </Modal.Content> | ||
| </Modal> | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -2,11 +2,8 @@ | |||||
|
|
||||||
| import Star from '@assets/svg/star'; | ||||||
| import { useState, useEffect } from 'react'; | ||||||
| import { ReviewTitleProps } from '@/types/activityDetailType'; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 타입 전용 임포트 제안 타입만 사용하므로 -import { ReviewTitleProps } from '@/types/activityDetailType';
+import type { ReviewTitleProps } from '@/types/activityDetailType';📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
|
|
||||||
| interface ReviewTitleProps { | ||||||
| reviewCount: number; | ||||||
| rating: number; | ||||||
| } | ||||||
| export default function ReviewTitle({ | ||||||
| reviewCount = 0, | ||||||
| rating = 0, | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -11,15 +11,8 @@ import { useParams, useRouter } from 'next/navigation'; | |||||||||||||||||||||||||||||
| import { useQueryClient } from '@tanstack/react-query'; | ||||||||||||||||||||||||||||||
| import { useDeleteActivity } from '../hooks/useDeleteActivity'; | ||||||||||||||||||||||||||||||
| import Popup from '@/components/Popup'; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| interface TitleProps { | ||||||||||||||||||||||||||||||
| title: string; | ||||||||||||||||||||||||||||||
| category: string; | ||||||||||||||||||||||||||||||
| rating: number; | ||||||||||||||||||||||||||||||
| reviewCount: number; | ||||||||||||||||||||||||||||||
| address: string; | ||||||||||||||||||||||||||||||
| isOwner: boolean; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| import { toast } from 'sonner'; | ||||||||||||||||||||||||||||||
| import { TitleProps } from '@/types/activityDetailType'; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| function Title({ | ||||||||||||||||||||||||||||||
| title, | ||||||||||||||||||||||||||||||
|
|
@@ -45,8 +38,8 @@ function Title({ | |||||||||||||||||||||||||||||
| if (!id) return; | ||||||||||||||||||||||||||||||
| mutate(id as string); | ||||||||||||||||||||||||||||||
| setIsPopupOpen(false); | ||||||||||||||||||||||||||||||
| toast.success('체험이 삭제되었습니다!'); | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| toast.success('체험이 삭제되었습니다!'); | |
| const handleDeleteConfirm = () => { | |
| if (!id) return; | |
| const idStr = Array.isArray(id) ? id[0] : (id as string); | |
| mutate(idStr, { | |
| onSuccess: () => { | |
| setIsPopupOpen(false); | |
| toast.success('체험이 삭제되었습니다!'); | |
| }, | |
| onError: () => { | |
| toast.error('삭제에 실패했습니다. 다시 시도해주세요.'); | |
| }, | |
| }); | |
| }; |
🤖 Prompt for AI Agents
In src/app/(with-header)/activities/[id]/components/Title.tsx around line 41,
the code currently shows toast.success('체험이 삭제되었습니다!') unconditionally; change
this so the toast is only shown in the mutation callbacks: remove the
unconditional toast call and instead pass onSuccess and onError handlers to the
delete mutation (or to mutateAsync's .then/.catch) that call toast.success(...)
on success and toast.error(...) on failure, and ensure any state updates or
navigation happen inside onSuccess so they run only after a confirmed successful
response.
This file was deleted.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,12 +9,7 @@ import { ActivityDetailEdit, Schedule } from '@/types/activityDetailType'; | |||||||||||||||||||||||||||||||||||||||||||||||||
| import { AxiosError } from 'axios'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { toast } from 'sonner'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { notFound } from 'next/navigation'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { validateSchedules } from '../../utils/dateValidatoin'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| interface SubImageType { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| id?: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| url: string | File; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { SubImageType } from '@/types/addEditExperienceType'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 타입 전용 임포트 제안 타입은 -import { SubImageType } from '@/types/addEditExperienceType';
+import type { SubImageType } from '@/types/addEditExperienceType';🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| export const useEditActivityForm = () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const { id } = useParams() as { id: string }; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -209,9 +204,16 @@ export const useEditActivityForm = () => { | |||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleSubmit = async (e: React.FormEvent) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const validationMessage = validateSchedules(dates); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (validationMessage) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| toast.error(validationMessage); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| !title || | ||||||||||||||||||||||||||||||||||||||||||||||||||
| !category || | ||||||||||||||||||||||||||||||||||||||||||||||||||
| !description || | ||||||||||||||||||||||||||||||||||||||||||||||||||
| !address || | ||||||||||||||||||||||||||||||||||||||||||||||||||
| !price || | ||||||||||||||||||||||||||||||||||||||||||||||||||
| !mainImage || | ||||||||||||||||||||||||||||||||||||||||||||||||||
| dates.length === 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| toast.error('소개이미지를 제외한 모든값은 필수값입니다!'); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+207
to
217
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 🛠️ Refactor suggestion 검증 메시지와 로직 불일치 (메인 이미지 필수인데 “제외”) + 스케줄 필드 공란 검증 권장 메인 이미지를 필수로 요구하면서 메시지는 반대로 안내합니다. 또한 스케줄 항목의 날짜/시간 공란도 빠르게 검증하면 UX가 좋아집니다. if (
!title ||
!category ||
!description ||
!address ||
!price ||
!mainImage ||
- dates.length === 0
+ dates.length === 0 ||
+ dates.some((d) => !d.date || !d.startTime || !d.endTime)
) {
- toast.error('소개이미지를 제외한 모든값은 필수값입니다!');
+ toast.error('모든 값은 필수값입니다!');
return;
}추가로, 생성 훅(useCreateActivityForm)과 본 훅의 검증 메시지를 통일하면 사용자 혼란을 줄일 수 있습니다. 원하시면 생성/수정 공통 폼 검증 유틸(예: validateActivityForm)을 만들어 두 훅에서 공유하도록 PR 보조 커밋을 제안드릴 수 있습니다. 📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,9 +1,5 @@ | ||||||
| import ChevronIcon from '@assets/svg/chevron'; // 아이콘 경로는 맞게 조정 | ||||||
|
|
||||||
| interface CategoryProps { | ||||||
| category?: string; | ||||||
| onCategoryChange: (value: string) => void; | ||||||
| } | ||||||
| import { CategoryProps } from '@/types/addEditExperienceType'; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 타입 전용 임포트로 미묘한 번들/트리셰이킹 이슈 예방 타입만 사용하는 경우 -import { CategoryProps } from '@/types/addEditExperienceType';
+import type { CategoryProps } from '@/types/addEditExperienceType';📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
|
|
||||||
| export default function CategoryInput({ | ||||||
| category, | ||||||
|
|
@@ -16,7 +12,7 @@ export default function CategoryInput({ | |||||
| </label> */} | ||||||
| <div className='relative w-full'> | ||||||
| <select | ||||||
| className={`appearance-none w-full rounded-md border border-gray-800 bg-white px-20 py-17 ${ | ||||||
| className={`w-full appearance-none rounded-md border border-gray-800 bg-white px-20 py-17 ${ | ||||||
| category ? 'text-black' : 'text-gray-400' | ||||||
| }`} | ||||||
| id='category' | ||||||
|
|
@@ -26,16 +22,28 @@ export default function CategoryInput({ | |||||
| <option value='' disabled hidden> | ||||||
| 카테고리 | ||||||
| </option> | ||||||
| <option className='text-black' value='문화 · 예술'>문화 · 예술</option> | ||||||
| <option className='text-black' value='식음료'>식음료</option> | ||||||
| <option className='text-black' value='스포츠'>스포츠</option> | ||||||
| <option className='text-black' value='투어'>투어</option> | ||||||
| <option className='text-black' value='관광'>관광</option> | ||||||
| <option className='text-black' value='웰빙'>웰빙</option> | ||||||
| <option className='text-black' value='문화 · 예술'> | ||||||
| 문화 · 예술 | ||||||
| </option> | ||||||
| <option className='text-black' value='식음료'> | ||||||
| 식음료 | ||||||
| </option> | ||||||
| <option className='text-black' value='스포츠'> | ||||||
| 스포츠 | ||||||
| </option> | ||||||
| <option className='text-black' value='투어'> | ||||||
| 투어 | ||||||
| </option> | ||||||
| <option className='text-black' value='관광'> | ||||||
| 관광 | ||||||
| </option> | ||||||
| <option className='text-black' value='웰빙'> | ||||||
| 웰빙 | ||||||
| </option> | ||||||
|
Comment on lines
+25
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 하드코딩된 옵션을 상수/타입으로 관리해 중복 제거 동일 카테고리 목록이 여러 컴포넌트에 흩어지면 유지보수 비용이 커집니다. 상수(또는 전역 - <option className='text-black' value='문화 · 예술'>
- 문화 · 예술
- </option>
- <option className='text-black' value='식음료'>
- 식음료
- </option>
- <option className='text-black' value='스포츠'>
- 스포츠
- </option>
- <option className='text-black' value='투어'>
- 투어
- </option>
- <option className='text-black' value='관광'>
- 관광
- </option>
- <option className='text-black' value='웰빙'>
- 웰빙
- </option>
+ {CATEGORIES.map((c) => (
+ <option key={c} className='text-black' value={c}>
+ {c}
+ </option>
+ ))}추가(파일 상단 등) 코드 예시: // 가능하면 공용 상수(예: ACTIVITY_CATEGORIES)를 import 해 사용하세요.
// 임시로 로컬 상수 사용 시:
const CATEGORIES = ['문화 · 예술', '식음료', '스포츠', '투어', '관광', '웰빙'] as const;🤖 Prompt for AI Agents |
||||||
| </select> | ||||||
|
|
||||||
| {/* 커스텀 화살표 아이콘 */} | ||||||
| <div className='pointer-events-none absolute right-12 top-1/2 -translate-y-1/2'> | ||||||
| <div className='pointer-events-none absolute top-1/2 right-12 -translate-y-1/2'> | ||||||
| <ChevronIcon size={20} /> | ||||||
| </div> | ||||||
| </div> | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick (assertive)
props 변경 시 이미지 상태 동기화 필요
초기 렌더에서만
[mainImage, ...subImages]로 state를 설정하므로, props가 갱신되어도 UI가 반영되지 않습니다. 상세 페이지에서 데이터가 비동기로 로드/변경될 수 있다면 동기화 로직을 추가하세요.아래 수정으로 props 변화에 동기화할 수 있습니다.
📝 Committable suggestion
🤖 Prompt for AI Agents