+
),
diff --git a/src/components/Product/ProductDetailItem/ProductDetailItem.tsx b/src/components/Product/ProductDetailItem/ProductDetailItem.tsx
index 87fe70c8c..8b829f02b 100644
--- a/src/components/Product/ProductDetailItem/ProductDetailItem.tsx
+++ b/src/components/Product/ProductDetailItem/ProductDetailItem.tsx
@@ -1,8 +1,15 @@
-import { Text, useTheme } from '@fun-eat/design-system';
-import styled from 'styled-components';
+import {
+ previewWrapper,
+ productContent,
+ productDetails,
+ productImage,
+ productInfo,
+ productName,
+ productOverview,
+ summaryWrapper,
+} from './productDetailItem.css';
-import PreviewImage from '@/assets/characters.svg';
-import { SvgIcon, TagList } from '@/components/Common';
+import { SvgIcon, TagList, Text } from '@/components/Common';
import type { ProductDetail } from '@/types/product';
interface ProductDetailItemProps {
@@ -10,80 +17,48 @@ interface ProductDetailItemProps {
}
const ProductDetailItem = ({ productDetail }: ProductDetailItemProps) => {
- const { name, price, image, content, averageRating, tags } = productDetail;
-
- const theme = useTheme();
+ const { name, price, image, content, averageRating, tags, reviewCount, category } = productDetail;
return (
-
- {image ? : }
-
-
-
- 가격
- {price.toLocaleString('ko-KR')}원
-
-
- 상품 설명
- {content}
-
-
- 평균 평점
-
-
- {averageRating.toFixed(1)}
-
-
-
-
- );
-};
-
-export default ProductDetailItem;
-
-const ProductDetailContainer = styled.div`
- display: flex;
- flex-direction: column;
- row-gap: 30px;
- g & > img,
- svg {
- align-self: center;
- }
-`;
+
+
-const ImageWrapper = styled.div`
- display: flex;
- justify-content: center;
- align-items: center;
-`;
+
+
+
+
+ {category.name}
+
+
{name}
+
+ {price.toLocaleString('ko-KR')}원
+
+
-const DetailInfoWrapper = styled.div`
- & > div + div {
- margin-top: 10px;
- }
-`;
+
+
+
+
+ {averageRating.toFixed(1)}
+
+
+
+
+
+ {reviewCount}
+
+
+
+
-const DescriptionWrapper = styled.div`
- display: flex;
- column-gap: 20px;
+
+ {content}
+
- & > p:first-of-type {
- flex-shrink: 0;
- width: 60px;
- }
-`;
-
-const ProductContent = styled(Text)`
- white-space: pre-wrap;
-`;
-
-const RatingIconWrapper = styled.div`
- display: flex;
- align-items: center;
- margin-left: -4px;
- column-gap: 4px;
+
+
+
+ );
+};
- & > svg {
- padding-bottom: 2px;
- }
-`;
+export default ProductDetailItem;
diff --git a/src/components/Product/ProductDetailItem/productDetailItem.css.ts b/src/components/Product/ProductDetailItem/productDetailItem.css.ts
new file mode 100644
index 000000000..5a8841c16
--- /dev/null
+++ b/src/components/Product/ProductDetailItem/productDetailItem.css.ts
@@ -0,0 +1,57 @@
+import { vars } from '@/styles/theme.css';
+import { style } from '@vanilla-extract/css';
+
+export const container = style({
+ display: 'flex',
+ flexDirection: 'column',
+ rowGap: '30px',
+});
+
+export const productImage = style({
+ width: '100%',
+ objectFit: 'cover',
+});
+
+export const productOverview = style({
+ margin: '20px 0 25px',
+ padding: '0 20px',
+});
+
+export const productInfo = style({
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'flex-start',
+});
+
+export const productDetails = style({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 4,
+});
+
+export const categoryName = style({
+ color: vars.colors.gray4,
+});
+
+export const productName = style({
+ fontSize: '1.6rem',
+ fontWeight: 600,
+ lineHeight: 1.4,
+});
+
+export const summaryWrapper = style({
+ display: 'flex',
+ gap: 12,
+});
+
+export const previewWrapper = style({
+ display: 'flex',
+ alignItems: 'flex-start',
+ justifyContent: 'center',
+ gap: 4,
+});
+
+export const productContent = style({
+ margin: '16px 0 8px',
+ whiteSpace: 'pre-line',
+});
diff --git a/src/components/Product/ProductRecipeList/ProductRecipeList.tsx b/src/components/Product/ProductRecipeList/ProductRecipeList.tsx
index 7298f3c8f..6a943c664 100644
--- a/src/components/Product/ProductRecipeList/ProductRecipeList.tsx
+++ b/src/components/Product/ProductRecipeList/ProductRecipeList.tsx
@@ -1,77 +1,50 @@
-import { Link, Text } from '@fun-eat/design-system';
-import { useRef } from 'react';
-import { Link as RouterLink } from 'react-router-dom';
-import styled from 'styled-components';
+import { Link } from 'react-router-dom';
+import { container, moreIcon, moreIconWrapper, moreItem, moreLink } from './productRecipeList.css';
+
+import { SvgIcon, Text } from '@/components/Common';
import { RecipeItem } from '@/components/Recipe';
-import { PATH } from '@/constants/path';
-import { useIntersectionObserver } from '@/hooks/common';
import { useInfiniteProductRecipesQuery } from '@/hooks/queries/product';
-import type { SortOption } from '@/types/common';
+import { vars } from '@/styles/theme.css';
+import displaySlice from '@/utils/displaySlice';
interface ProductRecipeListProps {
productId: number;
- productName: string;
- selectedOption: SortOption;
}
-const ProductRecipeList = ({ productId, productName, selectedOption }: ProductRecipeListProps) => {
- const scrollRef = useRef
(null);
- const { fetchNextPage, hasNextPage, data } = useInfiniteProductRecipesQuery(productId, selectedOption.value);
- useIntersectionObserver(fetchNextPage, scrollRef, hasNextPage);
+const ProductRecipeList = ({ productId }: ProductRecipeListProps) => {
+ // 상품에서 보여줄 꿀조합 정렬 조건
+ const { data } = useInfiniteProductRecipesQuery(productId, 'favoriteCount,desc');
const recipes = data.pages.flatMap((page) => page.recipes);
+ const recipeToDisplay = displaySlice(true, recipes, 3);
if (recipes.length === 0) {
- return (
-
-
- {productName}을/를 {'\n'}사용한 꿀조합을 만들어보세요 🍯
-
-
- 꿀조합 작성하러 가기
-
-
- );
+ return null;
}
return (
- <>
-
- {recipes.map((recipe) => (
-
-
-
-
-
- ))}
-
-
- >
+
+ {recipeToDisplay.map((recipe) => (
+ -
+
+
+ ))}
+ {recipeToDisplay.length < recipes.length && (
+ -
+ {/*링크는 상품이 포함된 꿀조합 검색결과로 가는 것이 맞을듯?*/}
+
+
+
+
+
+ 전체보기
+
+
+
+ )}
+
);
};
export default ProductRecipeList;
-
-const ProductRecipeListContainer = styled.ul`
- & > li + li {
- margin-top: 40px;
- }
-`;
-
-const ErrorContainer = styled.div`
- display: flex;
- flex-direction: column;
- align-items: center;
-`;
-
-const ErrorDescription = styled(Text)`
- padding: 20px 0;
- white-space: pre-wrap;
-`;
-
-const RecipeLink = styled(Link)`
- padding: 16px 24px;
- border: 1px solid ${({ theme }) => theme.colors.gray4};
- border-radius: 8px;
-`;
diff --git a/src/components/Product/ProductRecipeList/productRecipeList.css.ts b/src/components/Product/ProductRecipeList/productRecipeList.css.ts
new file mode 100644
index 000000000..5c7824bab
--- /dev/null
+++ b/src/components/Product/ProductRecipeList/productRecipeList.css.ts
@@ -0,0 +1,38 @@
+import { vars } from '@/styles/theme.css';
+import { style } from '@vanilla-extract/css';
+
+export const container = style({
+ display: 'flex',
+ gap: 10,
+ padding: '0 10px 0 20px',
+ overflowX: 'auto',
+});
+
+export const moreItem = style({
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ minWidth: 108,
+});
+
+export const moreLink = style({
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ alignItems: 'center',
+});
+
+export const moreIconWrapper = style({
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ width: 40,
+ height: 40,
+ marginBottom: 12,
+ borderRadius: '50%',
+ background: vars.colors.secondary1,
+});
+
+export const moreIcon = style({
+ transform: 'rotate(180deg)',
+});
diff --git a/src/components/Recipe/RecipeItem/RecipeItem.tsx b/src/components/Recipe/RecipeItem/RecipeItem.tsx
index a3f3f96c4..56481bee8 100644
--- a/src/components/Recipe/RecipeItem/RecipeItem.tsx
+++ b/src/components/Recipe/RecipeItem/RecipeItem.tsx
@@ -1,5 +1,4 @@
-import { BottomSheet, Skeleton, useBottomSheet } from '@fun-eat/design-system';
-import type { MouseEventHandler } from 'react';
+import { Skeleton } from '@fun-eat/design-system';
import { memo, useState } from 'react';
import { Link } from 'react-router-dom';
@@ -10,37 +9,38 @@ import {
recipeAuthor,
recipeContent,
recipeImage,
- recipeProductWrapper,
recipeTitle,
} from './recipeItem.css';
import RecipeFavoriteButton from '../RecipeFavoriteButton/RecipeFavoriteButton';
import RecipeProductButton from '../RecipeProductButton/RecipeProductButton';
-import { ProductOverviewList } from '@/components/Product';
import { RECIPE_CARD_DEFAULT_IMAGE_URL } from '@/constants/image';
+import { PATH } from '@/constants/path';
import type { MemberRecipe, Recipe } from '@/types/recipe';
interface RecipeItemProps {
recipe: Recipe | MemberRecipe;
isMemberPage?: boolean;
+ hasFavoriteButton?: boolean;
+ hasProductButton?: boolean;
+ hasContent?: boolean;
}
-const RecipeItem = ({ recipe, isMemberPage = false }: RecipeItemProps) => {
- const { id, image, title, content, favorite, products } = recipe;
- const { isOpen, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet();
-
- const author = 'author' in recipe ? recipe.author : null;
-
+const RecipeItem = ({
+ recipe,
+ isMemberPage = false,
+ hasFavoriteButton = false,
+ hasProductButton = false,
+ hasContent = false,
+}: RecipeItemProps) => {
const [isImageLoading, setIsImageLoading] = useState(true);
- const handleOpenProductSheet: MouseEventHandler = (event) => {
- event.preventDefault();
- handleOpenBottomSheet();
- };
+ const { id, image, title, content, favorite, products } = recipe;
+ const author = 'author' in recipe ? recipe.author : null;
return (
<>
-
+
{!isMemberPage && (
{
onLoad={() => image && setIsImageLoading(false)}
/>
{isImageLoading && image &&
}
-
e.preventDefault()}>
-
-
-
handleOpenProductSheet(e)}>
-
-
+ {hasFavoriteButton && (
+
e.preventDefault()}>
+
+
+ )}
+ {hasProductButton && (
+
e.preventDefault()}>
+
+
+ )}
)}
{title}
{author && `${author.nickname} 님`}
- {content}
+ {hasContent && {content}
}
-
-
-
-
>
);
};
diff --git a/src/components/Recipe/RecipeItem/recipeItem.css.ts b/src/components/Recipe/RecipeItem/recipeItem.css.ts
index c85e91ab9..2922fec9f 100644
--- a/src/components/Recipe/RecipeItem/recipeItem.css.ts
+++ b/src/components/Recipe/RecipeItem/recipeItem.css.ts
@@ -10,19 +10,21 @@ export const recipeImage = style({
minWidth: 163,
borderRadius: '6px',
objectFit: 'cover',
- aspectRatio: '1 / 1',
+ aspectRatio: '4 / 5',
});
export const favoriteButtonWrapper = style({
position: 'absolute',
top: 8,
right: 8,
+ zIndex: 100,
});
export const productButtonWrapper = style({
position: 'absolute',
bottom: 8,
left: 8,
+ zIndex: 100,
});
export const recipeTitle = style({
@@ -46,7 +48,3 @@ export const recipeContent = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
});
-
-export const recipeProductWrapper = style({
- margin: '48px 20px',
-});
diff --git a/src/components/Recipe/RecipeList/RecipeList.tsx b/src/components/Recipe/RecipeList/RecipeList.tsx
index 70edda004..03ca65a42 100644
--- a/src/components/Recipe/RecipeList/RecipeList.tsx
+++ b/src/components/Recipe/RecipeList/RecipeList.tsx
@@ -27,7 +27,7 @@ const RecipeList = ({ selectedOption }: RecipeListProps) => {
{recipes.map((recipe) => (
-
-
+
))}
diff --git a/src/components/Recipe/RecipeProductButton/RecipeProductButton.tsx b/src/components/Recipe/RecipeProductButton/RecipeProductButton.tsx
index 70447bc11..370ebe17b 100644
--- a/src/components/Recipe/RecipeProductButton/RecipeProductButton.tsx
+++ b/src/components/Recipe/RecipeProductButton/RecipeProductButton.tsx
@@ -1,18 +1,32 @@
+import { BottomSheet, useBottomSheet } from '@fun-eat/design-system';
import cx from 'classnames';
-import { container, translucent } from './recipeProductButton.css';
+import { container, recipeProductWrapper, translucent } from './recipeProductButton.css';
import { SvgIcon } from '@/components/Common';
+import { ProductOverviewList } from '@/components/Product';
+import type { RecipeProduct } from '@/types/recipe';
interface RecipeProductButtonProps {
isTranslucent?: boolean;
+ products: RecipeProduct[];
}
-const RecipeProductButton = ({ isTranslucent }: RecipeProductButtonProps) => {
+const RecipeProductButton = ({ isTranslucent, products }: RecipeProductButtonProps) => {
+ const { isOpen, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet();
+
return (
-
+ <>
+
+
+
+
+
+ >
);
};
diff --git a/src/components/Recipe/RecipeProductButton/recipeProductButton.css.ts b/src/components/Recipe/RecipeProductButton/recipeProductButton.css.ts
index f406288e2..9f60d6291 100644
--- a/src/components/Recipe/RecipeProductButton/recipeProductButton.css.ts
+++ b/src/components/Recipe/RecipeProductButton/recipeProductButton.css.ts
@@ -10,3 +10,7 @@ export const container = style({
export const translucent = style({
opacity: '50%',
});
+
+export const recipeProductWrapper = style({
+ margin: '48px 20px',
+});
diff --git a/src/components/Review/ReviewFavoriteButton/ReviewFavoriteButton.tsx b/src/components/Review/ReviewFavoriteButton/ReviewFavoriteButton.tsx
index 1aef40a6d..38ed52914 100644
--- a/src/components/Review/ReviewFavoriteButton/ReviewFavoriteButton.tsx
+++ b/src/components/Review/ReviewFavoriteButton/ReviewFavoriteButton.tsx
@@ -1,10 +1,11 @@
-import { Text, Button, useTheme } from '@fun-eat/design-system';
import { useState } from 'react';
-import styled from 'styled-components';
-import { SvgIcon } from '@/components/Common';
+import { favoriteButton } from './reviewFavoriteButton.css';
+
+import { SvgIcon, Text } from '@/components/Common';
import { useTimeout } from '@/hooks/common';
import { useReviewFavoriteMutation } from '@/hooks/queries/review';
+import { vars } from '@/styles/theme.css';
interface ReviewFavoriteButtonProps {
productId: number;
@@ -14,8 +15,6 @@ interface ReviewFavoriteButtonProps {
}
const ReviewFavoriteButton = ({ productId, reviewId, favorite, favoriteCount }: ReviewFavoriteButtonProps) => {
- const theme = useTheme();
-
const initialFavoriteState = {
isFavorite: favorite,
currentFavoriteCount: favoriteCount,
@@ -45,25 +44,18 @@ const ReviewFavoriteButton = ({ productId, reviewId, favorite, favoriteCount }:
const [debouncedToggleFavorite] = useTimeout(handleToggleFavorite, 200);
return (
-
-
-
+
+
{currentFavoriteCount}
-
+
);
};
export default ReviewFavoriteButton;
-
-const FavoriteButton = styled(Button)`
- display: flex;
- align-items: center;
- padding: 0;
- column-gap: 8px;
-`;
diff --git a/src/components/Review/ReviewFavoriteButton/reviewFavoriteButton.css.ts b/src/components/Review/ReviewFavoriteButton/reviewFavoriteButton.css.ts
new file mode 100644
index 000000000..b8698b1ab
--- /dev/null
+++ b/src/components/Review/ReviewFavoriteButton/reviewFavoriteButton.css.ts
@@ -0,0 +1,8 @@
+import { style } from '@vanilla-extract/css';
+
+export const favoriteButton = style({
+ display: 'flex',
+ alignItems: 'center',
+ gap: 4,
+ cursor: 'pointer',
+});
diff --git a/src/components/Review/ReviewItem/ReviewItem.stories.tsx b/src/components/Review/ReviewItem/ReviewItem.stories.tsx
new file mode 100644
index 000000000..6312e774a
--- /dev/null
+++ b/src/components/Review/ReviewItem/ReviewItem.stories.tsx
@@ -0,0 +1,42 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import ReviewItem from './ReviewItem';
+
+import productDetail from '@/mocks/data/productDetail.json';
+import mockReviews from '@/mocks/data/reviews.json';
+
+const meta: Meta = {
+ title: 'review/ReviewItem',
+ component: ReviewItem,
+ args: {
+ productId: productDetail.id,
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ review: mockReviews.reviews[2],
+ },
+};
+
+export const WithImage: Story = {
+ args: {
+ review: mockReviews.reviews[1],
+ },
+};
+
+export const WithRebuy: Story = {
+ args: {
+ review: mockReviews.reviews[0],
+ },
+};
diff --git a/src/components/Review/ReviewItem/ReviewItem.tsx b/src/components/Review/ReviewItem/ReviewItem.tsx
index 6b7e9a129..789778edc 100644
--- a/src/components/Review/ReviewItem/ReviewItem.tsx
+++ b/src/components/Review/ReviewItem/ReviewItem.tsx
@@ -1,11 +1,20 @@
-import { Badge, Text, useTheme } from '@fun-eat/design-system';
import { memo } from 'react';
-import styled from 'styled-components';
+import {
+ date,
+ favoriteWrapper,
+ memberImage,
+ memberInfo,
+ ratingInfo,
+ ratingNumber,
+ ratingWrapper,
+ reviewContent,
+ reviewImage,
+} from './reviewItem.css';
import ReviewFavoriteButton from '../ReviewFavoriteButton/ReviewFavoriteButton';
-import { SvgIcon, TagList } from '@/components/Common';
-import { MemberImage } from '@/components/Members';
+import { Badge, SvgIcon, TagList, Text } from '@/components/Common';
+import { vars } from '@/styles/theme.css';
import type { Review } from '@/types/review';
import { getRelativeDate } from '@/utils/date';
@@ -15,92 +24,61 @@ interface ReviewItemProps {
}
const ReviewItem = ({ productId, review }: ReviewItemProps) => {
- const theme = useTheme();
-
const { id, userName, profileImage, image, rating, tags, content, createdAt, rebuy, favorite, favoriteCount } =
review;
return (
-
-
-
-
-
- {userName}
-
- {Array.from({ length: 5 }, (_, index) => (
-
- ))}
-
- {getRelativeDate(createdAt)}
-
-
-
-
+
+
+
+
{userName}
{rebuy && (
-
- 😝 또 살래요
-
+
+ 또 살래요
+
)}
-
- {image &&
}
-
-
{content}
-
-
- );
-};
+
+
+
+
-export default memo(ReviewItem);
+
-const ReviewItemContainer = styled.div`
- display: flex;
- flex-direction: column;
- row-gap: 20px;
-`;
+
+
+
+ {rating.toFixed(1)}
+
+ {Array.from({ length: 5 }, (_, index) => (
+
+ ))}
+
+
+ {getRelativeDate(createdAt)}
+
+
-const ReviewerWrapper = styled.div`
- display: flex;
- justify-content: space-between;
- align-items: center;
-`;
+
-const ReviewerInfoWrapper = styled.div`
- display: flex;
- align-items: center;
- column-gap: 10px;
-`;
+ {image &&
}
-const RebuyBadge = styled(Badge)`
- font-weight: ${({ theme }) => theme.fontWeights.bold};
-`;
+
-const RatingIconWrapper = styled.div`
- display: flex;
- align-items: center;
- margin-left: -2px;
+
+ {content}
+
- & > span {
- margin-left: 12px;
- }
-`;
+
-const ReviewImage = styled.img`
- align-self: center;
-`;
+
+
+ );
+};
-const ReviewContent = styled(Text)`
- white-space: pre-wrap;
-`;
+export default memo(ReviewItem);
diff --git a/src/components/Review/ReviewItem/reviewItem.css.ts b/src/components/Review/ReviewItem/reviewItem.css.ts
new file mode 100644
index 000000000..3d3751d34
--- /dev/null
+++ b/src/components/Review/ReviewItem/reviewItem.css.ts
@@ -0,0 +1,47 @@
+import { vars } from '@/styles/theme.css';
+import { style } from '@vanilla-extract/css';
+
+export const memberInfo = style({
+ display: 'flex',
+ alignItems: 'center',
+ gap: 8,
+});
+
+export const memberImage = style({
+ borderRadius: '50%',
+ objectFit: 'cover',
+});
+
+export const favoriteWrapper = style({
+ marginLeft: 'auto',
+});
+
+export const ratingWrapper = style({
+ display: 'flex',
+ alignItems: 'center',
+ gap: 8,
+});
+
+export const ratingInfo = style({
+ display: 'flex',
+ alignItems: 'center',
+ gap: 4,
+});
+
+export const ratingNumber = style({
+ paddingTop: 4,
+ color: vars.colors.gray5,
+});
+
+export const date = style({
+ paddingTop: 2,
+});
+
+export const reviewImage = style({
+ borderRadius: 6,
+ objectFit: 'cover',
+});
+
+export const reviewContent = style({
+ whiteSpace: 'pre-wrap',
+});
diff --git a/src/components/Review/ReviewList/ReviewList.tsx b/src/components/Review/ReviewList/ReviewList.tsx
index bb2b993b9..de7191141 100644
--- a/src/components/Review/ReviewList/ReviewList.tsx
+++ b/src/components/Review/ReviewList/ReviewList.tsx
@@ -1,10 +1,9 @@
-import { Text } from '@fun-eat/design-system';
import { useRef } from 'react';
-import styled from 'styled-components';
+import { container } from './reviewList.css';
import ReviewItem from '../ReviewItem/ReviewItem';
-import { Loading } from '@/components/Common';
+import { Loading, Text } from '@/components/Common';
import { useIntersectionObserver } from '@/hooks/common';
import { useInfiniteProductReviewsQuery } from '@/hooks/queries/product';
import type { SortOption } from '@/types/common';
@@ -30,13 +29,13 @@ const ReviewList = ({ productId, selectedOption }: ReviewListProps) => {
return (
<>
-
+
{reviews.map((review) => (
-
))}
-
+
{isFetchingNextPage && }
>
@@ -44,9 +43,3 @@ const ReviewList = ({ productId, selectedOption }: ReviewListProps) => {
};
export default ReviewList;
-
-const ReviewListContainer = styled.ul`
- display: flex;
- flex-direction: column;
- row-gap: 60px;
-`;
diff --git a/src/components/Review/ReviewList/reviewList.css.ts b/src/components/Review/ReviewList/reviewList.css.ts
new file mode 100644
index 000000000..0e850e476
--- /dev/null
+++ b/src/components/Review/ReviewList/reviewList.css.ts
@@ -0,0 +1,8 @@
+import { style } from '@vanilla-extract/css';
+
+export const container = style({
+ display: 'flex',
+ flexDirection: 'column',
+ rowGap: 40,
+ padding: '0 20px',
+});
diff --git a/src/mocks/data/productDetail.json b/src/mocks/data/productDetail.json
index 2695b51b1..3e4dda199 100644
--- a/src/mocks/data/productDetail.json
+++ b/src/mocks/data/productDetail.json
@@ -6,6 +6,11 @@
"content": "할머니가 먹을 거 같은 맛입니다.\n1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데, 맛있어요.",
"averageRating": 4.5,
"reviewCount": 100,
+ "category": {
+ "id": 1,
+ "name": "간편식사",
+ "image": "https://image.funeat.site/prod/cu.webp"
+ },
"tags": [
{
"id": 5,
diff --git a/src/mocks/data/productDetails.json b/src/mocks/data/productDetails.json
index fc42d5697..82a8109f7 100644
--- a/src/mocks/data/productDetails.json
+++ b/src/mocks/data/productDetails.json
@@ -3,10 +3,15 @@
"id": 1,
"name": "꼬북칩",
"price": 1500,
- "image": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34",
+ "image": "https://i.namu.wiki/i/9wnvUaEa1EkDqG-M0Pbwfdf19FJQQXV_-bnlU2SYaNcG05y2wbabiIrfrGES1M4xSgDjY39RwOvLNggDd3Huuw.webp",
"content": "할머니가 먹을 거 같은 맛입니다.\n1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데, 맛있어요.",
"averageRating": 4.5,
"reviewCount": 100,
+ "category": {
+ "id": 1,
+ "name": "간편식사",
+ "image": "https://image.funeat.site/prod/cu.webp"
+ },
"tags": [
{
"id": 5,
@@ -29,10 +34,15 @@
"id": 2,
"name": "새우깡",
"price": 1000,
- "image": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34",
+ "image": "https://i.namu.wiki/i/9wnvUaEa1EkDqG-M0Pbwfdf19FJQQXV_-bnlU2SYaNcG05y2wbabiIrfrGES1M4xSgDjY39RwOvLNggDd3Huuw.webp",
"content": "할머니가 먹을 거 같은 맛입니다. 1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데, 맛있어요.",
"averageRating": 4.0,
"reviewCount": 55,
+ "category": {
+ "id": 1,
+ "name": "간편식사",
+ "image": "https://image.funeat.site/prod/cu.webp"
+ },
"tags": [
{
"id": 5,
diff --git a/src/mocks/data/reviews.json b/src/mocks/data/reviews.json
index 5f9d129ee..31a7c353c 100644
--- a/src/mocks/data/reviews.json
+++ b/src/mocks/data/reviews.json
@@ -5,12 +5,21 @@
"id": 1,
"userName": "우가우가",
"profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34",
- "image": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34",
+ "image": "https://cdn.pixabay.com/photo/2016/03/23/15/00/ice-cream-1274894_1280.jpg",
"rating": 5,
- "tags": [],
+ "tags": [
+ {
+ "id": 5,
+ "name": "단짠단짠"
+ },
+ {
+ "id": 1,
+ "name": "망고망고"
+ }
+ ],
"content": "우가우가~!~!",
"createdAt": "2023-08-03T13:10:06.379389",
- "rebuy": false,
+ "rebuy": true,
"favoriteCount": 150,
"favorite": false
},
@@ -18,7 +27,7 @@
"id": 2,
"userName": "펀잇",
"profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34",
- "image": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34",
+ "image": "https://cdn.pixabay.com/photo/2016/03/23/15/00/ice-cream-1274894_1280.jpg",
"rating": 4,
"tags": [
{
@@ -32,7 +41,7 @@
],
"content": "할머니가 먹을 거 같은 맛입니다.\n\n1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데, 맛있어요.",
"createdAt": "2023-08-02T13:43:06.379389",
- "rebuy": true,
+ "rebuy": false,
"favoriteCount": 1320,
"favorite": true
},
@@ -54,7 +63,7 @@
],
"content": "할머니가 먹을 거 같은 맛입니다. 1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데, 맛있어요. 하얀 짜파게티라니 말이 안된다고 생각했었죠. 실제로 맛을 보니까 까만 짜파게티랑 맛이 뭔가 다를게 없네요.",
"createdAt": "2023-07-03T13:43:06.379389",
- "rebuy": true,
+ "rebuy": false,
"favoriteCount": 1321,
"favorite": true
}
diff --git a/src/pages/ProductDetailPage.tsx b/src/pages/ProductDetailPage.tsx
deleted file mode 100644
index 18f79e5b9..000000000
--- a/src/pages/ProductDetailPage.tsx
+++ /dev/null
@@ -1,166 +0,0 @@
-import { Spacing, useBottomSheet, Text, Button } from '@fun-eat/design-system';
-import { useQueryErrorResetBoundary } from '@tanstack/react-query';
-import { useState, useRef, Suspense } from 'react';
-import { useParams, useLocation, useNavigate } from 'react-router-dom';
-import styled from 'styled-components';
-
-import {
- SortButton,
- ScrollButton,
- Loading,
- ErrorBoundary,
- ErrorComponent,
- RegisterButton,
- SectionTitle,
-} from '@/components/Common';
-import { ProductDetailItem } from '@/components/Product';
-import { BestReviewItem } from '@/components/Review';
-import { PREVIOUS_PATH_LOCAL_STORAGE_KEY, REVIEW_SORT_OPTIONS } from '@/constants';
-import { PATH } from '@/constants/path';
-import { useGA, useSortOption } from '@/hooks/common';
-import { useMemberQuery } from '@/hooks/queries/members';
-import { useProductDetailQuery } from '@/hooks/queries/product';
-import { setLocalStorage } from '@/utils/localStorage';
-
-const LOGIN_ERROR_MESSAGE_REVIEW =
- '로그인 후 상품 리뷰를 볼 수 있어요.\n펀잇에 가입하고 편의점 상품 리뷰를 확인해보세요 😊';
-const LOGIN_ERROR_MESSAGE_RECIPE =
- '로그인 후 상품 꿀조합을 볼 수 있어요.\n펀잇에 가입하고 편의점 상품 꿀조합을 확인해보세요 😊';
-
-export const ProductDetailPage = () => {
- const { productId } = useParams();
- const { pathname } = useLocation();
- const navigate = useNavigate();
-
- const { data: member } = useMemberQuery();
- const { data: productDetail } = useProductDetailQuery(Number(productId));
-
- const { reset } = useQueryErrorResetBoundary();
-
- const tabRef = useRef(null);
-
- const { selectedOption, selectSortOption } = useSortOption(REVIEW_SORT_OPTIONS[0]);
- const { isOpen, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet();
- const [activeSheet, setActiveSheet] = useState<'registerReview' | 'sortOption'>('sortOption');
- const { gaEvent } = useGA();
-
- const productDetailPageRef = useRef(null);
-
- if (!productId) {
- return null;
- }
-
- const { name, reviewCount } = productDetail;
-
- const tabMenus = [`리뷰 ${reviewCount}`, '꿀조합'];
-
- const handleOpenRegisterReviewSheet = () => {
- setActiveSheet('registerReview');
- handleOpenBottomSheet();
- gaEvent({ category: 'button', action: '상품 리뷰 작성하기 버튼 클릭', label: '상품 리뷰 작성' });
- };
-
- const handleOpenSortOptionSheet = () => {
- setActiveSheet('sortOption');
- handleOpenBottomSheet();
- gaEvent({ category: 'button', action: '상품 리뷰 정렬 버튼 클릭', label: '상품 리뷰 정렬' });
- };
-
- const handleLoginButtonClick = () => {
- setLocalStorage(PREVIOUS_PATH_LOCAL_STORAGE_KEY, pathname);
- navigate(PATH.LOGIN);
- };
-
- return (
-
-
-
-
-
-
-
- {member ? (
-
- }>
-
-
-
-
- {/*{isReviewTab ? (
-
- ) : (
-
- )}*/}
-
-
-
- ) : (
-
-
- {/*{isReviewTab ? LOGIN_ERROR_MESSAGE_REVIEW : LOGIN_ERROR_MESSAGE_RECIPE}*/}
-
-
- 로그인하러 가기
-
-
- )}
-
-
-
-
-
-
- );
-};
-
-const ProductDetailPageContainer = styled.div`
- height: 100%;
- overflow-y: auto;
-
- &::-webkit-scrollbar {
- display: none;
- }
-`;
-
-const SortButtonWrapper = styled.div`
- display: flex;
- justify-content: flex-end;
- align-items: center;
- margin: 20px 0;
-`;
-
-const ErrorContainer = styled.div`
- display: flex;
- flex-direction: column;
- align-items: center;
-`;
-
-const ErrorDescription = styled(Text)`
- padding: 40px 0 20px;
- white-space: pre-wrap;
-`;
-
-const LoginButton = styled(Button)`
- border: 1px solid ${({ theme }) => theme.colors.gray4};
-`;
-
-const ReviewRegisterButtonWrapper = styled.div`
- position: fixed;
- left: 50%;
- bottom: 0;
- width: calc(100% - 40px);
- height: 80px;
- max-width: 560px;
- background: ${({ theme }) => theme.backgroundColors.default};
- transform: translateX(-50%);
-`;
diff --git a/src/pages/ProductDetailPage/ProductDetailPage.tsx b/src/pages/ProductDetailPage/ProductDetailPage.tsx
new file mode 100644
index 000000000..0367084c3
--- /dev/null
+++ b/src/pages/ProductDetailPage/ProductDetailPage.tsx
@@ -0,0 +1,107 @@
+import { BottomSheet, useBottomSheet } from '@fun-eat/design-system';
+import { useQueryErrorResetBoundary } from '@tanstack/react-query';
+import { Suspense } from 'react';
+import { useParams, useLocation, useNavigate } from 'react-router-dom';
+
+import { main, registerButton, registerButtonWrapper, section, sortWrapper } from './productDetailPage.css';
+import NotFoundPage from '../NotFoundPage';
+
+import {
+ SortButton,
+ PageHeader,
+ SectionHeader,
+ ErrorBoundary,
+ ErrorComponent,
+ Loading,
+ SelectOptionList,
+} from '@/components/Common';
+import { ProductDetailItem, ProductRecipeList } from '@/components/Product';
+import { ReviewList } from '@/components/Review';
+import { PREVIOUS_PATH_LOCAL_STORAGE_KEY, REVIEW_SORT_OPTIONS } from '@/constants';
+import { PATH } from '@/constants/path';
+import { useGA, useSelect } from '@/hooks/common';
+import { useMemberQuery } from '@/hooks/queries/members';
+import { useProductDetailQuery } from '@/hooks/queries/product';
+import type { SortOption } from '@/types/common';
+import { setLocalStorage } from '@/utils/localStorage';
+
+export const ProductDetailPage = () => {
+ const { productId } = useParams();
+ const { pathname } = useLocation();
+ const navigate = useNavigate();
+
+ const { data: member } = useMemberQuery();
+ const { data: productDetail } = useProductDetailQuery(Number(productId));
+
+ const { reset } = useQueryErrorResetBoundary();
+
+ const [currentSortOption, setSortOption] = useSelect(REVIEW_SORT_OPTIONS[0]);
+ const { isOpen, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet();
+ const { gaEvent } = useGA();
+
+ if (!productId) {
+ return ;
+ }
+
+ const handleOpenSortOptionSheet = () => {
+ handleOpenBottomSheet();
+ gaEvent({ category: 'button', action: '상품 리뷰 정렬 버튼 클릭', label: '상품 리뷰 정렬' });
+ };
+
+ const handleLoginButtonClick = () => {
+ setLocalStorage(PREVIOUS_PATH_LOCAL_STORAGE_KEY, pathname);
+ navigate(PATH.LOGIN);
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*로그인 여부에 따라 링크 경로*/}
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/pages/ProductDetailPage/productDetailPage.css.ts b/src/pages/ProductDetailPage/productDetailPage.css.ts
new file mode 100644
index 000000000..78cc32f30
--- /dev/null
+++ b/src/pages/ProductDetailPage/productDetailPage.css.ts
@@ -0,0 +1,47 @@
+import { vars } from '@/styles/theme.css';
+import { style, styleVariants } from '@vanilla-extract/css';
+
+export const main = style({
+ paddingBottom: 70,
+});
+
+export const section = style({
+ position: 'relative',
+ margin: '12px 0 24px',
+});
+
+export const sortWrapper = style({
+ position: 'absolute',
+ top: 0,
+ right: 20,
+});
+
+export const registerButtonWrapper = style({
+ position: 'fixed',
+ left: '50%',
+ bottom: 0,
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'flex-end',
+ width: '100%',
+ height: 70,
+ maxWidth: 400,
+ padding: '0 20px',
+ border: `1px solid ${vars.colors.border.default}`,
+ backgroundColor: vars.colors.background.default,
+ transform: 'translateX(-50%)',
+});
+
+const registerButtonBase = style({
+ width: '100%',
+ height: 56,
+ backgroundColor: vars.colors.primary,
+ color: vars.colors.white,
+ borderRadius: 6,
+ fontWeight: 700,
+});
+
+export const registerButton = styleVariants({
+ active: [registerButtonBase, { backgroundColor: vars.colors.primary }],
+ disabled: [registerButtonBase, { backgroundColor: vars.colors.background.tag }],
+});
diff --git a/src/router/index.tsx b/src/router/index.tsx
index b41bb8ba4..4215a0d9d 100644
--- a/src/router/index.tsx
+++ b/src/router/index.tsx
@@ -174,7 +174,7 @@ const router = createBrowserRouter([
path: `${PATH.PRODUCT_LIST}/detail/:productId`,
async lazy() {
const { ProductDetailPage } = await import(
- /* webpackChunkName: "ProductDetailPage" */ '@/pages/ProductDetailPage'
+ /* webpackChunkName: "ProductDetailPage" */ '@/pages/ProductDetailPage/ProductDetailPage'
);
return { Component: ProductDetailPage };
},
diff --git a/src/styles/theme.css.ts b/src/styles/theme.css.ts
index 0a0355658..44d0e8880 100644
--- a/src/styles/theme.css.ts
+++ b/src/styles/theme.css.ts
@@ -1,5 +1,19 @@
import { createGlobalTheme } from '@vanilla-extract/css';
+const semantic = {
+ blue: '#1774FF',
+ orange: '#FF9417',
+ red: '#FD4545',
+};
+
+const text = {
+ default: '#232527',
+ sub: '#3D3D3D',
+ info: '#808080',
+ disabled: '#999999',
+ white: '#FFFFFF',
+};
+
const border = {
default: '#E6E6E6',
navigation: '#F2F2F2',
@@ -8,6 +22,7 @@ const border = {
const icon = {
default: '#FFB017',
+ fill: '#FFC14A',
disabled: '#999999',
gray: '#D6D6D6',
light: '#E6E6E6',
@@ -33,10 +48,12 @@ export const vars = createGlobalTheme(':root', {
gray5: '#444444',
black: '#232527',
- success: '#1774FF',
- caution: '#FF9417',
- error: '#FD4545',
+ success: semantic.blue,
+ caution: semantic.orange,
+ error: semantic.red,
+ semantic,
+ text,
border: border,
icon: icon,
background: background,
diff --git a/src/types/product.ts b/src/types/product.ts
index 82017dcb1..45ad8184d 100644
--- a/src/types/product.ts
+++ b/src/types/product.ts
@@ -1,4 +1,4 @@
-import type { Tag } from './common';
+import type { Category, Tag } from './common';
export interface Product {
id: number;
@@ -17,6 +17,7 @@ export interface ProductDetail {
content: string;
averageRating: number;
reviewCount: number;
+ category: Category;
tags: Tag[];
}