+
+ {userIcon}
-
- {/* 좋아요 & 메뉴 */}
-
- {likeSlot}
- {menuSlot}
+
+ {username}
+ {timeAgo}
- {/* 태그 & 별점 */}
-
-
- {tags.map((tag, idx) => (
-
- {tag}
-
- ))}
-
- {rating}
+ {/* 좋아요 & 메뉴 */}
+
{children}
+
+ );
+};
+
+ReviewCard.TagAndRating = function TagAndRating({ reviewId }: TagAndRatingProps) {
+ const tags = useReviewCardStore((state) => state.allReviews[reviewId]?.aroma ?? []);
+ const rating = useReviewCardStore((state) => state.allReviews[reviewId]?.rating);
+ return (
+
+
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ {rating}
+
+
+ );
+};
- {/* 리뷰 텍스트 */}
- {reviewText &&
{reviewText}
}
+ReviewCard.ReviewBody = function ReviewBody({ reviewId, flavorSliderSlot }: ReviewBodyProps) {
+ const reviewText = useReviewCardStore((state) => state.allReviews[reviewId]?.content);
+ const isOpen = useReviewCardStore((state) => state.allReviews[reviewId]?.isOpen);
- {/* 슬라이더 */}
- {flavorSliderSlot &&
{flavorSliderSlot}
}
+ const cardTransition = cn('overflow-hidden transition-all duration-500 ease-in-out', {
+ 'opacity-100 translate-y-0 max-h-[1000px]': isOpen,
+ 'opacity-0 -translate-y-4 max-h-0': !isOpen,
+ });
+
+ if (!reviewText && !flavorSliderSlot) null;
+
+ return (
+
+
+ {reviewText}
+
+
{flavorSliderSlot}
);
-}
+};
+
+ReviewCard.ToggleButton = function TogleButton({ reviewId }: ToggleButtonProps) {
+ const isOpen = useReviewCardStore((state) => state.allReviews[reviewId]?.isOpen);
+ const toggleReviewOpen = useReviewCardStore((state) => state.toggleReviewOpen);
+ return (
+
+ );
+};
diff --git a/src/components/common/card/ReviewCardTypes.ts b/src/components/common/card/ReviewCardTypes.ts
new file mode 100644
index 0000000..489fa29
--- /dev/null
+++ b/src/components/common/card/ReviewCardTypes.ts
@@ -0,0 +1,32 @@
+import { ReactNode } from 'react';
+
+interface ReviewCardProps {
+ children: ReactNode;
+}
+
+interface UserHeaderProps {
+ userIcon: ReactNode;
+ reviewId: string;
+ children: ReactNode;
+}
+
+interface TagAndRatingProps {
+ reviewId: string;
+}
+
+interface ReviewBodyProps {
+ reviewId: string;
+ flavorSliderSlot: ReactNode;
+}
+
+interface ToggleButtonProps {
+ reviewId: string;
+}
+
+export type {
+ ReviewCardProps,
+ UserHeaderProps,
+ TagAndRatingProps,
+ ReviewBodyProps,
+ ToggleButtonProps,
+};
diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx
new file mode 100644
index 0000000..b1cdd8f
--- /dev/null
+++ b/src/components/ui/progress.tsx
@@ -0,0 +1,24 @@
+import * as React from 'react';
+
+import * as ProgressPrimitive from '@radix-ui/react-progress';
+
+import { cn } from '@/lib/utils';
+
+const Progress = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef
+>(({ className, value, ...props }, ref) => (
+
+
+
+));
+Progress.displayName = ProgressPrimitive.Root.displayName;
+
+export { Progress };
diff --git a/src/components/wineDetail/AverageStar.tsx b/src/components/wineDetail/AverageStar.tsx
new file mode 100644
index 0000000..abbeabc
--- /dev/null
+++ b/src/components/wineDetail/AverageStar.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+
+import clsx from 'clsx';
+
+import Star from '@/assets/icons/star.svg';
+
+interface Props {
+ rating: number;
+}
+
+const STARS = ['1st', '2nd', '3rd', '4th', '5th'];
+
+function AverageStar({ rating }: Props) {
+ const activateStar = Math.floor(rating);
+
+ return (
+
+ {STARS.map((_, i) => (
+
= activateStar,
+ })}
+ >
+
+
+ ))}
+
+ );
+}
+
+export default AverageStar;
diff --git a/src/components/wineDetail/FlavorSliderList.tsx b/src/components/wineDetail/FlavorSliderList.tsx
new file mode 100644
index 0000000..2a76375
--- /dev/null
+++ b/src/components/wineDetail/FlavorSliderList.tsx
@@ -0,0 +1,54 @@
+import FlavorSlider from '@/components/common/slider/FlavorSlider';
+
+interface Props {
+ lightBold: number;
+ smoothTannic: number;
+ drySweet: number;
+ softAcidic: number;
+}
+
+function FlavorSliderList({
+ lightBold = 0,
+ smoothTannic = 0,
+ drySweet = 0,
+ softAcidic = 0,
+}: Props) {
+ return (
+
+ {}} //onChange가 필수 프롭이라
+ labelLeft={'가벼워요'}
+ labelRight={'진해요'}
+ badgeLabel={'바디감'}
+ disabled
+ />
+ {}}
+ labelLeft={'부드러워요'}
+ labelRight={'떫어요'}
+ badgeLabel={'타닌'}
+ disabled
+ />
+ {}}
+ labelLeft={'드라이해요'}
+ labelRight={'달아요'}
+ badgeLabel={'당도'}
+ disabled
+ />
+ {}}
+ labelLeft={'안셔요'}
+ labelRight={'많이셔요'}
+ badgeLabel={'산미'}
+ disabled
+ />
+
+ );
+}
+
+export default FlavorSliderList;
diff --git a/src/components/wineDetail/Kebab.tsx b/src/components/wineDetail/Kebab.tsx
new file mode 100644
index 0000000..f158b14
--- /dev/null
+++ b/src/components/wineDetail/Kebab.tsx
@@ -0,0 +1,33 @@
+import KebabIcon from '@/assets/icons/kebab.svg';
+import { Button } from '@/components/ui/button';
+
+import MenuDropdown from '../common/dropdown/MenuDropdown';
+
+function Kebab() {
+ //유진님이 만든 거랑 겹치는 거 같은데 나중에 합쳐지면 그걸로 수정해두겠습니다.
+ function onSelect(value: string) {
+ //-> 요거 혹시 저번에 멘토님께서 제네릭 관련 피드백 해주신 거 반영되어 있을까요???
+ if (value === 'update') alert('수정하기 모달 호출');
+ if (value === 'delete') alert('정말 삭제하겠습니다 alert 호출');
+ }
+
+ return (
+
+
+
+ }
+ >
+ );
+}
+
+export default Kebab;
diff --git a/src/components/wineDetail/LikeButton.tsx b/src/components/wineDetail/LikeButton.tsx
new file mode 100644
index 0000000..ecdbe09
--- /dev/null
+++ b/src/components/wineDetail/LikeButton.tsx
@@ -0,0 +1,34 @@
+import { useState } from 'react';
+
+import FullLikeIcon from '@/assets/icons/fullLike.svg';
+import LikeIcon from '@/assets/icons/like.svg';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+interface Props {
+ isLike?: boolean;
+}
+
+function LikeButton({ isLike }: Props) {
+ const [isClicked, setIsClicked] = useState(isLike);
+
+ function handleToggle() {
+ setIsClicked(!isClicked);
+ //좋아요 api 요청 보내기
+ }
+
+ return (
+
+ );
+}
+
+export default LikeButton;
diff --git a/src/components/wineDetail/WineContent.tsx b/src/components/wineDetail/WineContent.tsx
new file mode 100644
index 0000000..827b5e3
--- /dev/null
+++ b/src/components/wineDetail/WineContent.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+
+import { Badge } from '@/components/ui/badge';
+
+interface Props {
+ name: string;
+ region: string;
+ price: number;
+}
+
+function WineContent({ name, region, price }: Props) {
+ return (
+
+
+ {name}
+
+
+ {region}
+
+
+ ₩{' ' + price.toLocaleString('ko-KR')}
+
+
+ );
+}
+
+export default WineContent;
diff --git a/src/components/wineDetail/WineRating.tsx b/src/components/wineDetail/WineRating.tsx
new file mode 100644
index 0000000..b83f9ef
--- /dev/null
+++ b/src/components/wineDetail/WineRating.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+
+import { Button } from '@/components/ui/button';
+import { Progress } from '@/components/ui/progress';
+
+import AverageStar from './AverageStar';
+
+interface Props {
+ rating: number;
+ reviewCount: number;
+ ratings: number[];
+}
+
+function WineRating({ rating, reviewCount, ratings }: Props) {
+ return (
+
+
+
+ {ratings.map((rating, i) => (
+
+ ))}
+
+
+ );
+}
+
+export default WineRating;
diff --git a/src/components/wineDetail/WineReviewCard.tsx b/src/components/wineDetail/WineReviewCard.tsx
new file mode 100644
index 0000000..7b3d75f
--- /dev/null
+++ b/src/components/wineDetail/WineReviewCard.tsx
@@ -0,0 +1,63 @@
+import React, { useEffect } from 'react';
+
+import { ReviewCard } from '@/components/common/card/ReviewCard';
+import UserDefaultImg from '@/components/common/UserDefaultImg';
+import FlavorSliderList from '@/components/wineDetail/FlavorSliderList';
+import Kebab from '@/components/wineDetail/Kebab';
+import LikeButton from '@/components/wineDetail/LikeButton';
+import useReviewCardStore from '@/stores/reviewCardStore';
+
+//컨텍스트말고 주스탄드 기반 컴파운드 패턴
+interface Props {
+ review: {
+ content: string;
+ user: {
+ name: string;
+ };
+ updatedAt: string;
+ aroma: string[];
+ rating: string;
+ lightBold: number;
+ smoothTannic: number;
+ drySweet: number;
+ softAcidic: number;
+ id: string;
+ };
+}
+
+function WineReviewCard({ review }: Props) {
+ const setReviews = useReviewCardStore((state) => state.setReviews);
+
+ useEffect(() => {
+ setReviews(review);
+ }, [review, setReviews]);
+
+ const { id, lightBold, smoothTannic, drySweet, softAcidic } = review;
+
+ return (
+
+ }
+ reviewId={id}
+ >
+
+
+
+
+
+ }
+ >
+
+
+ );
+}
+
+export default WineReviewCard;
diff --git a/src/components/wineDetail/mock.js b/src/components/wineDetail/mock.js
new file mode 100644
index 0000000..d306177
--- /dev/null
+++ b/src/components/wineDetail/mock.js
@@ -0,0 +1,90 @@
+const review1 = {
+ content:
+ '최근에 센티넬 카베르네 소비뇽 2016을 마셔볼 기회가 있었는데, 정말 인상 깊은 경험이었어요! 처음 잔에 따랐을 때부터 짙고 깊은 루비색이 묵직한 존재감을 드러냈고, 가장자리로는 은은한 오렌지빛 림이 형성되어 숙성미를 짐작게 했습니다. 코를 가져다 대니 처음에는 블랙커런트, 잘 익은 블랙체리 같은 진한 검은 과일 향이 지배적이었고, 이내 삼나무, 가죽, 흙내음 같은 복합적인 2차 아로마와 함께 초콜릿, 에스프레소 같은 숙성된 오크 뉘앙스가 우아하게 피어났습니다. 한 모금 마셔보니, 탄탄하면서도 벨벳 같은 질감의 타닌이 입안을 감쌌고, 응축된 과일 맛과 함께 숙성에서 오는 스파이시함, 담배 잎 같은 쌉쌀한 풍미가 조화롭게 어우러졌습니다. 산미는 적절하게 균형을 잡아주어 자칫 무거울 수 있는 와인에 생기를 불어넣었고, 길고 우아하게 이어지는 여운 속에서는 미묘한 허브와 미네랄리티가 느껴졌습니다. 그냥 저녁 식사 후 편안하게 혼자 또는 가까운 사람들과 함께 깊이 있는 대화를 나눌 때 마시기에도 완벽했습니다.',
+ user: {
+ name: '김성주',
+ },
+ updatedAt: '11시간 전', // updatedAt: '2025-07-23T08:41:22.920Z', 변환하는 함수 하나 만들기
+ aroma: ['체리', '오크', '시트러스'],
+ rating: '4.8',
+ lightBold: 30,
+ smoothTannic: 40,
+ drySweet: 50,
+ softAcidic: 90,
+ id: '1',
+};
+
+const review2 = {
+ ...review1,
+ id: '2',
+};
+
+const review3 = {
+ ...review1,
+ id: '3',
+};
+
+const review4 = {
+ ...review1,
+ id: '4',
+};
+
+const wineInfo = {
+ image: '/wineImg.png',
+ name: 'Sentinel Carbernet Sauvignon 2016',
+ region: 'Western Cape, South Africa',
+ price: 360000,
+};
+const testReviews = [review1, review2, review3, review4];
+export { wineInfo, testReviews, review1 };
+
+/*--------------응답 예시------------------*/
+// const example = {
+// id: 0,
+// name: 'string',
+// region: 'string',
+// image: 'string',
+// price: 0,
+// type: 'string',
+// avgRating: 0,
+// reviewCount: 0,
+// recentReview: {
+// user: {
+// id: 0,
+// nickname: 'string',
+// image: 'string',
+// },
+// updatedAt: '2025-07-23T08:41:22.919Z',
+// createdAt: '2025-07-23T08:41:22.919Z',
+// content: 'string',
+// aroma: ['CHERRY'],
+// rating: 0,
+// id: 0,
+// },
+// userId: 0,
+// reviews: [
+// {
+// id: 0,
+// rating: 0,
+// lightBold: 0,
+// smoothTannic: 0,
+// drySweet: 0,
+// softAcidic: 0,
+// aroma: ['CHERRY'],
+// content: 'string',
+// createdAt: '2025-07-23T08:41:22.920Z',
+// updatedAt: '2025-07-23T08:41:22.920Z',
+// user: {
+// id: 0,
+// nickname: 'string',
+// image: 'string',
+// },
+// isLiked: {},
+// },
+// ],
+// avgRatings: {
+// additionalProp1: 0,
+// additionalProp2: 0,
+// additionalProp3: 0,
+// },
+// };
diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx
index 740ca42..27bb609 100644
--- a/src/pages/_document.tsx
+++ b/src/pages/_document.tsx
@@ -1,6 +1,8 @@
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
+ const BASE_URL = process.env.NEXT_PUBLIC_VERCEL_URL;
+ const defaultOGImage = `${BASE_URL}/og-image.png`;
return (
@@ -11,8 +13,8 @@ export default function Document() {
{/* todo: 배포 후 이미지, url 변경 필요 */}
-
-
+
+
diff --git a/src/pages/wines/[wineid].tsx b/src/pages/wines/[wineid].tsx
new file mode 100644
index 0000000..da44e51
--- /dev/null
+++ b/src/pages/wines/[wineid].tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+
+import { ImageCard } from '@/components/common/card/ImageCard';
+import { testReviews, wineInfo } from '@/components/wineDetail/mock';
+import WineContent from '@/components/wineDetail/WineContent';
+import WineRating from '@/components/wineDetail/WineRating';
+import WineReviewCard from '@/components/wineDetail/WineReviewCard';
+import { cn } from '@/lib/utils';
+
+export default function WineInfoById() {
+ const { name, region, price, image } = wineInfo;
+
+ return (
+
+
+
+
+
+
+
리뷰 목록
+
+ {testReviews.map((review) => (
+ -
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+const IMAGE_CLASS_NAME =
+ 'w-[58px] md:w-[84px] xl:w-[58px] h-[209px] md:h-[302px] xl:h-[209px] absolute bottom-0 left-[20px] md:left-[60px] xl:left-[100px]';
diff --git a/src/stores/reviewCardStore.ts b/src/stores/reviewCardStore.ts
new file mode 100644
index 0000000..aeec6f4
--- /dev/null
+++ b/src/stores/reviewCardStore.ts
@@ -0,0 +1,72 @@
+import { create } from 'zustand';
+import { shallow } from 'zustand/shallow';
+import { useStoreWithEqualityFn } from 'zustand/traditional';
+
+//리뷰카드 컴파운트 패턴용 스토어
+interface ReviewItemTypes {
+ content: string;
+ user: { name: string };
+ updatedAt: string;
+ aroma: string[];
+ rating: string;
+ lightBold: number;
+ smoothTannic: number;
+ drySweet: number;
+ softAcidic: number;
+ id: string;
+ isOpen: boolean;
+}
+
+interface ReviewsbyId {
+ [id: string]: ReviewItemTypes;
+}
+
+interface ReviewStates {
+ allReviews: ReviewsbyId;
+ setReviews: (review: Omit) => void;
+ toggleReviewOpen: (reviewId: string) => void;
+}
+
+export const reviewStore = create((set) => ({
+ allReviews: {},
+ setReviews: (reviewData) => {
+ set((state) => {
+ return {
+ allReviews: {
+ ...state.allReviews,
+ [reviewData.id]: {
+ ...reviewData,
+ isOpen:
+ Object.keys(state.allReviews).length === 0 //처음에 allReviews비어있으면 isOpen :true
+ ? true
+ : (state.allReviews[reviewData.id]?.isOpen ?? false),
+ },
+ },
+ };
+ });
+ },
+
+ toggleReviewOpen: (reviewId) => {
+ set((state) => ({
+ allReviews: {
+ ...state.allReviews,
+ [reviewId]: {
+ ...state.allReviews[reviewId],
+ isOpen: !state.allReviews[reviewId].isOpen,
+ },
+ },
+ }));
+ },
+
+ //스토어 초기화는 굳이? 어차피 새로고침하면 사라지지 않나요?
+}));
+
+//use-sync-external-store 패키지 설치 후 useStoreWithEqualityFn으로 타입 지정해야 shallow제대로 인식
+//-> 이거 외에 패키지 설치 안하는 방식으로 이것저것 해봤는데 도무지 못 찾겠네요 ㅠ
+//-> 일단 쓰긴했는데 왜 이 패키지가 필요한지 잘 모르겠습니다. 예전에 그냥 리액트+js로만 했을 때는 없어도 잘 인식했던 것 같은데...
+
+//원시타입 === 비교/ 객체,배열 얕은 비교
+const useReviewCardStore = (selector: (state: ReviewStates) => T): T =>
+ useStoreWithEqualityFn(reviewStore, selector, shallow);
+
+export default useReviewCardStore;