diff --git a/src/api/customApi.ts b/src/api/customApi.ts new file mode 100644 index 0000000..17c0f4a --- /dev/null +++ b/src/api/customApi.ts @@ -0,0 +1,106 @@ +import { initializeApp } from 'firebase/app'; +import { firebaseConfig } from './firebaseConfig'; +import { + getFirestore, + doc, + setDoc, + deleteDoc, + updateDoc, + increment, + getDoc, + collection, + getDocs, + Timestamp, + orderBy, + limit, + query, + arrayUnion, + arrayRemove, +} from 'firebase/firestore'; +import { RecipeDetail } from 'components/Accordion/Accordion.types'; + +initializeApp(firebaseConfig); + +const db = getFirestore(); + +interface Recipe { + recipeId: string; + title: string; + image: string; + creditsText: string; + tags: string[]; + readyInMinutes: number; + savedCount: number; + savedBy: string[]; + recipeDetails: RecipeDetail; +} + +export const saveRecipe = async (userId: string, recipeData: Recipe) => { + const myRecipesRef = doc(db, 'users', userId, 'my-recipes', recipeData.recipeId); + const savedRecipesRef = doc(db, 'savedRecipes', recipeData.recipeId); + const savedRecipesSnap = await getDoc(savedRecipesRef); + + await setDoc(myRecipesRef, { + id: recipeData.recipeId, + title: recipeData.title, + image: recipeData.image, + savedAt: Timestamp.fromDate(new Date()), + }); + + if (savedRecipesSnap.exists()) { + await updateDoc(savedRecipesRef, { + savedCount: increment(1), + savedBy: arrayUnion(userId), + }); + } else { + await setDoc(savedRecipesRef, { ...recipeData, savedCount: 1, savedBy: [userId] }); + } +}; + +export const removeRecipe = async (userId: string, recipeId: string) => { + const myRecipesRef = doc(db, 'users', userId, 'my-recipes', recipeId); + const savedRecipesRef = doc(db, 'savedRecipes', recipeId); + const savedRecipesSnap = await getDoc(savedRecipesRef); + + await deleteDoc(myRecipesRef); + + if (savedRecipesSnap.exists()) { + await updateDoc(savedRecipesRef, { + savedCount: increment(-1), + savedBy: arrayRemove(userId), + }); + } +}; + +export const getMyRecipes = async (userId: string) => { + const myRecipesRef = collection(db, 'users', userId, 'my-recipes'); + const q = query(myRecipesRef, orderBy('savedAt', 'desc')); + const myRecipesSnapShot = await getDocs(q); + const myRecipes = []; + myRecipesSnapShot.forEach((doc) => { + myRecipes.push(doc.data()); + }); + return myRecipes; +}; + +export const getHotRecipes = async (num = 6) => { + const hotRecipesRef = collection(db, 'savedRecipes'); + const q = query(hotRecipesRef, orderBy('savedCount', 'desc'), limit(num)); + const hotRecipesSnapshot = await getDocs(q); + const hotRecipes = []; + hotRecipesSnapshot.forEach((doc) => { + hotRecipes.push(doc.data()); + }); + return hotRecipes; +}; + +export const getSavedRecipe = async (recipeId: string) => { + const savedRecipeRef = doc(db, 'savedRecipes', recipeId); + const savedRecipeSnap = await getDoc(savedRecipeRef); + + if (savedRecipeSnap.exists()) { + return savedRecipeSnap.data(); + } else { + return null; + } +}; diff --git a/src/components/Accordion/Accordion.styled.tsx b/src/components/Accordion/Accordion.styled.tsx index 0100c9c..8252fcf 100644 --- a/src/components/Accordion/Accordion.styled.tsx +++ b/src/components/Accordion/Accordion.styled.tsx @@ -5,7 +5,7 @@ import { pxToRem, media } from 'utils'; const collapseContent = (props: { theme: Theme }) => css` font-size: ${pxToRem(18)}; - color: ${props.theme.color.gray200}; + color: ${props.theme.color.gray500}; padding: 0.5rem 0; line-height: 1.3; `; @@ -13,7 +13,7 @@ const collapseContent = (props: { theme: Theme }) => export const StyledCollapseHeading = styled.div` display: flex; justify-content: space-between; - border-bottom: 1px solid ${({ theme }) => theme.color.white}; + border-bottom: 1px solid ${({ theme }) => theme.color.gray500}; align-content: center; svg { @@ -100,6 +100,6 @@ export const StyledAccordion = styled.ul` } display: block; - color: ${({ theme }) => theme.color.white}; + color: ${({ theme }) => theme.color.gray500}; margin: 0; `; diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx index 6dd656b..ddb73b6 100644 --- a/src/components/Badge/Badge.tsx +++ b/src/components/Badge/Badge.tsx @@ -69,6 +69,7 @@ const BADGE: BadgeInfos = { }; export const Badge = ({ iconType, size }: BadgeProps) => { + if (!BADGE[iconType]) return null; return ( {BADGE[iconType].icon} diff --git a/src/components/BadgeList/BadgeList.stories.tsx b/src/components/BadgeList/BadgeList.stories.tsx new file mode 100644 index 0000000..2e1d760 --- /dev/null +++ b/src/components/BadgeList/BadgeList.stories.tsx @@ -0,0 +1,17 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { BadgeList } from './BadgeList'; + +export default { + title: 'BadgeList', + component: BadgeList, + args: { + tags: [ + 'glutenFree', + 'healthy', + ], + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +export const Default = Template.bind({}); diff --git a/src/components/BadgeList/BadgeList.tsx b/src/components/BadgeList/BadgeList.tsx new file mode 100644 index 0000000..c82af22 --- /dev/null +++ b/src/components/BadgeList/BadgeList.tsx @@ -0,0 +1,15 @@ +import { StyledUl } from './BadgeList.styled'; +import { BadgeListProps } from './BadgeList.types'; +import { Badge } from 'components'; + +export const BadgeList = ({ tags }: BadgeListProps) => { + return ( + + {tags.map((tag, index) => ( +
  • + +
  • + ))} +
    + ); +}; diff --git a/src/components/CookingInfo/CookingInfo.styled.tsx b/src/components/CookingInfo/CookingInfo.styled.tsx index 4f78907..2b9cbc6 100644 --- a/src/components/CookingInfo/CookingInfo.styled.tsx +++ b/src/components/CookingInfo/CookingInfo.styled.tsx @@ -2,9 +2,9 @@ import styled from '@emotion/styled'; import { BsBookmarkHeartFill } from 'react-icons/bs'; import { RiTimerFill } from 'react-icons/ri'; import { pxToRem } from 'utils'; - + export const StyledDL = styled.dl` - color: #cbcbcb; + color: ${({ theme }) => theme.color.gray500}; font-size: ${pxToRem(18)}; padding: ${pxToRem(10)} 0; width: 100%; diff --git a/src/components/Detail/Detail.stories.tsx b/src/components/Detail/Detail.stories.tsx new file mode 100644 index 0000000..b4eb953 --- /dev/null +++ b/src/components/Detail/Detail.stories.tsx @@ -0,0 +1,11 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Detail } from './Detail'; + +export default { + title: 'Detail', + component: Detail, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +export const Default = Template.bind({}); diff --git a/src/components/Detail/Detail.styled.tsx b/src/components/Detail/Detail.styled.tsx new file mode 100644 index 0000000..95c177c --- /dev/null +++ b/src/components/Detail/Detail.styled.tsx @@ -0,0 +1,51 @@ +import styled from '@emotion/styled'; +import { IconButton } from 'components'; +import { media } from 'utils'; + +export const StyledArticle = styled.article` + ${media.desktop} { + display: flex; + gap: 3%; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + padding: 20px 50px 25px 50px; + } + ${media.mobile} { + padding: 20px 30px 25px 30px; + } +`; + +export const StyledHeader = styled.header` + width: 100%; + margin-bottom: 10px; +`; + +export const StyledUl = styled.ul` + display: flex; + gap: 5px; + margin: 5px 0 0 0; +`; + +export const StyledIconButton = styled(IconButton)` + &:hover { + background: ${({ theme }) => theme.color.hoverGray}; + } +`; + +export const StyledDiv = styled.div` + ${media.desktop} { + width: 45%; + } +`; + +export const StyledFigure = styled.figure` + margin: 0 0 30px; + position: relative; +`; + +export const StyledImg = styled.img` + width: 100%; + height: 40vh; + object-fit: cover; +`; diff --git a/src/components/Detail/Detail.tsx b/src/components/Detail/Detail.tsx new file mode 100644 index 0000000..8f92192 --- /dev/null +++ b/src/components/Detail/Detail.tsx @@ -0,0 +1,130 @@ +import { + StyledArticle, + StyledHeader, + StyledUl, + StyledIconButton, + StyledDiv, + StyledFigure, + StyledImg, +} from './Detail.styled'; +import { DetailProps } from './Detail.types'; +import { useEffect, useState } from 'react'; +import { Heading, BadgeList, CookingInfo, Toast, AuthContainer } from 'components'; +import { useToast, useDialog } from 'hooks'; +import { useSelector } from 'react-redux'; +import { RootState } from 'store'; +import { AuthState } from 'store/slices/auth'; +import { removeRecipe, saveRecipe } from 'api/customApi'; +import { Accordion } from 'components'; + +export const Detail = (recipeData: DetailProps) => { + const { title, image, creditsText, tags, readyInMinutes, savedCount, savedBy, recipeId, recipeDetails } = recipeData; + + const { showDialog, handleOpenDialog, handleCloseDialog } = useDialog(); + const { showToast: showSignInToast, displayToast: displaySignInToast } = useToast(2000); + const { showToast, displayToast } = useToast(700); + const { authUser } = useSelector((state) => state.auth); + + const [isBookmarked, setIsBookmarked] = useState(false); + const [isSaved, setIsSaved] = useState(false); + const [countBeDisplayed, setCountBeDisplayed] = useState(0); + + useEffect(() => { + setCountBeDisplayed(savedCount); + + if (authUser && savedBy) { + setIsSaved(savedBy.includes(authUser)); + setIsBookmarked(savedBy.includes(authUser)); + } else { + setIsSaved(false); + setIsBookmarked(false); + } + }, [authUser]); + + useEffect(() => { + const id = setTimeout(() => { + if (authUser && isBookmarked !== isSaved) { + setIsSaved(isBookmarked); + if (isBookmarked) { + saveRecipe(authUser, recipeData); + } else { + removeRecipe(authUser, recipeId); + } + } + }, 300); + + return () => { + clearTimeout(id); + }; + }, [isBookmarked]); + + const copyPageUrl = async () => { + try { + await navigator.clipboard.writeText(location.href); + displayToast(); + } catch (err) { + console.error('Failed to copy: ', err); + } + }; + + const handleClick = () => { + if (!authUser) { + handleOpenDialog(); + return; + } + setIsBookmarked(!isBookmarked); + isBookmarked ? setCountBeDisplayed(countBeDisplayed - 1) : setCountBeDisplayed(countBeDisplayed + 1); + }; + + return ( + + + {title} + + button list + + +
  • + + {showToast && } +
  • +
  • + +
  • +
    +
    + + + +
    {creditsText}
    +
    + {tags && tags.length ? : null} + +
    + + {showDialog && } + {showSignInToast && } +
    + ); +}; diff --git a/src/components/Detail/Detail.types.ts b/src/components/Detail/Detail.types.ts new file mode 100644 index 0000000..fdcfe94 --- /dev/null +++ b/src/components/Detail/Detail.types.ts @@ -0,0 +1,10 @@ +import { IconType } from 'components/Badge/Badge.types'; + +export interface DetailProps { + title: string; + image: string; + creditsText: string; + tags: IconType[]; + readyInMinutes: number; + savedCount: number; +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 911cf60..7a6826d 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -1,5 +1,5 @@ import { AuthContainer, Button, Logo, Menu, SearchForm, Toast } from 'components'; -import { useToast } from 'hooks'; +import { useToast, useDialog } from 'hooks'; import _ from 'lodash'; import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -11,7 +11,7 @@ import { getAuthStatus } from 'api/requestAuth'; import { StyledDiv, StyledHeader, StyledIconButton } from './Header.styled'; export const Header = (): JSX.Element => { - const [showDialog, setShowDialog] = useState(false); + const { showDialog, handleOpenDialog, handleCloseDialog } = useDialog(); const { showToast: showSignInToast, displayToast: displaySignInToast } = useToast(2000); const { showToast: showSignOutToast, displayToast: displaySignOutToast } = useToast(2000); const [hideHeader, setHideHeader] = useState(false); @@ -29,14 +29,6 @@ export const Header = (): JSX.Element => { })(); }, []); - const handleOpenDialog = () => { - setShowDialog(true); - }; - - const handleCloseDialog = () => { - setShowDialog(false); - }; - const handleFocus = () => { setHideHeader(false); }; diff --git a/src/components/Toast/Toast.styled.tsx b/src/components/Toast/Toast.styled.tsx index 8b00c4c..324b599 100644 --- a/src/components/Toast/Toast.styled.tsx +++ b/src/components/Toast/Toast.styled.tsx @@ -1,4 +1,3 @@ -import { css } from '@emotion/react'; import styled from '@emotion/styled'; export const StyledP = styled.p` diff --git a/src/components/index.ts b/src/components/index.ts index 109beb9..92e0df9 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,7 +1,9 @@ export * from './Badge/Badge'; +export * from './BadgeList/BadgeList'; export * from './Button/Button'; export * from './Button/IconButton'; export * from './CookingInfo/CookingInfo'; +export * from './Detail/Detail'; export * from './Logo/Logo'; export * from './Loading/Loading'; export * from './Heading/Heading'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 33f1079..775be25 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,4 +1,5 @@ export * from './usePageNum'; export * from './useRandomRecipe'; export * from './useHotRecipes'; -export * from './useToast'; \ No newline at end of file +export * from './useToast'; +export * from './useDialog'; diff --git a/src/hooks/useDialog.ts b/src/hooks/useDialog.ts new file mode 100644 index 0000000..523e5a9 --- /dev/null +++ b/src/hooks/useDialog.ts @@ -0,0 +1,15 @@ +import { useState, useEffect, useCallback } from 'react'; + +export const useDialog = () => { + const [showDialog, setShowDialog] = useState(false); + + const handleOpenDialog = () => { + setShowDialog(true); + }; + + const handleCloseDialog = () => { + setShowDialog(false); + }; + + return { showDialog, handleOpenDialog, handleCloseDialog }; +}; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 4a874c8..0ba09c7 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,18 +1,6 @@ import type { NextPage } from 'next'; -import { useGetRandomRecipeQuery } from 'store/services'; -import Head from 'next/head'; -import Image from 'next/image'; const Home: NextPage = () => { - const { - data: { recipes }, - error, - isLoading, - } = useGetRandomRecipeQuery(1); - console.log(recipes); - console.log(error); - console.log(isLoading); - return
    Home
    ; }; diff --git a/src/pages/recipe/[id].tsx b/src/pages/recipe/[id].tsx new file mode 100644 index 0000000..793b2c8 --- /dev/null +++ b/src/pages/recipe/[id].tsx @@ -0,0 +1,88 @@ +import { getSavedRecipe } from 'api/customApi'; +import { excludeTags, camelCase } from 'utils/misc'; +import { Detail } from 'components'; + +// TODO: 타입 정의 + +const Recipe = ({ recipeData }) => { + const { title, image, creditsText, tags, readyInMinutes, savedCount, savedBy, recipeId, recipeDetails } = recipeData; + return ( + + ); +}; + +export default Recipe; + +export async function getServerSideProps(context) { + const { id } = context.query; + + let recipeData = await getSavedRecipe(id); + + if (!recipeData) { + recipeData = await getRecipeById(id); + recipeData.recipeDetails = [ + { + type: 'ingredients', + data: recipeData.extendedIngredients.map((ingredient) => ({ + name: ingredient.nameClean, + amount: ingredient.amount, + unit: ingredient.measures.metric.unitShort, + })), + }, + { + type: 'equipment', + data: [ + ...new Set( + recipeData.analyzedInstructions[0]?.steps?.flatMap((step) => + step.equipment?.flatMap((equip) => equip.name), + ), + ), + ], + }, + { type: 'summary', data: excludeTags(recipeData.summary) }, + { + type: 'instructions', + data: recipeData.analyzedInstructions[0]?.steps?.map((step) => step.step), + }, + ]; + recipeData.savedCount = 0; + recipeData.tags = [...recipeData.diets.filter((diet) => diet !== 'fodmap friendly')]; + + if (recipeData.veryPopular) recipeData.tags = [...recipeData.tags, 'popular']; + if (recipeData.veryHealthy) recipeData.tags = [...recipeData.tags, 'healthy']; + } + + recipeData.tags = recipeData.tags.map((str: string) => camelCase(str)); + recipeData.recipeId = id; + + return { + props: { recipeData }, + }; +} + +const getRecipeById = async (id) => { + try { + const data = await fetch(`https://spoonacular-recipe-food-nutrition-v1.p.rapidapi.com/recipes/${id}/information`, { + headers: { + 'content-type': 'application/json', + 'x-rapidapi-host': process.env.NEXT_PUBLIC_RAPID_API_HOST, + 'x-rapidapi-key': process.env.NEXT_PUBLIC_RAPID_API_KEY, + }, + params: { includeNutrition: 'true' }, + }).then((res) => res.json()); + + return data; + } catch (e) { + throw new Error(e.message); + } +}; diff --git a/src/theme/emotion.d.ts b/src/theme/emotion.d.ts index d8bfa95..0c68a19 100644 --- a/src/theme/emotion.d.ts +++ b/src/theme/emotion.d.ts @@ -16,6 +16,7 @@ declare module '@emotion/react' { backgroundGray: string; searchGray: string; menuBg: string; + hoverGray: string; gray100: string; gray200: string; diff --git a/src/theme/theme.ts b/src/theme/theme.ts index 5e6ee0f..0818155 100644 --- a/src/theme/theme.ts +++ b/src/theme/theme.ts @@ -16,6 +16,7 @@ export const theme: Theme = { backgroundGray: '#fafafa', searchGray: '#ebebeb', menuBg: '#252525', + hoverGray: '#f1f3f4', gray100: '#d8d8d8', gray200: '#b0b0b0', diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 4eb074a..18421ba 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -1,3 +1,4 @@ export const excludeTags = (content: string) => content.replace(/<[^>]*>/g, ''); -export const camelCase = (data: string) => data.replace(/\s\w/g, (match) => match.toUpperCase().trim()); +export const camelCase = (data: string): T => + data.replace(/\s\w/g, (match) => match.toUpperCase().trim()) as unknown as T;