From 4196d6b05e45bcf03fa6b06a818d3b2a067f598b Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Sun, 14 Apr 2024 22:22:49 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B0=9C=ED=8E=B8=20?= =?UTF-8?q?(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 상품 상세 컴포넌트 개편 * feat: 상품 상세 아이템 스타일 수정 * refactor: 상품 상세 페이지 폴더 추가 * feat: 리뷰 등록 버튼 구현 * feat: 상품 상세 응답 카테고리 추가 * feat: 상품 상세 아이템 카테고리 추가 * feat: 리뷰 좋아요 버튼 수정 * feat: 리뷰 작성자 정보 디자인 수정 * feat: 리뷰 별점 디자인 수정 * feat: 태그 리스트 컴포넌트 개편 * feat: 리뷰 아이템 개편 * feat: 리뷰 리스트 컴포넌트 개편 * feat: 상품 상세 페이지 리뷰 섹션 개편 * feat: 평점 높이 수정 * feat: 상품 상세 페이지 category param 삭제 * refactor: 꿀조합 상품 바텀시트 버튼 컴포넌트로 이동 * refactor: 꿀조합 아이템 props 추가 * feat: 꿀조합 이미지 비율 수정 * feat: 상품 상세 꿀조합 리스트 개편 * refactor: Text 컴포넌트로 수정 * feat: 리뷰 정렬 바텀시트 추가 * feat: 리뷰 반영 * refactor: 좋아요 색상 이름 수정 --- src/components/Common/TagList/TagList.tsx | 36 ++-- src/components/Common/TagList/tagList.css.ts | 17 ++ src/components/Common/Text/text.css.ts | 1 + src/components/Common/Text/text.types.ts | 2 +- .../ProductDetailItem.stories.tsx | 2 +- .../ProductDetailItem/ProductDetailItem.tsx | 123 ++++++------- .../productDetailItem.css.ts | 57 ++++++ .../ProductRecipeList/ProductRecipeList.tsx | 89 ++++------ .../productRecipeList.css.ts | 38 ++++ .../Recipe/RecipeItem/RecipeItem.tsx | 54 +++--- .../Recipe/RecipeItem/recipeItem.css.ts | 8 +- .../Recipe/RecipeList/RecipeList.tsx | 2 +- .../RecipeProductButton.tsx | 24 ++- .../recipeProductButton.css.ts | 4 + .../ReviewFavoriteButton.tsx | 26 +-- .../reviewFavoriteButton.css.ts | 8 + .../Review/ReviewItem/ReviewItem.stories.tsx | 42 +++++ .../Review/ReviewItem/ReviewItem.tsx | 134 ++++++-------- .../Review/ReviewItem/reviewItem.css.ts | 47 +++++ .../Review/ReviewList/ReviewList.tsx | 15 +- .../Review/ReviewList/reviewList.css.ts | 8 + src/mocks/data/productDetail.json | 5 + src/mocks/data/productDetails.json | 14 +- src/mocks/data/reviews.json | 21 ++- src/pages/ProductDetailPage.tsx | 166 ------------------ .../ProductDetailPage/ProductDetailPage.tsx | 107 +++++++++++ .../productDetailPage.css.ts | 47 +++++ src/router/index.tsx | 2 +- src/styles/theme.css.ts | 23 ++- src/types/product.ts | 3 +- 30 files changed, 642 insertions(+), 483 deletions(-) create mode 100644 src/components/Common/TagList/tagList.css.ts create mode 100644 src/components/Product/ProductDetailItem/productDetailItem.css.ts create mode 100644 src/components/Product/ProductRecipeList/productRecipeList.css.ts create mode 100644 src/components/Review/ReviewFavoriteButton/reviewFavoriteButton.css.ts create mode 100644 src/components/Review/ReviewItem/ReviewItem.stories.tsx create mode 100644 src/components/Review/ReviewItem/reviewItem.css.ts create mode 100644 src/components/Review/ReviewList/reviewList.css.ts delete mode 100644 src/pages/ProductDetailPage.tsx create mode 100644 src/pages/ProductDetailPage/ProductDetailPage.tsx create mode 100644 src/pages/ProductDetailPage/productDetailPage.css.ts diff --git a/src/components/Common/TagList/TagList.tsx b/src/components/Common/TagList/TagList.tsx index b36cd70aa..359cc7ed4 100644 --- a/src/components/Common/TagList/TagList.tsx +++ b/src/components/Common/TagList/TagList.tsx @@ -1,8 +1,7 @@ -import { Badge } from '@fun-eat/design-system'; -import styled from 'styled-components'; +import { tag, tagList } from './tagList.css'; +import Text from '../Text/Text'; import type { Tag } from '@/types/common'; -import { convertTagColor } from '@/utils/convertTagColor'; interface TagListProps { tags: Tag[]; @@ -10,29 +9,16 @@ interface TagListProps { const TagList = ({ tags }: TagListProps) => { return ( - - {tags.map((tag) => { - const tagColor = convertTagColor(tag.tagType); - return ( -
  • - - {tag.name} - -
  • - ); - })} -
    + ); }; export default TagList; - -const TagListContainer = styled.ul` - display: flex; - margin: 12px 0; - column-gap: 8px; -`; - -const TagBadge = styled(Badge)` - font-weight: bold; -`; diff --git a/src/components/Common/TagList/tagList.css.ts b/src/components/Common/TagList/tagList.css.ts new file mode 100644 index 000000000..e4bc21cc9 --- /dev/null +++ b/src/components/Common/TagList/tagList.css.ts @@ -0,0 +1,17 @@ +import { vars } from '@/styles/theme.css'; +import { style } from '@vanilla-extract/css'; + +export const tagList = style({ + display: 'flex', + gap: 4, +}); + +export const tag = style({ + display: 'flex', + alignItems: 'center', + height: 23, + padding: '0 6px', + textAlign: 'center', + borderRadius: 4, + backgroundColor: vars.colors.background.category, +}); diff --git a/src/components/Common/Text/text.css.ts b/src/components/Common/Text/text.css.ts index 2272a2eb4..67c2ea832 100644 --- a/src/components/Common/Text/text.css.ts +++ b/src/components/Common/Text/text.css.ts @@ -22,6 +22,7 @@ export const text = recipe({ caption1: { fontSize: '1.4rem' }, body: { fontSize: '1.6rem' }, headline: { fontSize: '1.8rem' }, + display1: { fontSize: '2.2rem' }, }, weight: { regular: { fontWeight: 400 }, diff --git a/src/components/Common/Text/text.types.ts b/src/components/Common/Text/text.types.ts index 11752a80b..e6456a3f9 100644 --- a/src/components/Common/Text/text.types.ts +++ b/src/components/Common/Text/text.types.ts @@ -5,7 +5,7 @@ import type { text } from './text.css'; import type { RecipeVariants } from '@vanilla-extract/recipes'; export const colors = ['default', 'sub', 'info', 'disabled', 'white'] as const; -export const sizes = ['caption4', 'caption3', 'caption2', 'caption1', 'body', 'headline'] as const; +export const sizes = ['caption4', 'caption3', 'caption2', 'caption1', 'body', 'headline', 'display1'] as const; export const weights = ['regular', 'medium', 'semiBold'] as const; export type Color = (typeof colors)[number]; diff --git a/src/components/Product/ProductDetailItem/ProductDetailItem.stories.tsx b/src/components/Product/ProductDetailItem/ProductDetailItem.stories.tsx index 32226fec6..65574c710 100644 --- a/src/components/Product/ProductDetailItem/ProductDetailItem.stories.tsx +++ b/src/components/Product/ProductDetailItem/ProductDetailItem.stories.tsx @@ -17,7 +17,7 @@ type Story = StoryObj; export const Default: Story = { render: ({ ...args }) => ( -
    +
    ), 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 ? {name} : } - - - - 가격 - {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; - } -`; +
    + {name} -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}의 + {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 && {`${userName}의} -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[]; } From 0c9edba78290f494fb18e90292d4b9c9242ad1c0 Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Sun, 14 Apr 2024 22:23:24 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EB=A6=AC=EB=B7=B0=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=ED=8F=BC=20=EA=B0=9C=ED=8E=B8=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 사진 아이콘 추가 * feat: 이미지 업로더 개편 * feat: 별점 작성 개편 * feat: 리뷰 설명 개편 * refactor: 안 쓰는 스타일 파일 삭제 * feat: 재구매 체크박스 개편 * feat: 재구매 타이틀 추가 * feat: 태그 선택 바텀시트 열기 버튼 추가 * feat: 태그 선택 구현 * feat: 태그 최대 3개 선택 구현 * feat: 체크 되어 있는 태그 스타일 * feat: 선택 태그 폼에서 렌더링 * feat: 폼에서 태그 삭제 * refactor: 안 쓰는 컴포넌트 삭제 * feat: 태그 선택 바텀시트 컴포넌트 추가 * feat: 리뷰 등록 페이지 추가 * feat: 태그 바텀시트 prop 추가 * refactor: 리뷰 태그 바텀시트 index export * refactor: Text 컴포넌트 사용 * style: 태그 조건 이름 수정 * feat: 태그 선택 최소 조건 추가 * feat: 태그 선택 바텀시트 높이 추가 * refactor: Text 컴포넌트 사용 * feat: 이미지 업로더 스타일 개편 * feat: 리뷰 설명 에러 처리 * feat: 설명 에러 메시지 추가 구현 * feat: 리뷰 태그 바텀시트 스토리 추가 --- .storybook/preview-body.html | 20 +++ .../Common/ImageUploader/ImageUploader.tsx | 69 ++++--- .../Common/ImageUploader/imageUploader.css.ts | 56 ++++++ src/components/Common/Svg/SvgIcon.tsx | 2 + src/components/Common/Svg/SvgSprite.tsx | 18 ++ .../Review/RebuyCheckbox/RebuyCheckbox.tsx | 33 +++- .../Review/RebuyCheckbox/rebuyCheckbox.css.ts | 27 +++ .../ReviewRegisterForm/ReviewRegisterForm.tsx | 169 +++++++----------- .../ReviewTextarea/ReviewTextarea.stories.tsx | 0 .../ReviewTextarea/ReviewTextarea.tsx | 78 ++++++++ .../ReviewTextarea/reviewTextarea.css.ts | 54 ++++++ .../StarRate/StarRate.stories.tsx | 0 .../StarRate/StarRate.tsx | 52 +++--- .../StarRate/starRate.css.ts | 10 ++ .../reviewRegisterForm.css.ts | 44 +++++ .../ReviewTagItem/ReviewTagItem.stories.tsx | 33 ---- .../Review/ReviewTagItem/ReviewTagItem.tsx | 65 ------- .../Review/ReviewTagList/ReviewTagList.tsx | 127 +++++-------- .../Review/ReviewTagList/reviewTagList.css.ts | 40 +++++ .../ReviewTagSheet/ReviewTagSheet.stories.tsx | 38 ++++ .../Review/ReviewTagSheet/ReviewTagSheet.tsx | 35 ++++ .../ReviewTagSheet/reviewTagSheet.css.ts | 41 +++++ .../Review/ReviewTextarea/ReviewTextarea.tsx | 59 ------ src/components/Review/index.ts | 2 +- src/constants/index.ts | 3 +- src/contexts/ReviewFormContext.tsx | 47 +++-- .../ReviewRegisterPage/ReviewRegisterPage.tsx | 30 ++++ .../reviewRegisterPage.css.ts | 6 + src/router/index.tsx | 9 + 29 files changed, 728 insertions(+), 439 deletions(-) create mode 100644 src/components/Common/ImageUploader/imageUploader.css.ts create mode 100644 src/components/Review/RebuyCheckbox/rebuyCheckbox.css.ts rename src/components/Review/{ => ReviewRegisterForm}/ReviewTextarea/ReviewTextarea.stories.tsx (100%) create mode 100644 src/components/Review/ReviewRegisterForm/ReviewTextarea/ReviewTextarea.tsx create mode 100644 src/components/Review/ReviewRegisterForm/ReviewTextarea/reviewTextarea.css.ts rename src/components/Review/{ => ReviewRegisterForm}/StarRate/StarRate.stories.tsx (100%) rename src/components/Review/{ => ReviewRegisterForm}/StarRate/StarRate.tsx (51%) create mode 100644 src/components/Review/ReviewRegisterForm/StarRate/starRate.css.ts create mode 100644 src/components/Review/ReviewRegisterForm/reviewRegisterForm.css.ts delete mode 100644 src/components/Review/ReviewTagItem/ReviewTagItem.stories.tsx delete mode 100644 src/components/Review/ReviewTagItem/ReviewTagItem.tsx create mode 100644 src/components/Review/ReviewTagList/reviewTagList.css.ts create mode 100644 src/components/Review/ReviewTagSheet/ReviewTagSheet.stories.tsx create mode 100644 src/components/Review/ReviewTagSheet/ReviewTagSheet.tsx create mode 100644 src/components/Review/ReviewTagSheet/reviewTagSheet.css.ts delete mode 100644 src/components/Review/ReviewTextarea/ReviewTextarea.tsx create mode 100644 src/pages/ReviewRegisterPage/ReviewRegisterPage.tsx create mode 100644 src/pages/ReviewRegisterPage/reviewRegisterPage.css.ts diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html index ba12aafb9..9c17143a0 100644 --- a/.storybook/preview-body.html +++ b/.storybook/preview-body.html @@ -167,6 +167,11 @@ d="M20.2 4.683a1.013 1.013 0 0 1 0 1.438L9.008 17.317a1.013 1.013 0 0 1-1.437 0L1.8 11.55a1.017 1.017 0 0 1 1.437-1.437l5.045 5.045L18.758 4.683a1.013 1.013 0 0 1 1.437 0h.005z" /> + + + + + + + + + + + + +
    diff --git a/src/components/Common/ImageUploader/ImageUploader.tsx b/src/components/Common/ImageUploader/ImageUploader.tsx index b139a1b4c..5b1e99a09 100644 --- a/src/components/Common/ImageUploader/ImageUploader.tsx +++ b/src/components/Common/ImageUploader/ImageUploader.tsx @@ -1,9 +1,13 @@ -import { Button, useToastActionContext } from '@fun-eat/design-system'; +import { useToastActionContext } from '@fun-eat/design-system'; import type { ChangeEventHandler } from 'react'; -import styled from 'styled-components'; + +import { container, deleteButton, image, imageWrapper, uploadInput, uploadLabel } from './imageUploader.css'; +import SvgIcon from '../Svg/SvgIcon'; +import Text from '../Text/Text'; import { IMAGE_MAX_SIZE } from '@/constants'; import { useEnterKeyDown } from '@/hooks/common'; +import { vars } from '@/styles/theme.css'; interface ReviewImageUploaderProps { previewImage: string; @@ -32,45 +36,32 @@ const ImageUploader = ({ previewImage, uploadImage, deleteImage }: ReviewImageUp }; return ( - <> - {previewImage ? ( - - 업로드한 사진 - - - ) : ( - - + - - +
    + + {previewImage && ( +
    + 업로드한 사진 + +
    )} - +
    ); }; export default ImageUploader; - -const ImageUploadLabel = styled.label` - display: flex; - justify-content: center; - align-items: center; - width: 92px; - height: 95px; - border: 1px solid ${({ theme }) => theme.borderColors.disabled}; - border-radius: ${({ theme }) => theme.borderRadius.xs}; - background: ${({ theme }) => theme.colors.gray1}; - cursor: pointer; - - & > input { - display: none; - } -`; - -const PreviewImageWrapper = styled.div` - display: flex; - flex-direction: column; - gap: 20px; - align-items: center; -`; diff --git a/src/components/Common/ImageUploader/imageUploader.css.ts b/src/components/Common/ImageUploader/imageUploader.css.ts new file mode 100644 index 000000000..1edc9ab8d --- /dev/null +++ b/src/components/Common/ImageUploader/imageUploader.css.ts @@ -0,0 +1,56 @@ +import { vars } from '@/styles/theme.css'; +import { style, styleVariants } from '@vanilla-extract/css'; + +export const container = style({ + display: 'flex', + gap: 8, +}); + +export const uploadLabelBase = style({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + width: 80, + height: 80, + borderRadius: 6, + cursor: 'pointer', +}); + +export const uploadLabel = styleVariants({ + default: [uploadLabelBase, { backgroundColor: vars.colors.background.category }], + uploaded: [ + uploadLabelBase, + { backgroundColor: vars.colors.background.default, border: `1px solid ${vars.colors.border.default}` }, + ], +}); + +export const uploadInput = style({ + display: 'none', +}); + +export const imageWrapper = style({ + position: 'relative', + width: 80, + height: 80, +}); + +export const image = style({ + objectFit: 'cover', + borderRadius: 6, +}); + +export const deleteButton = style({ + position: 'absolute', + top: 4, + right: 4, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: 18, + height: 18, + borderRadius: '50%', + backgroundColor: vars.colors.black, + opacity: 0.5, + cursor: 'pointer', +}); diff --git a/src/components/Common/Svg/SvgIcon.tsx b/src/components/Common/Svg/SvgIcon.tsx index 305891eb8..6f8eca925 100644 --- a/src/components/Common/Svg/SvgIcon.tsx +++ b/src/components/Common/Svg/SvgIcon.tsx @@ -14,6 +14,7 @@ export const SVG_ICON_VARIANTS = [ 'star2', 'review2', 'check2', + 'picture', 'recipe', 'list', 'member', @@ -46,6 +47,7 @@ export const SVG_ICON_VARIANTS = [ 'box', 'close2', 'disk', + 'error', ] as const; export type SvgIconVariant = (typeof SVG_ICON_VARIANTS)[number]; diff --git a/src/components/Common/Svg/SvgSprite.tsx b/src/components/Common/Svg/SvgSprite.tsx index 106e7367c..9ac66c67a 100644 --- a/src/components/Common/Svg/SvgSprite.tsx +++ b/src/components/Common/Svg/SvgSprite.tsx @@ -161,6 +161,9 @@ const SvgSprite = () => { + + + @@ -344,6 +347,21 @@ const SvgSprite = () => { + + + + + + + + + + ); }; diff --git a/src/components/Review/RebuyCheckbox/RebuyCheckbox.tsx b/src/components/Review/RebuyCheckbox/RebuyCheckbox.tsx index 9f1a6e956..ee9749009 100644 --- a/src/components/Review/RebuyCheckbox/RebuyCheckbox.tsx +++ b/src/components/Review/RebuyCheckbox/RebuyCheckbox.tsx @@ -1,10 +1,18 @@ -import { Checkbox } from '@fun-eat/design-system'; import type { ChangeEventHandler } from 'react'; +import { check, checkbox, container } from './rebuyCheckbox.css'; +import { itemTitle } from '../ReviewRegisterForm/reviewRegisterForm.css'; + +import { SvgIcon, Text } from '@/components/Common'; import { useEnterKeyDown } from '@/hooks/common'; import { useReviewFormActionContext } from '@/hooks/context'; +import { vars } from '@/styles/theme.css'; + +interface RebuyCheckboxProps { + isRebuy: boolean; +} -const RebuyCheckbox = () => { +const RebuyCheckbox = ({ isRebuy }: RebuyCheckboxProps) => { const { handleReviewFormValue } = useReviewFormActionContext(); const { inputRef, labelRef, handleKeydown } = useEnterKeyDown(); @@ -13,11 +21,22 @@ const RebuyCheckbox = () => { }; return ( -

    - - 재구매할 생각이 있으신가요? - -

    + <> +

    + 재구매 여부 +

    +

    + +

    + ); }; diff --git a/src/components/Review/RebuyCheckbox/rebuyCheckbox.css.ts b/src/components/Review/RebuyCheckbox/rebuyCheckbox.css.ts new file mode 100644 index 000000000..efb6cd1c9 --- /dev/null +++ b/src/components/Review/RebuyCheckbox/rebuyCheckbox.css.ts @@ -0,0 +1,27 @@ +import { vars } from '@/styles/theme.css'; +import { style, styleVariants } from '@vanilla-extract/css'; + +export const container = style({ + display: 'inline-flex', + alignItems: 'center', + gap: 8, + cursor: 'pointer', +}); + +export const checkbox = style({ + display: 'none', +}); + +const checkBase = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: 17, + height: 17, + borderRadius: '50%', +}); + +export const check = styleVariants({ + default: [checkBase, { backgroundColor: vars.colors.icon.light }], + checked: [checkBase, { backgroundColor: vars.colors.black }], +}); diff --git a/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx b/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx index a2e684006..dbbc04559 100644 --- a/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx +++ b/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx @@ -1,60 +1,62 @@ -import { Button, Divider, Heading, Spacing, Text, theme, useToastActionContext } from '@fun-eat/design-system'; -import type { FormEventHandler, RefObject } from 'react'; -import styled from 'styled-components'; +import { Spacing, useToastActionContext } from '@fun-eat/design-system'; +import type { FormEventHandler } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { itemTitle, requiredMark, tagAddButton, tagButton, tagList } from './reviewRegisterForm.css'; +import ReviewTextarea from './ReviewTextarea/ReviewTextarea'; +import StarRate from './StarRate/StarRate'; import RebuyCheckbox from '../RebuyCheckbox/RebuyCheckbox'; -import ReviewTagList from '../ReviewTagList/ReviewTagList'; -import ReviewTextarea from '../ReviewTextarea/ReviewTextarea'; -import StarRate from '../StarRate/StarRate'; -import { ImageUploader, SvgIcon } from '@/components/Common'; -import { MIN_DISPLAYED_TAGS_LENGTH } from '@/constants'; -import { useFormData, useImageUploader, useScroll } from '@/hooks/common'; +import { ImageUploader, SvgIcon, Text } from '@/components/Common'; +import { MAX_DISPLAYED_TAGS_LENGTH, MIN_DISPLAYED_TAGS_LENGTH } from '@/constants'; +import type { TagValue } from '@/contexts/ReviewFormContext'; +import { useFormData, useImageUploader } from '@/hooks/common'; import { useReviewFormActionContext, useReviewFormValueContext } from '@/hooks/context'; -import { useProductDetailQuery } from '@/hooks/queries/product'; import { useReviewRegisterFormMutation } from '@/hooks/queries/review'; +import { vars } from '@/styles/theme.css'; import type { ReviewRequest } from '@/types/review'; const MIN_RATING_SCORE = 0; -const MIN_SELECTED_TAGS_COUNT = 1; const MIN_CONTENT_LENGTH = 0; interface ReviewRegisterFormProps { productId: number; - targetRef: RefObject; - closeReviewDialog: () => void; - initTabMenu: () => void; + openBottomSheet: () => void; } -const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog, initTabMenu }: ReviewRegisterFormProps) => { - const { scrollToPosition } = useScroll(); +const ReviewRegisterForm = ({ productId, openBottomSheet }: ReviewRegisterFormProps) => { const { isImageUploading, previewImage, imageFile, uploadImage, deleteImage } = useImageUploader(); + const navigate = useNavigate(); const reviewFormValue = useReviewFormValueContext(); - const { resetReviewFormValue } = useReviewFormActionContext(); + const { handleReviewFormValue, resetReviewFormValue } = useReviewFormActionContext(); const { toast } = useToastActionContext(); - const { data: productDetail } = useProductDetailQuery(productId); - const { mutate, isLoading } = useReviewRegisterFormMutation(productId); + const { mutate } = useReviewRegisterFormMutation(productId); const isValid = reviewFormValue.rating > MIN_RATING_SCORE && - reviewFormValue.tagIds.length >= MIN_SELECTED_TAGS_COUNT && - reviewFormValue.tagIds.length <= MIN_DISPLAYED_TAGS_LENGTH && + reviewFormValue.tags.length >= MIN_DISPLAYED_TAGS_LENGTH && + reviewFormValue.tags.length <= MAX_DISPLAYED_TAGS_LENGTH && reviewFormValue.content.length > MIN_CONTENT_LENGTH && !isImageUploading; + const formValue: ReviewRequest = { ...reviewFormValue, tagIds: reviewFormValue.tags.map(({ id }) => id) }; + const formData = useFormData({ imageKey: 'image', imageFile: imageFile, formContentKey: 'reviewRequest', - formContent: reviewFormValue, + formContent: formValue, }); const resetAndCloseForm = () => { deleteImage(); resetReviewFormValue(); - closeReviewDialog(); + }; + + const handleTagSelect = (currentTag: TagValue) => () => { + handleReviewFormValue({ target: 'tags', value: currentTag }); }; const handleSubmit: FormEventHandler = async (event) => { @@ -63,9 +65,8 @@ const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog, initTabMe mutate(formData, { onSuccess: () => { resetAndCloseForm(); - initTabMenu(); - scrollToPosition(targetRef); toast.success('📝 리뷰가 등록 됐어요'); + navigate(-1); }, onError: (error) => { resetAndCloseForm(); @@ -80,89 +81,43 @@ const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog, initTabMe }; return ( - - 리뷰 작성 - - - - - - - - - 구매한 상품 사진이 있다면 올려주세요. - - - - (사진은 5MB 이하, 1장까지 업로드 할 수 있어요.) - - - - - - - - - - - - - - - [작성시 유의사항] 신뢰성 확보에 저해되는 게시물은 삭제하거나 보이지 않게 할 수 있습니다. - - - - {isValid ? '리뷰 등록하기' : '꼭 입력해야 하는 항목이 있어요'} - - - +
    +
    +

    + 사진 등록 +

    + +
    + + + +
    +

    + 태그 + + * + +

    + +
      + {reviewFormValue.tags.map((tag) => ( +
    • + +
    • + ))} +
    +
    + + + + + ); }; export default ReviewRegisterForm; - -const ReviewRegisterFormContainer = styled.div` - position: relative; - height: 100%; -`; - -const ReviewHeading = styled(Heading)` - height: 80px; - font-size: 2.4rem; - line-height: 80px; - text-align: center; -`; - -const CloseButton = styled(Button)` - position: absolute; - top: 24px; - right: 32px; -`; - -const ProductOverviewItemWrapper = styled.div` - margin: 15px 0; -`; - -const RegisterForm = styled.form` - padding: 50px 20px; -`; - -const ReviewImageUploaderContainer = styled.div` - display: flex; - flex-direction: column; - align-items: center; -`; - -const FormButton = styled(Button)` - color: ${({ theme, disabled }) => (disabled ? theme.colors.white : theme.colors.black)}; - background: ${({ theme, disabled }) => (disabled ? theme.colors.gray3 : theme.colors.primary)}; - cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')}; -`; diff --git a/src/components/Review/ReviewTextarea/ReviewTextarea.stories.tsx b/src/components/Review/ReviewRegisterForm/ReviewTextarea/ReviewTextarea.stories.tsx similarity index 100% rename from src/components/Review/ReviewTextarea/ReviewTextarea.stories.tsx rename to src/components/Review/ReviewRegisterForm/ReviewTextarea/ReviewTextarea.stories.tsx diff --git a/src/components/Review/ReviewRegisterForm/ReviewTextarea/ReviewTextarea.tsx b/src/components/Review/ReviewRegisterForm/ReviewTextarea/ReviewTextarea.tsx new file mode 100644 index 000000000..cde88ef9a --- /dev/null +++ b/src/components/Review/ReviewRegisterForm/ReviewTextarea/ReviewTextarea.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import type { FocusEventHandler, ChangeEventHandler } from 'react'; + +import { + container, + currentLength, + errorMessage, + errorWrapper, + reviewTextarea, + statusWrapper, +} from './reviewTextarea.css'; +import { itemTitle, requiredMark } from '../reviewRegisterForm.css'; + +import { SvgIcon, Text } from '@/components/Common'; +import { useReviewFormActionContext } from '@/hooks/context'; + +const MIN_LENGTH = 10; +const MAX_LENGTH = 500; + +interface ReviewTextareaProps { + content: string; +} + +const ReviewTextarea = ({ content }: ReviewTextareaProps) => { + const { handleReviewFormValue } = useReviewFormActionContext(); + const [isTouched, setIsTouched] = useState(false); + + const handleReviewText: ChangeEventHandler = (event) => { + handleReviewFormValue({ target: 'content', value: event.currentTarget.value }); + }; + + const handleFocus: FocusEventHandler = () => { + setIsTouched(false); + }; + + const handleBlur: FocusEventHandler = () => { + setIsTouched(true); + }; + + const isValid = content.trim().length >= MIN_LENGTH || !isTouched; + + return ( +
    +

    + 설명 + + * + +

    + +