Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: 기초 recipe item 컴포넌트 구현 #83

Merged
merged 17 commits into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const MemberRecipeList = ({ isPreview = false }: MemberRecipeListProps) => {
{recipeToDisplay?.map((recipe) => (
<li key={recipe.id}>
<Link as={RouterLink} to={`${PATH.RECIPE}/${recipe.id}`}>
<RecipeItem recipe={recipe} isMemberPage={isPreview} />
{/* <RecipeItem recipe={recipe} isMemberPage={isPreview} /> */}
</Link>
</li>
))}
Expand Down
80 changes: 76 additions & 4 deletions src/components/Recipe/RecipeItem/RecipeItem.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,89 @@ import type { Meta, StoryObj } from '@storybook/react';

import RecipeItem from './RecipeItem';

import RecipeItemProvider from '@/contexts/RecipeItemContext';
import mockRecipe from '@/mocks/data/recipes.json';

const meta: Meta<typeof RecipeItem> = {
title: 'recipe/RecipeItem',
component: RecipeItem,
args: {
recipe: mockRecipe.recipes[0],
},
decorators: [
(Story) => (
<RecipeItemProvider recipe={mockRecipe.recipes[0]}>
<Story />
</RecipeItemProvider>
),
],
};

export default meta;
type Story = StoryObj<typeof RecipeItem>;

export const Default: Story = {};
export const Default: Story = {
render: (args) => {
return (
<RecipeItem {...args}>
<RecipeItem.ImageAndFavoriteButton />
<div style={{ height: '8px' }} />
<RecipeItem.Title />
<div style={{ display: 'flex', gap: '0.3rem' }}>
<RecipeItem.Author />
<RecipeItem.CreatedDate />
</div>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인라인 이런 말하는건가요?! 저는 얘네는 큰 컴포넌트로 묶어도 될 것 같아요!
그리고 Flex, Grid 컴포넌트 만들면 더 깔끔해지겠네요

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋슴니다 따로 묶어서 구현했습니다!
나중에 화면 작업 끝나면 바로 Flex랑 Grid랑 Heading 같은 애들 만듭시다~~

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flex, Grid, Heading 너무 필요..

</RecipeItem>
);
},
};

export const Recipe: Story = {
render: (args) => {
return (
<RecipeItem {...args}>
<RecipeItem.ImageAndFavoriteButton>
<RecipeItem.ProductButton />
</RecipeItem.ImageAndFavoriteButton>
<div style={{ height: '8px' }} />
<RecipeItem.Title />
<div style={{ display: 'flex', gap: '0.3rem' }}>
<RecipeItem.Author />
<RecipeItem.CreatedDate />
</div>
<RecipeItem.Content />
</RecipeItem>
);
},
};

export const MyPage: Story = {
render: (args) => {
return (
<RecipeItem {...args}>
<RecipeItem.ImageAndFavoriteButton>
<RecipeItem.ProductCircleButton />
</RecipeItem.ImageAndFavoriteButton>
<div style={{ height: '8px' }} />
<RecipeItem.Title />
<RecipeItem.Author />
<RecipeItem.Content />
</RecipeItem>
);
},
};

export const Search: Story = {
render: (args) => {
return (
<RecipeItem {...args}>
<RecipeItem.ImageAndFavoriteButton>
<RecipeItem.ProductButton />
</RecipeItem.ImageAndFavoriteButton>
<div style={{ height: '8px' }} />
<RecipeItem.Title />
<div style={{ display: 'flex', gap: '0.3rem' }}>
<RecipeItem.Author />
<RecipeItem.CreatedDate />
</div>
</RecipeItem>
);
},
};
164 changes: 124 additions & 40 deletions src/components/Recipe/RecipeItem/RecipeItem.tsx
Original file line number Diff line number Diff line change
@@ -1,69 +1,81 @@
import { BottomSheet, Skeleton, useBottomSheet } from '@fun-eat/design-system';
import type { MouseEventHandler } from 'react';
import { memo, useState } from 'react';
import type { MouseEventHandler, PropsWithChildren } from 'react';
import { useState } from 'react';
import { Link } from 'react-router-dom';

import {
ellipsis,
favoriteButtonWrapper,
imageWrapper,
productButtonWrapper,
recipeAuthor,
recipeContent,
productCircleListWrapper,
productCircleWrapper,
productImage,
recipeImage,
recipeProductWrapper,
recipeTitle,
recipeProductsCount,
thirdProductImage,
} from './recipeItem.css';
import RecipeFavoriteButton from '../RecipeFavoriteButton/RecipeFavoriteButton';
import RecipeProductButton from '../RecipeProductButton/RecipeProductButton';

import { Text } from '@/components/Common';
import { ProductOverviewList } from '@/components/Product';
import { RECIPE_CARD_DEFAULT_IMAGE_URL } from '@/constants/image';
import type { MemberRecipe, Recipe } from '@/types/recipe';
import RecipeItemProvider from '@/contexts/RecipeItemContext';
import { useRecipeItemValueContext } from '@/hooks/context';
import { getRelativeDate } from '@/utils/date';
import displaySlice from '@/utils/displaySlice';

interface RecipeItemProps {
recipe: Recipe | MemberRecipe;
isMemberPage?: boolean;
}
const RecipeItem = ({ children }: PropsWithChildren) => {
const { recipe } = useRecipeItemValueContext();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const RecipeItem = ({ children }: PropsWithChildren) => {
const { recipe } = useRecipeItemValueContext();
const RecipeItem = ({ recipe, children }: PropsWithChildren) => {

const { id } = recipe;

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;
return (
<RecipeItemProvider recipe={recipe}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서 아래로 내려주니까

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞네...여기서 감싸주고 있었구나...

<Link to={`${id}`}>{children}</Link>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<Link to={`${id}`}>{children}</Link>
<Link to={id}}>{children}</Link>

그냥 이렇게 적어도 될듯!!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to가 string만 받을 수 있나봐요..!
'number' 형식은 'To' 형식에 할당할 수 없습니다.ts(2322)
요렇게 에러가 뜨네용?!?!

</RecipeItemProvider>
);
};

const ImageAndFavoriteButton = ({ children }: PropsWithChildren) => {
const { recipe } = useRecipeItemValueContext();
const { id, image, title, favorite } = recipe;
const [isImageLoading, setIsImageLoading] = useState(true);

return (
<div className={imageWrapper}>
<img
className={recipeImage}
src={image ?? RECIPE_CARD_DEFAULT_IMAGE_URL}
alt={`조리된 ${title}`}
loading="lazy"
onLoad={() => image && setIsImageLoading(false)}
/>
{isImageLoading && image && <Skeleton width={163} height={200} />}
<div className={favoriteButtonWrapper} onClick={(e) => e.preventDefault()}>
<RecipeFavoriteButton recipeId={id} favorite={favorite} />
</div>
{children}
</div>
);
};

const ProductButton = () => {
const { recipe } = useRecipeItemValueContext();
const { products } = recipe;
const { isOpen, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet();

const handleOpenProductSheet: MouseEventHandler<HTMLDivElement> = (event) => {
event.preventDefault();
handleOpenBottomSheet();
};

return (
<>
<Link to={`${id}`}>
{!isMemberPage && (
<div className={imageWrapper}>
<img
className={recipeImage}
src={image ?? RECIPE_CARD_DEFAULT_IMAGE_URL}
alt={`조리된 ${title}`}
loading="lazy"
onLoad={() => image && setIsImageLoading(false)}
/>
{isImageLoading && image && <Skeleton width={163} height={200} />}
<div className={favoriteButtonWrapper} onClick={(e) => e.preventDefault()}>
<RecipeFavoriteButton recipeId={id} favorite={favorite} />
</div>
<div className={productButtonWrapper} onClick={(e) => handleOpenProductSheet(e)}>
<RecipeProductButton isTranslucent />
</div>
</div>
)}
<div style={{ height: '8px' }} />
<p className={recipeTitle}>{title}</p>
<p className={recipeAuthor}>{author && `${author.nickname} 님`}</p>
<p className={recipeContent}>{content}</p>
</Link>
<div className={productButtonWrapper} onClick={(e) => handleOpenProductSheet(e)}>
<RecipeProductButton isTranslucent />
</div>

<BottomSheet isOpen={isOpen} isClosing={isClosing} maxWidth="400px" close={handleCloseBottomSheet}>
<div className={recipeProductWrapper}>
Expand All @@ -74,4 +86,76 @@ const RecipeItem = ({ recipe, isMemberPage = false }: RecipeItemProps) => {
);
};

export default memo(RecipeItem);
const ProductCircleButton = () => {
const { recipe } = useRecipeItemValueContext();
const { products } = recipe;

return (
<ul className={productCircleWrapper}>
{displaySlice(true, products, 3).map(({ id, image }, idx) => (
<li key={id} className={productCircleListWrapper}>
<img
src={image}
alt="사용한 상품"
className={products.length > 3 && idx === 2 ? thirdProductImage : productImage}
/>
{idx === 2 && (
<Text size="caption3" weight="medium" className={recipeProductsCount}>
+{products.length - 3}
</Text>
)}
</li>
))}
</ul>
);
};

const Title = () => {
const { recipe } = useRecipeItemValueContext();
const { title } = recipe;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const { recipe } = useRecipeItemValueContext();
const { title } = recipe;
const { recipe: { title } } = useRecipeItemValueContext();

너무 반복된다면 이렇게 할 수 있었던거 같은데 잘 기억이 안 나네요 ㅎㅎ


return (
<Text className={ellipsis} size="caption1" weight="semiBold" color="default">
{title}
</Text>
);
};

const Author = () => {
const { recipe } = useRecipeItemValueContext();
const { author } = recipe;

return <Text size="caption3" color="sub">{`${author.nickname} 님`}</Text>;
};

const CreatedDate = () => {
const { recipe } = useRecipeItemValueContext();
const { createdAt } = recipe;

return (
<Text size="caption3" color="sub">
· {getRelativeDate(createdAt)}
</Text>
);
};

const Content = () => {
const { recipe } = useRecipeItemValueContext();
const { content } = recipe;

return (
<Text className={ellipsis} size="caption4" color="disabled">
{content}
</Text>
);
};

RecipeItem.ImageAndFavoriteButton = ImageAndFavoriteButton;
RecipeItem.ProductButton = ProductButton;
RecipeItem.ProductCircleButton = ProductCircleButton;
RecipeItem.Title = Title;
RecipeItem.Author = Author;
RecipeItem.CreatedDate = CreatedDate;
RecipeItem.Content = Content;

export default RecipeItem;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

얘는 외부에서 사용하지 않고 아래 친구들만 사용하는거죠?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

얍얍 그렇슴다!

57 changes: 40 additions & 17 deletions src/components/Recipe/RecipeItem/recipeItem.css.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { vars } from '@/styles/theme.css';
import { style } from '@vanilla-extract/css';

export const imageWrapper = style({
Expand Down Expand Up @@ -25,28 +26,50 @@ export const productButtonWrapper = style({
left: 8,
});

export const recipeTitle = style({
color: '#232527',
fontSize: 14,
fontWeight: 600,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
export const recipeProductWrapper = style({
margin: '48px 20px',
});

export const recipeAuthor = style({
color: '#3D3D3D',
fontSize: 11,
export const productCircleWrapper = style({
position: 'absolute',
bottom: 10,
left: 10,
display: 'flex',
});

export const recipeContent = style({
color: '#808080',
fontSize: 11,
export const productCircleListWrapper = style({
position: 'relative',
width: '100%',
height: '40px',
marginRight: '-10px',
borderRadius: '50%',
border: `2px solid ${vars.colors.border.light}`,
boxSizing: 'content-box',
});

export const productImage = style({
width: '40px',
height: '40px',
borderRadius: '50%',
});

export const thirdProductImage = style({
width: '40px',
height: '40px',
borderRadius: '50%',
filter: 'brightness(50%)',
});

export const recipeProductsCount = style({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate( -50%, -50% )',
color: vars.colors.white,
});

export const ellipsis = style({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});

export const recipeProductWrapper = style({
margin: '48px 20px',
});
5 changes: 1 addition & 4 deletions src/components/Recipe/RecipeList/RecipeList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useRef } from 'react';

import { container } from './recipeList.css';
import RecipeItem from '../RecipeItem/RecipeItem';

import { useIntersectionObserver } from '@/hooks/common';
import { useInfiniteRecipesQuery } from '@/hooks/queries/recipe';
Expand All @@ -26,9 +25,7 @@ const RecipeList = ({ selectedOption }: RecipeListProps) => {
<>
<ul className={container}>
{recipes.map((recipe) => (
<li key={recipe.id}>
<RecipeItem recipe={recipe} />
</li>
<li key={recipe.id}>{/* <RecipeItem recipe={recipe} /> */}</li>
))}
</ul>
<div ref={scrollRef} aria-hidden style={{ height: '1px' }} />
Expand Down
Loading
Loading