diff --git a/next.config.js b/next.config.js index 3c0d46d..b7c6b5d 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,17 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + swcMinify: true, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'sprint-fe-project.s3.ap-northeast-2.amazonaws.com', + port: '', + pathname: '/**', + }, + ], + }, webpack(config) { config.module.rules = config.module.rules.filter( diff --git a/src/api/wineid.ts b/src/api/wineid.ts new file mode 100644 index 0000000..5d0f8f3 --- /dev/null +++ b/src/api/wineid.ts @@ -0,0 +1,8 @@ +// import { createSeverApiInstance, RetryRequestConfig } from './apiServer'; +import { GetWineInfoResponse } from '@/types/WineTypes'; + +import apiClient from './apiClient'; + +export const getWineInfoForClient = (wineid: number): Promise => { + return apiClient.get(`/${process.env.NEXT_PUBLIC_TEAM}/wines/${wineid}`); +}; diff --git a/src/assets/icons/noReview.svg b/src/assets/icons/noReview.svg new file mode 100644 index 0000000..b63f79e --- /dev/null +++ b/src/assets/icons/noReview.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/src/assets/icons/wine-placeholder.svg b/src/assets/icons/wine-placeholder.svg new file mode 100644 index 0000000..8e0d5e2 --- /dev/null +++ b/src/assets/icons/wine-placeholder.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/components/common/card/ImageCard.tsx b/src/components/common/card/ImageCard.tsx index b8a8ec9..6fe463f 100644 --- a/src/components/common/card/ImageCard.tsx +++ b/src/components/common/card/ImageCard.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { useState } from 'react'; import Image from 'next/image'; +import WinePlaceholder from '@/assets/icons/wine-placeholder.svg'; import { cn } from '@/lib/utils'; interface ImageCardProps { @@ -9,7 +10,7 @@ interface ImageCardProps { children?: React.ReactNode; rightSlot?: React.ReactNode; className?: string; - imageClassName?: string; + imageClassName: string; } export function ImageCard({ @@ -19,17 +20,24 @@ export function ImageCard({ className, imageClassName, }: ImageCardProps) { + const [hasImageError, setHasImageError] = useState(false); + return (
{/* 왼쪽 이미지 */}
- 와인 이미지 + {imageSrc !== '' && hasImageError ? ( + + ) : ( + 와인 이미지 setHasImageError(true)} + /> + )}
{/* 오른쪽 텍스트 & 상단 버튼 */} @@ -42,3 +50,15 @@ export function ImageCard({
); } + +interface WineFallbackProps { + className: string; +} + +function WineFallback({ className }: WineFallbackProps) { + return ( +
+ +
+ ); +} diff --git a/src/components/common/card/ReviewCard.tsx b/src/components/common/card/ReviewCard.tsx index 6eda836..85e96ac 100644 --- a/src/components/common/card/ReviewCard.tsx +++ b/src/components/common/card/ReviewCard.tsx @@ -2,6 +2,7 @@ import ShowMoreBtn from '@/assets/icons/showMoreBtn.svg'; import Star from '@/assets/icons/star.svg'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { calculateRelativeTime } from '@/lib/calculateRelativeTime'; import { cn } from '@/lib/utils'; import useReviewCardStore from '@/stores/reviewCardStore'; @@ -27,7 +28,7 @@ export function ReviewCard({ children }: ReviewCardProps) { } ReviewCard.UserHeader = function UserHeader({ userIcon, reviewId, children }: UserHeaderProps) { - const username = useReviewCardStore((state) => state.allReviews[reviewId]?.user.name); + const username = useReviewCardStore((state) => state.allReviews[reviewId]?.user.nickname); const timeAgo = useReviewCardStore((state) => state.allReviews[reviewId]?.updatedAt); return ( @@ -38,7 +39,9 @@ ReviewCard.UserHeader = function UserHeader({ userIcon, reviewId, children }: Us
{username} - {timeAgo} + + {calculateRelativeTime(timeAgo)} +
diff --git a/src/components/common/card/ReviewCardTypes.ts b/src/components/common/card/ReviewCardTypes.ts index 489fa29..5bb0bc0 100644 --- a/src/components/common/card/ReviewCardTypes.ts +++ b/src/components/common/card/ReviewCardTypes.ts @@ -6,21 +6,21 @@ interface ReviewCardProps { interface UserHeaderProps { userIcon: ReactNode; - reviewId: string; + reviewId: number; children: ReactNode; } interface TagAndRatingProps { - reviewId: string; + reviewId: number; } interface ReviewBodyProps { - reviewId: string; + reviewId: number; flavorSliderSlot: ReactNode; } interface ToggleButtonProps { - reviewId: string; + reviewId: number; } export type { diff --git a/src/components/wineDetail/LikeButton.tsx b/src/components/wineDetail/LikeButton.tsx index ecdbe09..3c99d07 100644 --- a/src/components/wineDetail/LikeButton.tsx +++ b/src/components/wineDetail/LikeButton.tsx @@ -1,20 +1,41 @@ import { useState } from 'react'; +import apiClient from '@/api/apiClient'; 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'; +async function postLike(reviewId: number) { + console.log('좋아요!'); + return apiClient.post(`${process.env.NEXT_PUBLIC_TEAM}/reviews/${reviewId}/like`); +} + +async function deleteLike(reviewId: number) { + console.log('싫어요!'); + return apiClient.delete(`${process.env.NEXT_PUBLIC_TEAM}/reviews/${reviewId}/like`); +} + interface Props { isLike?: boolean; + reviewId: number; } -function LikeButton({ isLike }: Props) { +function LikeButton({ isLike, reviewId }: Props) { const [isClicked, setIsClicked] = useState(isLike); - function handleToggle() { - setIsClicked(!isClicked); + async function handleToggle() { + setIsClicked((prev) => !prev); //미리 업데이트 //좋아요 api 요청 보내기 + // /{teamId}/reviews/{id}/like + + try { + isClicked === true ? await deleteLike(reviewId) : await postLike(reviewId); + } catch (err) { + //모달 호출 후 집어 넣기 + + setIsClicked((prev) => !prev); //실패하면 업데이트 했던 거 취소 + } } return ( diff --git a/src/components/wineDetail/NoReviews.tsx b/src/components/wineDetail/NoReviews.tsx new file mode 100644 index 0000000..3125c83 --- /dev/null +++ b/src/components/wineDetail/NoReviews.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import NoReviewIcon from '@/assets/icons/noReview.svg'; + +import { Button } from '../ui/button'; + +interface Props { + className: string; +} + +function NoReviews({ className }: Props) { + return ( +
+
+ +
+ +
+ ); +} + +export default NoReviews; diff --git a/src/components/wineDetail/Reviews.tsx b/src/components/wineDetail/Reviews.tsx new file mode 100644 index 0000000..eb669ec --- /dev/null +++ b/src/components/wineDetail/Reviews.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { WineReview } from '@/types/WineTypes'; + +import NoReviews from './NoReviews'; +import WineReviewCard from './WineReviewCard'; + +interface Props { + reviews: WineReview[]; + reviewCount: number; +} + +function Reviews({ reviews, reviewCount }: Props) { + return ( + + ); +} + +export default Reviews; diff --git a/src/components/wineDetail/WineRating.tsx b/src/components/wineDetail/WineRating.tsx index b83f9ef..ff5137e 100644 --- a/src/components/wineDetail/WineRating.tsx +++ b/src/components/wineDetail/WineRating.tsx @@ -17,30 +17,36 @@ function WineRating({ rating, reviewCount, ratings }: Props) { className='mb-10 md:mb-[60px] md:px-[63px] xl:px-0 w-full xl:max-w-[280px] mx-auto xl:mx-0 order-1 xl:order-2 flex flex-col md:flex-row md:gap-20 xl:gap-0 xl:flex-col xl:relative' > -
-
- {rating} -
- -
{reviewCount}개의 후기
+ {reviewCount > 0 && ( + <> +
+
+ + {rating} + +
+ +
{reviewCount}개의 후기
+
+
+
-
- -
-
- {ratings.map((rating, i) => ( -
- {5 - i}점 - +
+ {ratings.map((rating, i) => ( +
+ {5 - i}점 + +
+ ))}
- ))} -
+ + )}
); } diff --git a/src/components/wineDetail/WineReviewCard.tsx b/src/components/wineDetail/WineReviewCard.tsx index 7b3d75f..e8d850b 100644 --- a/src/components/wineDetail/WineReviewCard.tsx +++ b/src/components/wineDetail/WineReviewCard.tsx @@ -6,23 +6,11 @@ import FlavorSliderList from '@/components/wineDetail/FlavorSliderList'; import Kebab from '@/components/wineDetail/Kebab'; import LikeButton from '@/components/wineDetail/LikeButton'; import useReviewCardStore from '@/stores/reviewCardStore'; +import { WineReview } from '@/types/WineTypes'; //컨텍스트말고 주스탄드 기반 컴파운드 패턴 interface Props { - review: { - content: string; - user: { - name: string; - }; - updatedAt: string; - aroma: string[]; - rating: string; - lightBold: number; - smoothTannic: number; - drySweet: number; - softAcidic: number; - id: string; - }; + review: WineReview; } function WineReviewCard({ review }: Props) { @@ -32,7 +20,7 @@ function WineReviewCard({ review }: Props) { setReviews(review); }, [review, setReviews]); - const { id, lightBold, smoothTannic, drySweet, softAcidic } = review; + const { id, lightBold, smoothTannic, drySweet, softAcidic, isLiked } = review; return ( @@ -40,7 +28,7 @@ function WineReviewCard({ review }: Props) { userIcon={} reviewId={id} > - + diff --git a/src/lib/calculateRelativeTime.ts b/src/lib/calculateRelativeTime.ts new file mode 100644 index 0000000..7e19961 --- /dev/null +++ b/src/lib/calculateRelativeTime.ts @@ -0,0 +1,38 @@ +const timeObj = { + second: 1000, + minute: 1000 * 60, + hour: 1000 * 60 * 60, + day: 1000 * 60 * 60 * 24, + week: 1000 * 60 * 60 * 24 * 7, + month: 1000 * 60 * 60 * 24 * 30, + year: 1000 * 60 * 60 * 24 * 365, +} as const; + +const timeDict = { + second: '초', + minute: '분', + hour: '시간', + day: '일', + week: '주', + month: '달', + year: '년', +} as const; + +export function calculateRelativeTime(time: string) { + if (!time) return; + + const targetDate = new Date(time).getTime(); + const nowDate = new Date().getTime(); + + const difference = nowDate - targetDate; + + const timeArr = Object.values(timeObj); + const timeKeys = Object.keys(timeObj) as (keyof typeof timeObj)[]; + + for (let i = 0; i < timeArr.length; i++) { + if (difference / timeArr[i] < timeArr[i + 1] / timeArr[i]) { + const value = difference / timeArr[i]; + return `${Math.floor(value)}${timeDict[timeKeys[i]]} 전`; + } + } +} diff --git a/src/pages/wines/[wineid].tsx b/src/pages/wines/[wineid].tsx index da44e51..6ce92d6 100644 --- a/src/pages/wines/[wineid].tsx +++ b/src/pages/wines/[wineid].tsx @@ -1,40 +1,56 @@ import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +// import { GetServerSideProps } from 'next'; +import { useRouter } from 'next/router'; + +import { getWineInfoForClient } from '@/api/wineid'; import { ImageCard } from '@/components/common/card/ImageCard'; -import { testReviews, wineInfo } from '@/components/wineDetail/mock'; +import Reviews from '@/components/wineDetail/Reviews'; 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; + const router = useRouter(); + const parsedWineId = Number(router.query.wineid); + + //서버든 목록(클라이언트든) 캐싱된 데이터 사용 + const { data, isLoading } = useQuery({ + queryKey: ['wineDetail', parsedWineId], + queryFn: () => getWineInfoForClient(parsedWineId), + staleTime: 1000 * 60 * 5, + }); + + if (isLoading) return
123
; //테스트용 + + if (!data) return <>; //테스트용 return (
- +

리뷰 목록

    - {testReviews.map((review) => ( -
  • - -
  • - ))} +
- +
); diff --git a/src/stores/reviewCardStore.ts b/src/stores/reviewCardStore.ts index aeec6f4..b73a8ef 100644 --- a/src/stores/reviewCardStore.ts +++ b/src/stores/reviewCardStore.ts @@ -2,29 +2,21 @@ import { create } from 'zustand'; import { shallow } from 'zustand/shallow'; import { useStoreWithEqualityFn } from 'zustand/traditional'; +import { WineReview } from '@/types/WineTypes'; + //리뷰카드 컴파운트 패턴용 스토어 -interface ReviewItemTypes { - content: string; - user: { name: string }; - updatedAt: string; - aroma: string[]; - rating: string; - lightBold: number; - smoothTannic: number; - drySweet: number; - softAcidic: number; - id: string; +interface ReviewItemTypes extends WineReview { isOpen: boolean; } interface ReviewsbyId { - [id: string]: ReviewItemTypes; + [id: number]: ReviewItemTypes; } interface ReviewStates { allReviews: ReviewsbyId; setReviews: (review: Omit) => void; - toggleReviewOpen: (reviewId: string) => void; + toggleReviewOpen: (reviewId: number) => void; } export const reviewStore = create((set) => ({ diff --git a/src/types/WineTypes.ts b/src/types/WineTypes.ts new file mode 100644 index 0000000..0fd957e --- /dev/null +++ b/src/types/WineTypes.ts @@ -0,0 +1,57 @@ +import { GetServerSidePropsContext } from 'next'; + +export interface WineInfoServerOptions { + accessToken?: string | undefined; + refreshToken?: string; + context?: GetServerSidePropsContext; // SSR에서만 사용 +} + +export interface GetWineInfoResponse { + id: number; + name: string; + region: string; + image: string; + price: number; + type: string; + avgRating: number; + reviewCount: number; + recentReview: { + user: { + id: number; + nickname: string; + image: string; + }; + updatedAt: string; + createdAt: string; + content: string; + aroma: string[]; + rating: number; + id: number; + }; + userId: number; + reviews: WineReview[]; + avgRatings: { + additionalProp1: number; + additionalProp2: number; + additionalProp3: number; + }; +} + +export interface WineReview { + id: number; + rating: number; + lightBold: number; + smoothTannic: number; + drySweet: number; + softAcidic: number; + aroma: string[]; + content: string; + createdAt: string; + updatedAt: string; + user: { + id: number; + nickname: string; + image: string; + }; + isLiked: boolean; +}