Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
106 changes: 106 additions & 0 deletions src/api/customApi.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
6 changes: 3 additions & 3 deletions src/components/Accordion/Accordion.styled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ 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;
`;

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 {
Expand Down Expand Up @@ -100,6 +100,6 @@ export const StyledAccordion = styled.ul`
}

display: block;
color: ${({ theme }) => theme.color.white};
color: ${({ theme }) => theme.color.gray500};
margin: 0;
`;
1 change: 1 addition & 0 deletions src/components/Badge/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const BADGE: BadgeInfos = {
};

export const Badge = ({ iconType, size }: BadgeProps) => {
if (!BADGE[iconType]) return null;
return (
<StyledBadge $color={BADGE[iconType].color} $size={size}>
{BADGE[iconType].icon}
Expand Down
17 changes: 17 additions & 0 deletions src/components/BadgeList/BadgeList.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof BadgeList>;

const Template: ComponentStory<typeof BadgeList> = (args) => <BadgeList {...args} />;

export const Default = Template.bind({});
15 changes: 15 additions & 0 deletions src/components/BadgeList/BadgeList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { StyledUl } from './BadgeList.styled';
import { BadgeListProps } from './BadgeList.types';
import { Badge } from 'components';

export const BadgeList = ({ tags }: BadgeListProps) => {
return (
<StyledUl>
{tags.map((tag, index) => (
<li key={tag + index}>
<Badge iconType={tag} size="small" />
</li>
))}
</StyledUl>
);
};
4 changes: 2 additions & 2 deletions src/components/CookingInfo/CookingInfo.styled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
11 changes: 11 additions & 0 deletions src/components/Detail/Detail.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { Detail } from './Detail';

export default {
title: 'Detail',
component: Detail,
} as ComponentMeta<typeof Detail>;

const Template: ComponentStory<typeof Detail> = (args) => <Detail {...args} />;

export const Default = Template.bind({});
51 changes: 51 additions & 0 deletions src/components/Detail/Detail.styled.tsx
Original file line number Diff line number Diff line change
@@ -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;
`;
130 changes: 130 additions & 0 deletions src/components/Detail/Detail.tsx
Original file line number Diff line number Diff line change
@@ -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<RootState, AuthState>((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 (
<StyledArticle>
<StyledHeader>
<Heading as="h2">{title}</Heading>
<Heading as="h3" className="a11yHidden">
button list
</Heading>
<StyledUl>
<li>
<StyledIconButton
variant="transparent"
type="button"
iconType="link"
ariaLabel="copy link"
color="black"
size="large"
circle
onClick={copyPageUrl}
/>
{showToast && <Toast message="Copied" />}
</li>
<li>
<StyledIconButton
variant="transparent"
type="button"
iconType={isBookmarked ? 'bookmarkFill' : 'bookmark'}
ariaLabel="save to my recipes"
color={isBookmarked ? 'primaryOrange' : 'black'}
size="large"
circle
onClick={handleClick}
/>
</li>
</StyledUl>
</StyledHeader>
<StyledDiv>
<StyledFigure>
<StyledImg
src={image && !/^(https)/.test(image) ? 'https://spoonacular.com/recipeImages/' + image : image}
alt={title}
/>
<figcaption>{creditsText}</figcaption>
</StyledFigure>
{tags && tags.length ? <BadgeList tags={tags} /> : null}
<CookingInfo time={readyInMinutes || 0} count={countBeDisplayed || 0} />
</StyledDiv>
<Accordion recipeDetails={recipeDetails} />
{showDialog && <AuthContainer onClose={handleCloseDialog} onSignIn={displaySignInToast} />}
{showSignInToast && <Toast message="Signed in successfully!" />}
</StyledArticle>
);
};
10 changes: 10 additions & 0 deletions src/components/Detail/Detail.types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading