diff --git a/public/wine.png b/public/wine.png new file mode 100644 index 0000000..fa4966e Binary files /dev/null and b/public/wine.png differ diff --git a/src/assets/icons/dot.svg b/src/assets/icons/dot.svg new file mode 100644 index 0000000..fb1a8c3 --- /dev/null +++ b/src/assets/icons/dot.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/common/card/MyCard.tsx b/src/components/common/card/MyCard.tsx index fdd23ae..1bfa16b 100644 --- a/src/components/common/card/MyCard.tsx +++ b/src/components/common/card/MyCard.tsx @@ -33,10 +33,10 @@ export function MyCard({ rating, timeAgo, title, review, rightSlot, className }: {/* 제목 */} -

{title}

+

{title}

{/* 리뷰 내용 */} -

{review}

+

{review}

); } diff --git a/src/components/common/dropdown/MenuDropdown.tsx b/src/components/common/dropdown/MenuDropdown.tsx index fab6a29..aa410cf 100644 --- a/src/components/common/dropdown/MenuDropdown.tsx +++ b/src/components/common/dropdown/MenuDropdown.tsx @@ -28,7 +28,7 @@ interface MenuDropdownProps { export default function MenuDropdown({ options, onSelect, trigger }: MenuDropdownProps) { return ( - + {/* 드롭다운 트리거 버튼 */} {trigger} {/* 드롭다운 메뉴 영역 */} diff --git a/src/components/myprofile/Profile.tsx b/src/components/myprofile/Profile.tsx new file mode 100644 index 0000000..c0e2a0d --- /dev/null +++ b/src/components/myprofile/Profile.tsx @@ -0,0 +1,109 @@ +import React from 'react'; + +import { useForm, type SubmitHandler } from 'react-hook-form'; + +import Input from '@/components/common/Input'; +import { Button } from '@/components/ui/button'; + +interface ProfileProps { + nickname: string; // 현재 사용자 닉네임 (초기값으로 사용) + profileImageUrl: string; // 프로필 이미지 URL (이미지 표시용) +} + +interface FormValues { + nickname: string; // 폼에서 입력할 닉네임 값 +} + +export default function Profile({ nickname, profileImageUrl }: ProfileProps) { + // useForm 훅 초기화 + const { + register, // input 등록용 함수 + handleSubmit, // 폼 제출 핸들러 래퍼 + watch, // 특정 필드 값 관찰 + reset, // 폼 상태 초기화 + formState: { isSubmitting }, // 제출 중 상태 + } = useForm({ + defaultValues: { nickname }, // 초기값으로 기존 닉네임 설정 + mode: 'onChange', // 입력 시마다 유효성 검사 실행 + }); + + // 현재 입력된 값을 관찰 + const current = watch('nickname'); + // 기존 닉네임과 다르고 비어있지 않을 때만 true + const isChanged = current.trim().length > 0 && current !== nickname; + + // 폼 제출 시 호출되는 함수 + const onSubmit: SubmitHandler = async (data) => { + try { + // 실제 API 연결 시 axios/fetch 호출로 교체 + await new Promise((r) => setTimeout(r, 1000)); + console.log(`닉네임 변경: ${nickname} → ${data.nickname}`); + + // 제출 성공 후 폼 상태를 새 기본값으로 초기화 + reset({ nickname: data.nickname }); + } catch (e) { + // 에러 UI 없이 콘솔에만 출력 + console.error('닉네임 변경 오류:', e); + } + }; + + return ( +
+ {/* 프로필 섹션: 이미지 & 현재 닉네임 */} +
+
+ {/* 추후 이미지 업로드 기능 추가 필요 */} + 프로필 이미지 +
+
{nickname}
+
+ + {/* 닉네임 변경 폼 */} +
+ {/* 입력 필드 그룹 */} +
+ + ) => + // 브라우저 유효성 오류를 콘솔에만 출력 + console.error( + '닉네임 유효성 오류:', + (e.currentTarget as HTMLInputElement).validationMessage, + ) + } + /> +
+ + {/* 제출 버튼: 버튼이 좀 이상해서 api 연결 후 수정해보겠습니다다 */} + +
+
+ ); +} diff --git a/src/components/myprofile/ReviewList.tsx b/src/components/myprofile/ReviewList.tsx new file mode 100644 index 0000000..89cf7b7 --- /dev/null +++ b/src/components/myprofile/ReviewList.tsx @@ -0,0 +1,90 @@ +import React from 'react'; + +import { useQuery } from '@tanstack/react-query'; + +import DotIcon from '@/assets/icons/dot.svg'; +import { MyCard } from '@/components/common/card/MyCard'; +import MenuDropdown from '@/components/common/dropdown/MenuDropdown'; +import { Badge } from '@/components/ui/badge'; + +import { mockMyReviewsPage1 } from './mockUser'; + +/** + * Review 타입 정의 (mock 데이터에서 추론) + */ +type Review = (typeof mockMyReviewsPage1.list)[number]; + +/** + * 데이터 가져오는 함수 (현재는 mock, 추후 API 호출로 교체) + * 데이터 패치 내용은 무한스크롤 훅 구현 후 수정될 예정입니다 + */ +async function fetchReviews(): Promise { + return mockMyReviewsPage1.list; +} + +/** + * ReviewList 컴포넌트 + * - React Query의 useQuery 훅을 사용해 리뷰 데이터를 패칭 + * - 로딩 및 에러 상태를 처리한 뒤, MyCard 컴포넌트로 리스트를 렌더링 + */ +export function ReviewList() { + // React Query로 리뷰 데이터 요청 + const { + data: items = [], + isLoading, + isError, + } = useQuery({ + queryKey: ['myReviews'], + queryFn: fetchReviews, + }); + + // 로딩 중 표시 + if (isLoading) { + return

리뷰 불러오는 중…

; + } + + // 에러 시 표시 + if (isError) { + return

리뷰 불러오기 실패

; + } + + // 실제 리뷰 리스트 렌더링 + return ( +
+ {items.map((review) => ( + + + ★ {review.rating.toFixed(1)} + + + } + // 작성일 + timeAgo={new Date(review.createdAt).toLocaleDateString()} + // 작성자 닉네임 + title={review.user.nickname} + // 리뷰 내용 + review={review.content} + // dot 아이콘 클릭 시 드롭다운 오픈 + rightSlot={ + + + + } + options={[ + { label: '수정하기', value: 'edit' }, + { label: '삭제하기', value: 'delete' }, + ]} + onSelect={(value) => console.log(`${value} clicked for review id: ${review.id}`)} + /> + } + /> + ))} +
+ ); +} diff --git a/src/components/myprofile/Tab.tsx b/src/components/myprofile/Tab.tsx new file mode 100644 index 0000000..24643ac --- /dev/null +++ b/src/components/myprofile/Tab.tsx @@ -0,0 +1,33 @@ +type Tab = 'reviews' | 'wines'; + +interface TabNavProps { + current: Tab; + onChange: (t: Tab) => void; + reviewsCount: number; + winesCount: number; +} + +export function TabNav({ current, onChange, reviewsCount, winesCount }: TabNavProps) { + const count = current === 'reviews' ? reviewsCount : winesCount; + + return ( + + ); +} diff --git a/src/components/myprofile/WineList.tsx b/src/components/myprofile/WineList.tsx new file mode 100644 index 0000000..5b8c155 --- /dev/null +++ b/src/components/myprofile/WineList.tsx @@ -0,0 +1,93 @@ +import React from 'react'; + +import { useQuery } from '@tanstack/react-query'; + +import DotIcon from '@/assets/icons/dot.svg'; +import { ImageCard } from '@/components/common/card/ImageCard'; +import MenuDropdown from '@/components/common/dropdown/MenuDropdown'; +import { Badge } from '@/components/ui/badge'; + +import { mockMyWinesPage1 } from './mockUser'; + +/** + * Wine 타입 정의 (mock 데이터에서 추론) + */ +type Wine = (typeof mockMyWinesPage1.list)[number]; + +/** + * 데이터 가져오는 함수 (현재는 mock, 추후 API 호출로 교체) + * 데이터 패치 내용은 무한스크롤 훅 구현 후 수정될 예정입니다 + */ +async function fetchWines(): Promise { + return mockMyWinesPage1.list; +} + +/** + * WineList 컴포넌트 + * - React Query의 useQuery 훅을 사용해 리뷰 데이터를 패칭 + * - 로딩 및 에러 상태를 처리한 뒤, ImageCard 컴포넌트로 리스트를 렌더링 + */ +export function WineList() { + // React Query로 와인 목록 패칭 + const { + data: items = [], + isLoading, + isError, + } = useQuery({ + queryKey: ['myWines'], + queryFn: fetchWines, + }); + + // 로딩 중 표시 + if (isLoading) { + return

와인 불러오는 중…

; + } + + // 에러 시 표시 + if (isError) { + return

와인 불러오기 실패

; + } + + return ( +
+ {items.map((w) => ( + + + + } + options={[ + { label: '수정하기', value: 'edit' }, + { label: '삭제하기', value: 'delete' }, + ]} + onSelect={(value) => console.log(`${value} clicked for wine id: ${w.id}`)} + /> + } + > + {/* 카드 내부: 와인 정보 */} +
+

+ {w.name} {/* 와인 이름 */} +

+

+ {w.region} {/* 생산 지역 */} +

+ + + {/* 가격 표시 */}₩ {w.price.toLocaleString()} + + +
+
+ ))} +
+ ); +} diff --git a/src/components/myprofile/mockUser.ts b/src/components/myprofile/mockUser.ts new file mode 100644 index 0000000..10d36ce --- /dev/null +++ b/src/components/myprofile/mockUser.ts @@ -0,0 +1,217 @@ +// get으로 응답받는 responese +// 초기에 불러올 데이터 +export const mockMyReviewsPage1 = { + totalCount: 4, + nextCursor: 2, + list: [ + { + id: 101, + rating: 4.8, + lightBold: 2, + smoothTannic: 4, + drySweet: 1, + softAcidic: 3, + aroma: ['CHERRY', 'OAK'], + content: '진한 체리향과 부드러운 탄닌이 좋았어요.', + createdAt: '2025-07-25T02:50:33.040669Z', + updatedAt: '2025-07-25T02:50:33.040669Z', + user: { + id: 1, + nickname: '와인러버', + image: 'https://picsum.photos/seed/me1/32', + }, + isLiked: false, + }, + { + id: 102, + rating: 4.2, + lightBold: 3, + smoothTannic: 2, + drySweet: 2, + softAcidic: 2, + aroma: ['BLACKCURRANT'], + content: '깔끔하고 가볍게 마시기 좋은 와인이에요.', + createdAt: '2025-07-25T02:50:33.040669Z', + updatedAt: '2025-07-25T02:50:33.040669Z', + user: { + id: 1, + nickname: '와인러버', + image: 'https://picsum.photos/seed/me1/32', + }, + isLiked: true, + }, + ], +}; + +export const mockMyReviewsPage2 = { + totalCount: 4, + nextCursor: null, + list: [ + { + id: 103, + rating: 4.9, + lightBold: 4, + smoothTannic: 5, + drySweet: 1, + softAcidic: 4, + aroma: ['SPICE', 'PLUM'], + content: '풍미 깊고 고급스러운 맛이에요.', + createdAt: '2025-07-25T02:50:33.040669Z', + updatedAt: '2025-07-25T02:50:33.040669Z', + user: { + id: 1, + nickname: '와인러버', + image: 'https://picsum.photos/seed/me1/32', + }, + isLiked: false, + }, + { + id: 104, + rating: 3.8, + lightBold: 2, + smoothTannic: 1, + drySweet: 3, + softAcidic: 2, + aroma: ['FLORAL'], + content: '꽃향이 강하게 나서 인상 깊었어요.', + createdAt: '2025-07-25T02:50:33.040669Z', + updatedAt: '2025-07-25T02:50:33.040669Z', + user: { + id: 1, + nickname: '와인러버', + image: 'https://picsum.photos/seed/me1/32', + }, + isLiked: true, + }, + ], +}; + +export const mockMyWinesPage1 = { + totalCount: 4, + nextCursor: 2, + list: [ + { + id: 201, + name: 'Opus One 2019', + region: 'Napa Valley, USA', + image: '/wine.png', + price: 350000, + type: 'Red', + avgRating: 4.6, + reviewCount: 8, + userId: 1, + recentReview: { + id: 101, + rating: 4.8, + lightBold: 2, + smoothTannic: 4, + drySweet: 1, + softAcidic: 3, + aroma: ['CHERRY', 'OAK'], + content: '진한 체리향과 부드러운 탄닌이 좋았어요.', + createdAt: '2025-07-25T02:50:33.040669Z', + updatedAt: '2025-07-25T02:50:33.040669Z', + user: { + id: 1, + nickname: '와인러버', + image: 'https://picsum.photos/seed/me1/32', + }, + isLiked: false, + }, + }, + { + id: 202, + name: 'Sassicaia 2018', + region: 'Tuscany, Italy', + image: '/wine.png', + price: 290000, + type: 'Red', + avgRating: 4.7, + reviewCount: 10, + userId: 1, + recentReview: { + id: 102, + rating: 4.2, + lightBold: 3, + smoothTannic: 2, + drySweet: 2, + softAcidic: 2, + aroma: ['BLACKCURRANT'], + content: '깔끔하고 가볍게 마시기 좋은 와인이에요.', + createdAt: '2025-07-25T02:50:33.040669Z', + updatedAt: '2025-07-25T02:50:33.040669Z', + user: { + id: 1, + nickname: '와인러버', + image: 'https://picsum.photos/seed/me1/32', + }, + isLiked: true, + }, + }, + ], +}; + +export const mockMyWinesPage2 = { + totalCount: 4, + nextCursor: null, + list: [ + { + id: 203, + name: 'Château Latour 2010', + region: 'Bordeaux, France', + image: '/wine.png', + price: 410000, + type: 'Red', + avgRating: 4.9, + reviewCount: 12, + userId: 1, + recentReview: { + id: 103, + rating: 4.9, + lightBold: 4, + smoothTannic: 5, + drySweet: 1, + softAcidic: 4, + aroma: ['SPICE', 'PLUM'], + content: '풍미 깊고 고급스러운 맛이에요.', + createdAt: '2025-07-25T02:50:33.040669Z', + updatedAt: '2025-07-25T02:50:33.040669Z', + user: { + id: 1, + nickname: '와인러버', + image: 'https://picsum.photos/seed/me1/32', + }, + isLiked: false, + }, + }, + { + id: 204, + name: 'Cloudy Bay Sauvignon Blanc 2021', + region: 'Marlborough, New Zealand', + image: '/wine.png', + price: 65000, + type: 'White', + avgRating: 4.3, + reviewCount: 6, + userId: 1, + recentReview: { + id: 104, + rating: 3.8, + lightBold: 2, + smoothTannic: 1, + drySweet: 3, + softAcidic: 2, + aroma: ['FLORAL'], + content: '꽃향이 강하게 나서 인상 깊었어요.', + createdAt: '2025-07-25T02:50:33.040669Z', + updatedAt: '2025-07-25T02:50:33.040669Z', + user: { + id: 1, + nickname: '와인러버', + image: 'https://picsum.photos/seed/me1/32', + }, + isLiked: true, + }, + }, + ], +}; diff --git a/src/pages/myprofile/index.tsx b/src/pages/myprofile/index.tsx new file mode 100644 index 0000000..026b2c9 --- /dev/null +++ b/src/pages/myprofile/index.tsx @@ -0,0 +1,34 @@ +import React, { useState } from 'react'; + +import { mockMyReviewsPage1, mockMyWinesPage1 } from '@/components/myprofile/mockUser'; +import Profile from '@/components/myprofile/Profile'; +import { ReviewList } from '@/components/myprofile/ReviewList'; +import { TabNav } from '@/components/myprofile/Tab'; +import { WineList } from '@/components/myprofile/WineList'; + +export default function MyProfile() { + // 탭 상태: 'reviews' | 'wines' + const [tab, setTab] = useState<'reviews' | 'wines'>('reviews'); + + return ( +
+
+ {/* 프로필 섹션 */} + + + {/* 탭 & 리스트 섹션 */} +
+ + + {/* 탭에 따라 ReviewList 또는 WineList에 props 전달 */} + {tab === 'reviews' ? : } +
+
+
+ ); +}