diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html index 4e44004eb..9e1c31c57 100644 --- a/frontend/.storybook/preview-body.html +++ b/frontend/.storybook/preview-body.html @@ -100,6 +100,15 @@ d="M3 4V1h2v3h3v2H5v3H3V6H0V4m6 6V7h3V4h7l1.8 2H21c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V10m10 9c4.45 0 6.69-5.38 3.54-8.54C13.39 7.31 8 9.55 8 14c0 2.76 2.24 5 5 5m-3.2-5c0 2.85 3.45 4.28 5.46 2.26c2.02-2.01.59-5.46-2.26-5.46A3.21 3.21 0 0 0 9.8 14Z" /> + + + + + ; export const Default: Story = { args: { name: '사이다', - bookmark: false, }, }; export const Bookmarked: Story = { args: { name: '사이다', - bookmark: true, }, }; diff --git a/frontend/src/components/Common/SectionTitle/SectionTitle.tsx b/frontend/src/components/Common/SectionTitle/SectionTitle.tsx index 106c60759..c9d649ddb 100644 --- a/frontend/src/components/Common/SectionTitle/SectionTitle.tsx +++ b/frontend/src/components/Common/SectionTitle/SectionTitle.tsx @@ -1,4 +1,5 @@ -import { Button, Heading, theme } from '@fun-eat/design-system'; +import { Button, Heading, Link, theme } from '@fun-eat/design-system'; +import { Link as RouterLink } from 'react-router-dom'; import styled from 'styled-components'; import { SvgIcon } from '@/components/Common'; @@ -6,10 +7,10 @@ import { useRoutePage } from '@/hooks/common'; interface SectionTitleProps { name: string; - bookmark?: boolean; + link?: string; } -const SectionTitle = ({ name, bookmark = false }: SectionTitleProps) => { +const SectionTitle = ({ name, link }: SectionTitleProps) => { const { routeBack } = useRoutePage(); return ( @@ -18,18 +19,15 @@ const SectionTitle = ({ name, bookmark = false }: SectionTitleProps) => { - - {name} - + {link ? ( + + {name} + + ) : ( + {name} + )} + {link && } - {bookmark && ( - - - - )} ); }; @@ -45,9 +43,12 @@ const SectionTitleContainer = styled.div` const SectionTitleWrapper = styled.div` display: flex; align-items: center; - column-gap: 16px; svg { padding-top: 2px; } `; + +const ProductName = styled(Heading)` + margin: 0 5px 0 16px; +`; diff --git a/frontend/src/components/Common/Svg/SvgIcon.tsx b/frontend/src/components/Common/Svg/SvgIcon.tsx index d8d11d326..b8be9a974 100644 --- a/frontend/src/components/Common/Svg/SvgIcon.tsx +++ b/frontend/src/components/Common/Svg/SvgIcon.tsx @@ -21,6 +21,7 @@ export const SVG_ICON_VARIANTS = [ 'plus', 'pencil', 'camera', + 'link', 'plane', ] as const; export type SvgIconVariant = (typeof SVG_ICON_VARIANTS)[number]; diff --git a/frontend/src/components/Common/Svg/SvgSprite.tsx b/frontend/src/components/Common/Svg/SvgSprite.tsx index b4811ca73..65cf49d3d 100644 --- a/frontend/src/components/Common/Svg/SvgSprite.tsx +++ b/frontend/src/components/Common/Svg/SvgSprite.tsx @@ -74,6 +74,12 @@ const SvgSprite = () => { + + + + + + diff --git a/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx b/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx index 50398fedf..fde211413 100644 --- a/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx +++ b/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx @@ -48,12 +48,8 @@ const MemberReviewList = ({ isMemberPage = false }: MemberReviewListProps) => { {reviewsToDisplay.map((reviewRanking) => ( - - + + ))} diff --git a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx index 70fd89c32..c2004c20e 100644 --- a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx +++ b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx @@ -7,13 +7,14 @@ import type { ReviewRanking } from '@/types/ranking'; interface ReviewRankingItemProps { reviewRanking: ReviewRanking; + isMemberPage?: boolean; } -const ReviewRankingItem = ({ reviewRanking }: ReviewRankingItemProps) => { +const ReviewRankingItem = ({ reviewRanking, isMemberPage = false }: ReviewRankingItemProps) => { const { productName, content, rating, favoriteCount } = reviewRanking; return ( - + {productName} @@ -41,13 +42,14 @@ const ReviewRankingItem = ({ reviewRanking }: ReviewRankingItemProps) => { export default memo(ReviewRankingItem); -const ReviewRankingItemContainer = styled.div` +const ReviewRankingItemContainer = styled.div<{ isMemberPage: boolean }>` display: flex; flex-direction: column; gap: 4px; padding: 12px; - border: 1px solid ${({ theme }) => theme.borderColors.disabled}; - border-radius: ${({ theme }) => theme.borderRadius.sm}; + border: ${({ isMemberPage, theme }) => (isMemberPage ? 'none' : `1px solid ${theme.borderColors.disabled}`)}; + border-bottom: ${({ theme }) => `1px solid ${theme.borderColors.disabled}`}; + border-radius: ${({ isMemberPage, theme }) => (isMemberPage ? 0 : theme.borderRadius.sm)}; `; const ReviewText = styled(Text)` diff --git a/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx b/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx index 7b6c8272c..99f10d5f3 100644 --- a/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx +++ b/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx @@ -28,7 +28,7 @@ const ReviewRankingList = ({ isHomePage = false }: ReviewRankingListProps) => { diff --git a/frontend/src/constants/path.ts b/frontend/src/constants/path.ts index 6fd8735c8..f729a74b2 100644 --- a/frontend/src/constants/path.ts +++ b/frontend/src/constants/path.ts @@ -4,5 +4,6 @@ export const PATH = { PRODUCT_LIST: '/products', MEMBER: '/members', RECIPE: '/recipes', + REVIEW: '/reviews', LOGIN: '/login', } as const; diff --git a/frontend/src/hooks/queries/review/index.ts b/frontend/src/hooks/queries/review/index.ts index e8bb44d4a..78fd628d2 100644 --- a/frontend/src/hooks/queries/review/index.ts +++ b/frontend/src/hooks/queries/review/index.ts @@ -1,3 +1,4 @@ export { default as useReviewTagsQuery } from './useReviewTagsQuery'; export { default as useReviewFavoriteMutation } from './useReviewFavoriteMutation'; export { default as useReviewRegisterFormMutation } from './useReviewRegisterFormMutation'; +export { default as useReviewDetailQuery } from './useReviewDetailQuery'; diff --git a/frontend/src/hooks/queries/review/useReviewDetailQuery.ts b/frontend/src/hooks/queries/review/useReviewDetailQuery.ts new file mode 100644 index 000000000..a80914053 --- /dev/null +++ b/frontend/src/hooks/queries/review/useReviewDetailQuery.ts @@ -0,0 +1,16 @@ +import { useSuspendedQuery } from '../useSuspendedQuery'; + +import { reviewApi } from '@/apis'; +import type { ReviewDetailResponse } from '@/types/response'; + +const fetchReviewDetail = async (reviewId: number) => { + const response = await reviewApi.get({ params: `/${reviewId}` }); + const data: ReviewDetailResponse = await response.json(); + return data; +}; + +const useReviewDetailQuery = (reviewId: number) => { + return useSuspendedQuery(['review', reviewId, 'detail'], () => fetchReviewDetail(reviewId)); +}; + +export default useReviewDetailQuery; diff --git a/frontend/src/mocks/data/productDetail.json b/frontend/src/mocks/data/productDetail.json index 386c7ae71..2695b51b1 100644 --- a/frontend/src/mocks/data/productDetail.json +++ b/frontend/src/mocks/data/productDetail.json @@ -5,7 +5,6 @@ "image": "https://i.namu.wiki/i/9wnvUaEa1EkDqG-M0Pbwfdf19FJQQXV_-bnlU2SYaNcG05y2wbabiIrfrGES1M4xSgDjY39RwOvLNggDd3Huuw.webp", "content": "할머니가 먹을 거 같은 맛입니다.\n1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데, 맛있어요.", "averageRating": 4.5, - "bookmark": false, "reviewCount": 100, "tags": [ { diff --git a/frontend/src/mocks/data/productDetails.json b/frontend/src/mocks/data/productDetails.json index e3c68dab0..c386ce680 100644 --- a/frontend/src/mocks/data/productDetails.json +++ b/frontend/src/mocks/data/productDetails.json @@ -6,7 +6,6 @@ "image": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34", "content": "할머니가 먹을 거 같은 맛입니다.\n1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데, 맛있어요.", "averageRating": 4.5, - "bookmark": false, "reviewCount": 100, "tags": [ { @@ -33,7 +32,6 @@ "image": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34", "content": "할머니가 먹을 거 같은 맛입니다. 1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데, 맛있어요.", "averageRating": 4.0, - "bookmark": true, "reviewCount": 55, "tags": [ { diff --git a/frontend/src/mocks/data/reviewDetail.json b/frontend/src/mocks/data/reviewDetail.json new file mode 100644 index 000000000..4439f7d43 --- /dev/null +++ b/frontend/src/mocks/data/reviewDetail.json @@ -0,0 +1,29 @@ +{ + "reviews": { + "id": 1, + "userName": "펀잇", + "profileImage": "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", + "rating": 4.5, + "tags": [ + { + "id": 5, + "name": "단짠단짠", + "tagType": "TASTE" + }, + { + "id": 1, + "name": "망고망고", + "tagType": "TASTE" + } + ], + "content": "맛있어용~!~!", + "rebuy": true, + "favoriteCount": 1320, + "favorite": true, + "createdAt": "2023-10-13T00:00:00", + "categoryType": "food", + "productId": 1, + "productName": "칠성 사이다" + } +} diff --git a/frontend/src/mocks/handlers/reviewHandlers.ts b/frontend/src/mocks/handlers/reviewHandlers.ts index 455714f52..5c007c605 100644 --- a/frontend/src/mocks/handlers/reviewHandlers.ts +++ b/frontend/src/mocks/handlers/reviewHandlers.ts @@ -1,6 +1,7 @@ import { rest } from 'msw'; import { isReviewSortOption, isSortOrder } from './utils'; +import mockReviewDetail from '../data/reviewDetail.json'; import mockReviewRanking from '../data/reviewRankingList.json'; import mockReviews from '../data/reviews.json'; import mockReviewTags from '../data/reviewTagList.json'; @@ -73,4 +74,8 @@ export const reviewHandlers = [ rest.get('/api/tags', (_, res, ctx) => { return res(ctx.status(200), ctx.json(mockReviewTags)); }), + + rest.get('/api/reviews/:reviewId', (_, res, ctx) => { + return res(ctx.status(200), ctx.json(mockReviewDetail)); + }), ]; diff --git a/frontend/src/pages/ProductDetailPage.tsx b/frontend/src/pages/ProductDetailPage.tsx index 12df226e0..f53b61282 100644 --- a/frontend/src/pages/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductDetailPage.tsx @@ -54,7 +54,7 @@ export const ProductDetailPage = () => { return null; } - const { name, bookmark, reviewCount } = productDetail; + const { name, reviewCount } = productDetail; const tabMenus = [`리뷰 ${reviewCount}`, '꿀조합']; const sortOptions = isReviewTab ? REVIEW_SORT_OPTIONS : RECIPE_SORT_OPTIONS; @@ -84,7 +84,7 @@ export const ProductDetailPage = () => { return ( - + diff --git a/frontend/src/pages/ReviewDetailPage.tsx b/frontend/src/pages/ReviewDetailPage.tsx new file mode 100644 index 000000000..7447e88e2 --- /dev/null +++ b/frontend/src/pages/ReviewDetailPage.tsx @@ -0,0 +1,132 @@ +import { Badge, Spacing, Text, useTheme } from '@fun-eat/design-system'; +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; + +import { SectionTitle, SvgIcon, TagList } from '@/components/Common'; +import { PATH } from '@/constants/path'; +import { useReviewDetailQuery } from '@/hooks/queries/review'; +import { getRelativeDate } from '@/utils/date'; + +export const ReviewDetailPage = () => { + const { reviewId } = useParams(); + const { data: reviewDetail } = useReviewDetailQuery(Number(reviewId)); + + const { + productName, + categoryType, + productId, + profileImage, + userName, + rating, + createdAt, + rebuy, + image, + tags, + content, + favoriteCount, + } = reviewDetail.reviews; + + const theme = useTheme(); + + return ( + + + + + + + + + {userName} + + {Array.from({ length: 5 }, (_, index) => ( + + ))} + + {getRelativeDate(createdAt)} + + + + + {rebuy && ( + + 😝 또 살래요 + + )} + + {image && } + + {content} + + + + {favoriteCount} + + + + + ); +}; + +const ReviewDetailPageContainer = styled.div` + padding: 20px 20px 0; +`; + +const ReviewItemContainer = styled.div` + display: flex; + flex-direction: column; + row-gap: 20px; +`; + +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; +`; + +const RebuyBadge = styled(Badge)` + font-weight: ${({ theme }) => theme.fontWeights.bold}; +`; + +const ReviewerImage = styled.img` + border: 2px solid ${({ theme }) => theme.colors.primary}; + border-radius: 50%; + object-fit: cover; +`; + +const RatingIconWrapper = styled.div` + display: flex; + align-items: center; + margin-left: -2px; + + & > span { + margin-left: 12px; + } +`; + +const ReviewImage = styled.img` + align-self: center; +`; + +const ReviewContent = styled(Text)` + white-space: pre-wrap; +`; + +const FavoriteWrapper = styled.div` + display: flex; + align-items: center; + padding: 0; + column-gap: 8px; +`; diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 739785556..d045e8dc3 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -51,6 +51,15 @@ const router = createBrowserRouter([ return { Component: MemberRecipePage }; }, }, + { + path: `${PATH.REVIEW}/:reviewId`, + async lazy() { + const { ReviewDetailPage } = await import( + /* webpackChunkName: "ReviewDetailPage" */ '@/pages/ReviewDetailPage' + ); + return { Component: ReviewDetailPage }; + }, + }, ], }, { diff --git a/frontend/src/types/product.ts b/frontend/src/types/product.ts index 391cb103d..b3389a32f 100644 --- a/frontend/src/types/product.ts +++ b/frontend/src/types/product.ts @@ -17,7 +17,6 @@ export interface ProductDetail { content: string; averageRating: number; reviewCount: number; - bookmark: boolean; tags: Tag[]; } diff --git a/frontend/src/types/ranking.ts b/frontend/src/types/ranking.ts index 9f0707f68..01c669e0f 100644 --- a/frontend/src/types/ranking.ts +++ b/frontend/src/types/ranking.ts @@ -1,3 +1,4 @@ +import type { CategoryVariant } from './common'; import type { Member } from './member'; import type { Product } from './product'; @@ -10,7 +11,7 @@ export interface ReviewRanking { content: string; rating: number; favoriteCount: number; - categoryType: string; + categoryType: CategoryVariant; } export interface RecipeRanking { diff --git a/frontend/src/types/response.ts b/frontend/src/types/response.ts index fa17b469a..42c21a24d 100644 --- a/frontend/src/types/response.ts +++ b/frontend/src/types/response.ts @@ -1,7 +1,7 @@ import type { Product } from './product'; import type { ProductRanking, RecipeRanking, ReviewRanking } from './ranking'; import type { Comment, MemberRecipe, Recipe } from './recipe'; -import type { Review } from './review'; +import type { Review, ReviewDetail } from './review'; import type { ProductSearchResult, ProductSearchAutocomplete } from './search'; export interface Page { @@ -63,7 +63,9 @@ export interface MemberRecipeResponse { page: Page; recipes: MemberRecipe[]; } - +export interface ReviewDetailResponse { + reviews: ReviewDetail; +} export interface CommentResponse { hasNext: boolean; totalElements: number | null; diff --git a/frontend/src/types/review.ts b/frontend/src/types/review.ts index debf6f65e..ced1d2f58 100644 --- a/frontend/src/types/review.ts +++ b/frontend/src/types/review.ts @@ -1,4 +1,4 @@ -import type { Tag, TagVariants } from './common'; +import type { CategoryVariant, Tag, TagVariants } from './common'; export interface Review { id: number; @@ -14,6 +14,12 @@ export interface Review { favorite: boolean; } +export interface ReviewDetail extends Review { + categoryType: CategoryVariant; + productId: number; + productName: string; +} + export interface ReviewTag { tagType: TagVariants; tags: Tag[]; diff --git a/frontend/src/types/search.ts b/frontend/src/types/search.ts index 75d5d816d..78f293f7f 100644 --- a/frontend/src/types/search.ts +++ b/frontend/src/types/search.ts @@ -1,7 +1,8 @@ +import type { CategoryVariant } from './common'; import type { Product } from './product'; export interface ProductSearchResult extends Product { - categoryType: string; + categoryType: CategoryVariant; } export type ProductSearchAutocomplete = Pick; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index da508d2b9..3cdae7d06 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1375,10 +1375,10 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9" integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A== -"@fun-eat/design-system@^0.3.13": - version "0.3.13" - resolved "https://registry.yarnpkg.com/@fun-eat/design-system/-/design-system-0.3.13.tgz#fbb48efff05c95883889dff280e118204de6d459" - integrity sha512-+wlTfWAJ3Z0ZmnJ2GyxX+HSQB8eB3g9PY8Blemv8nAk5ppuWbB9UKjnhebNgdtbtq+AN4HezKmbNl1Y+prxcWA== +"@fun-eat/design-system@^0.3.15": + version "0.3.15" + resolved "https://registry.yarnpkg.com/@fun-eat/design-system/-/design-system-0.3.15.tgz#61a9a01a82f84fa5627c49bd646cb72ca9e648c8" + integrity sha512-uhn5UZWfvQhNz/2sOoMwDr7Hj7SSx94bN35jifuYpm7ju0A8LHfivmu0mAbrMojuQ6XKYf0ZUME8FMMHwpw9Fg== "@humanwhocodes/config-array@^0.11.11": version "0.11.11" @@ -4497,6 +4497,13 @@ browser-assert@^1.2.1: resolved "https://registry.yarnpkg.com/browser-assert/-/browser-assert-1.2.1.tgz#9aaa5a2a8c74685c2ae05bfe46efd606f068c200" integrity sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ== +browser-image-compression@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/browser-image-compression/-/browser-image-compression-2.0.2.tgz#4d5ef8882e9e471d6d923715ceb9034499d14eaa" + integrity sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw== + dependencies: + uzip "0.20201231.0" + browserify-zlib@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d" @@ -11068,6 +11075,11 @@ uuid@^9.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== +uzip@0.20201231.0: + version "0.20201231.0" + resolved "https://registry.yarnpkg.com/uzip/-/uzip-0.20201231.0.tgz#9e64b065b9a8ebf26eb7583fe8e77e1d9a15ed14" + integrity sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng== + v8-to-istanbul@^9.0.0, v8-to-istanbul@^9.0.1: version "9.1.2" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.1.2.tgz#51168df21c8ca01c83285f27316549b2c51a5b46"