-
Notifications
You must be signed in to change notification settings - Fork 1
Feat/100 내 체험 관리 페이지(UI 및 API연동) #115
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
Changes from 2 commits
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 |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import React from 'react'; | ||
|
|
||
| const MoreOptionsIcon = ({ size = 24, ...props }) => ( | ||
| <svg | ||
| xmlns='http://www.w3.org/2000/svg' | ||
| width={size} | ||
| height={size} | ||
| fill='none' | ||
| viewBox='0 0 40 40' | ||
| {...props} | ||
| > | ||
| <circle cx='20' cy='9' r='3' fill='#79747E' /> | ||
| <circle cx='20' cy='20' r='3' fill='#79747E' /> | ||
| <circle cx='20' cy='31' r='3' fill='#79747E' /> | ||
| </svg> | ||
| ); | ||
|
|
||
| export default MoreOptionsIcon; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,28 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { privateInstance } from './privateInstance'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { MyActivitiesResponse } from '@/types/dashboardTypes'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * 내 체험 리스트 조회 (무한 스크롤용) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * GET /my-activities | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const getMyActivitiesWithPagination = async (params?: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cursorId?: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| size?: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }): Promise<MyActivitiesResponse> => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const queryParams = new URLSearchParams(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (params?.cursorId) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| queryParams.append('cursorId', params.cursorId.toString()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (params?.size) queryParams.append('size', params.size.toString()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const url = `/my-activities${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const response = await privateInstance.get(url); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return response.data; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+8
to
+20
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) API 함수 구현을 승인하며 쿼리 파라미터 처리 개선을 제안합니다 전반적인 구현이 잘 되어 있습니다. 쿼리 파라미터 생성 부분을 약간 개선할 수 있습니다. 더 간결한 구현을 위해 다음과 같이 개선할 수 있습니다: export const getMyActivitiesWithPagination = async (params?: {
cursorId?: number;
size?: number;
}): Promise<MyActivitiesResponse> => {
- const queryParams = new URLSearchParams();
- if (params?.cursorId)
- queryParams.append('cursorId', params.cursorId.toString());
- if (params?.size) queryParams.append('size', params.size.toString());
-
- const url = `/my-activities${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
+ const queryParams = new URLSearchParams();
+ if (params?.cursorId !== undefined) {
+ queryParams.append('cursorId', params.cursorId.toString());
+ }
+ if (params?.size !== undefined) {
+ queryParams.append('size', params.size.toString());
+ }
+
+ const queryString = queryParams.toString();
+ const url = `/my-activities${queryString ? `?${queryString}` : ''}`;
const response = await privateInstance.get(url);
return response.data;
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * 내 체험 삭제 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * DELETE /deleteActivity/{id} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const deleteMyActivity = async (id: number): Promise<void> => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await privateInstance.delete(`/deleteActivity/${id}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| 'use client'; | ||
|
|
||
| import { ProfileNavigation } from '@/app/(with-header)/mypage/components'; | ||
| import useResponsiveRouting from '@/hooks/useResponsiveRouting'; | ||
| import { useMyProfile } from '@/hooks/useMyPageQueries'; | ||
|
|
||
| export default function MyActivityLayout({ | ||
| children, | ||
| }: { | ||
| children: React.ReactNode; | ||
| }) { | ||
| const { mounted } = useResponsiveRouting(); | ||
| const { isLoading, error } = useMyProfile(); | ||
|
|
||
| // mounted + API 로딩 상태 모두 체크 | ||
| if (!mounted || isLoading) { | ||
| return ( | ||
| <div className='min-h-screen bg-gray-100'> | ||
| <div className='mx-auto max-w-1200 px-20 py-24 lg:py-72'> | ||
| <div className='flex gap-24'> | ||
| {/* 좌측 프로필 네비게이션 스켈레톤 - 데스크톱/태블릿 */} | ||
| <div className='hidden flex-shrink-0 animate-pulse md:block'> | ||
| <div className='h-432 w-251 rounded border border-gray-300 bg-white p-24 lg:w-384'> | ||
| {/* 프로필 이미지 영역 */} | ||
| <div className='mb-32 flex justify-center'> | ||
| <div className='h-160 w-160 rounded-full bg-gray-200'></div> | ||
| </div> | ||
| {/* 메뉴 리스트 영역 */} | ||
| <div className='space-y-2'> | ||
| {[1, 2, 3, 4].map((i) => ( | ||
| <div | ||
| key={i} | ||
| className='h-44 w-203 rounded-xl bg-gray-200 lg:w-336' | ||
| ></div> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| {/* 메인 스켈레톤 */} | ||
| <div className='flex-grow animate-pulse rounded bg-gray-200'></div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (error) { | ||
| return ( | ||
| <div className='flex min-h-screen items-center justify-center bg-gray-100'> | ||
| <div className='text-center'> | ||
| <h2 className='mb-2 text-xl font-bold text-red-500'> | ||
| 로그인이 필요합니다 | ||
| </h2> | ||
| <p className='text-gray-600'>다시 로그인해주세요.</p> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| // API 로딩 완료 + mounted 상태일 때만 실행 | ||
| return ( | ||
| <div className='min-h-screen bg-gray-100'> | ||
| <div className='mx-auto max-w-1200 px-20 py-24 lg:py-72'> | ||
| <div className='flex gap-24'> | ||
| {/* 좌측 프로필 네비게이션 섹션 - 데스크톱/태블릿에서만 표시 */} | ||
| <ProfileNavigation /> | ||
|
|
||
| {/* 우측 메인 콘텐츠 섹션 */} | ||
| <div className='flex-grow'>{children}</div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| 'use client'; | ||
|
|
||
| import Image from 'next/image'; | ||
| import { useState } from 'react'; | ||
| import { MyActivity } from '@/types/dashboardTypes'; | ||
| import { useRouter } from 'next/navigation'; | ||
| import Star from '@assets/svg/star'; | ||
| import MoreOptionsIcon from '@assets/svg/moreOptionsIcon'; | ||
|
|
||
| interface ActivityCardProps { | ||
| activity: MyActivity; | ||
| onDelete: (activityId: number) => void; | ||
| } | ||
|
|
||
| export default function ActivityCard({ | ||
| activity, | ||
| onDelete, | ||
| }: ActivityCardProps) { | ||
| const [isMenuOpen, setIsMenuOpen] = useState(false); | ||
| const router = useRouter(); | ||
|
|
||
| const { id, title, price, bannerImageUrl, rating, reviewCount } = activity; | ||
|
|
||
| const handleEdit = () => { | ||
| router.push(`/myactivity/${id}`); | ||
| }; | ||
|
|
||
| const handleDelete = () => { | ||
| onDelete(id); | ||
| setIsMenuOpen(false); | ||
| }; | ||
|
|
||
| return ( | ||
| <div className='flex h-204 w-792 rounded-3xl border border-gray-300 bg-white'> | ||
| {/* 이미지 영역 */} | ||
| <div className='relative h-204 w-204 flex-shrink-0 overflow-hidden rounded-l-xl'> | ||
| <Image src={bannerImageUrl} alt={title} fill className='object-cover' /> | ||
| </div> | ||
|
|
||
| {/* 콘텐츠 영역 */} | ||
| <div className='flex flex-1 flex-col justify-start px-24 py-14'> | ||
| {/* 별점 및 리뷰 */} | ||
| <div className='flex items-center gap-6'> | ||
| <div className='flex items-center gap-2'> | ||
| <Star size={19} /> | ||
| <span className='font-base font-normal text-black'>{rating}</span> | ||
| <span className='font-base font-normal text-black'> | ||
| ({reviewCount}) | ||
| </span> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* 제목 */} | ||
| <div className='mt-6'> | ||
| <h3 className='text-nomad text-xl font-bold'>{title}</h3> | ||
| </div> | ||
|
|
||
| <div className='mt-auto flex items-center justify-between'> | ||
| {/* 가격 */} | ||
| <p className='text-2xl font-medium text-gray-900'> | ||
| ₩{price.toLocaleString()} / 인 | ||
| </p> | ||
|
|
||
| {/* 더보기 옵션 */} | ||
| <div className='relative'> | ||
| <button | ||
| onClick={() => setIsMenuOpen(!isMenuOpen)} | ||
| className='flex h-40 w-40 items-center justify-center rounded-full hover:bg-gray-100' | ||
| > | ||
| <MoreOptionsIcon size={40} /> | ||
| </button> | ||
|
Comment on lines
+67
to
+71
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. 버튼에 명시적인 type 속성을 추가하세요 모든 버튼에 <button
+ type="button"
onClick={() => setIsMenuOpen(!isMenuOpen)}
className='flex h-40 w-40 items-center justify-center rounded-full hover:bg-gray-100'
>
<button
+ type="button"
onClick={handleEdit}
className='flex h-62 w-full items-center justify-center border-b border-gray-300 px-46 py-18 text-center text-lg font-medium text-gray-900 hover:bg-gray-50'
>
<button
+ type="button"
onClick={handleDelete}
className='flex h-62 w-full items-center justify-center px-46 py-18 text-center text-lg font-medium text-gray-900 hover:bg-gray-50'
>Also applies to: 83-87, 89-93 🧰 Tools🪛 Biome (2.1.2)[error] 67-71: Provide an explicit type prop for the button element. The default type of a button is submit, which causes the submission of a form when placed inside a (lint/a11y/useButtonType) 🤖 Prompt for AI Agents |
||
|
|
||
| {isMenuOpen && ( | ||
| <> | ||
| <div | ||
| className='fixed inset-0 z-40' | ||
| onClick={() => setIsMenuOpen(false)} | ||
| /> | ||
|
|
||
| {/* 드롭다운 메뉴 */} | ||
| <div className='absolute top-full right-0 z-50 w-160 rounded-md border border-gray-300 bg-white shadow-lg'> | ||
| <button | ||
| onClick={handleEdit} | ||
| className='flex h-62 w-full items-center justify-center border-b border-gray-300 px-46 py-18 text-center text-lg font-medium text-gray-900 hover:bg-gray-50' | ||
| > | ||
| 수정하기 | ||
| </button> | ||
| <button | ||
| onClick={handleDelete} | ||
| className='flex h-62 w-full items-center justify-center px-46 py-18 text-center text-lg font-medium text-gray-900 hover:bg-gray-50' | ||
| > | ||
| 삭제하기 | ||
| </button> | ||
| </div> | ||
| </> | ||
| )} | ||
|
Comment on lines
+73
to
+96
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 드롭다운 메뉴의 접근성을 개선하세요 현재 구현은 키보드 네비게이션을 지원하지 않습니다. 접근성을 위해 다음을 고려하세요:
Headless UI의 Menu 컴포넌트나 Radix UI의 DropdownMenu를 사용하는 것을 추천합니다. 이미 구현된 접근성 기능을 활용할 수 있습니다. 🧰 Tools🪛 Biome (2.1.2)[error] 76-81: 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] 76-81: 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) [error] 84-88: Provide an explicit type prop for the button element. The default type of a button is submit, which causes the submission of a form when placed inside a (lint/a11y/useButtonType) [error] 90-94: Provide an explicit type prop for the button element. The default type of a button is submit, which causes the submission of a form when placed inside a (lint/a11y/useButtonType) 🤖 Prompt for AI Agents |
||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,65 @@ | ||||||||||||||||||||||||||||
| 'use client'; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| import Modal from '@/components/Modal'; | ||||||||||||||||||||||||||||
| import Button from '@/components/Button'; | ||||||||||||||||||||||||||||
| import CheckIcon from '@assets/svg/check'; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| interface DeleteActivityModalProps { | ||||||||||||||||||||||||||||
| isOpen: boolean; | ||||||||||||||||||||||||||||
| onCancel: () => void; | ||||||||||||||||||||||||||||
| onConfirm: () => void; | ||||||||||||||||||||||||||||
| isLoading?: boolean; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export default function DeleteActivityModal({ | ||||||||||||||||||||||||||||
| isOpen, | ||||||||||||||||||||||||||||
| onCancel, | ||||||||||||||||||||||||||||
| onConfirm, | ||||||||||||||||||||||||||||
| isLoading = false, | ||||||||||||||||||||||||||||
| }: DeleteActivityModalProps) { | ||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||
| <Modal isOpen={isOpen} onOpenChange={(open) => !open && onCancel()}> | ||||||||||||||||||||||||||||
| <Modal.Content className='!h-184 !w-298 !max-w-none !min-w-0 !rounded-xl !p-0'> | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||
| className='flex h-full w-full flex-col items-center justify-center gap-24 bg-white p-16' | ||||||||||||||||||||||||||||
| style={{ | ||||||||||||||||||||||||||||
| borderRadius: '12px', | ||||||||||||||||||||||||||||
| background: '#FFFFFF', | ||||||||||||||||||||||||||||
| boxShadow: '0px 4px 16px 0px rgba(17, 34, 17, 0.05)', | ||||||||||||||||||||||||||||
| overflow: 'hidden', | ||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
| <Modal.Content className='!h-184 !w-298 !max-w-none !min-w-0 !rounded-xl !p-0'> | |
| <div | |
| className='flex h-full w-full flex-col items-center justify-center gap-24 bg-white p-16' | |
| style={{ | |
| borderRadius: '12px', | |
| background: '#FFFFFF', | |
| boxShadow: '0px 4px 16px 0px rgba(17, 34, 17, 0.05)', | |
| overflow: 'hidden', | |
| }} | |
| <Modal.Content className='!h-184 !w-298 !max-w-none !min-w-0 !rounded-xl !p-0'> | |
| <div | |
| className='flex h-full w-full flex-col items-center justify-center gap-24 bg-white p-16 rounded-xl shadow-[0px_4px_16px_0px_rgba(17,34,17,0.05)] overflow-hidden' | |
| > |
🤖 Prompt for AI Agents
In src/app/(with-header)/mypage/activities/components/DeleteActivityModal.tsx
around lines 22 to 30, avoid mixing inline styles with className attributes.
Replace the inline style object with equivalent Tailwind CSS utility classes or
move the styles to a CSS module and apply them via className to improve
maintainability and consistency.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import EmptyDocumentIcon from '@assets/svg/empty-document'; | ||
|
|
||
| export default function EmptyActivities() { | ||
| return ( | ||
| <div className='flex flex-col items-center justify-center py-120'> | ||
| {/* 빈 상태 아이콘 */} | ||
| <div className='mb-24'> | ||
| <EmptyDocumentIcon size={131} /> | ||
| </div> | ||
|
|
||
| {/* 빈 상태 메시지 */} | ||
| <p className='text-2xl font-normal text-gray-700'> | ||
| 아직 등록한 체험이 없어요 | ||
| </p> | ||
| </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.
🛠️ Refactor suggestion
TypeScript 타입 정의와 접근성 개선이 필요합니다
컴포넌트에 적절한 타입 정의가 없고, 접근성을 위한 속성이 누락되어 있습니다.
다음과 같이 개선하세요:
📝 Committable suggestion
🤖 Prompt for AI Agents