diff --git a/src/App.tsx b/src/App.tsx index 38170254..54b1cebd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import PageNotFound from 'page/Error/PageNotFound'; import ModifyMenu from 'page/ModifyMenu'; import { Suspense } from 'react'; import Toast from 'component/common/Toast'; +import Coop from 'page/Coop'; function App() { return ( @@ -28,6 +29,7 @@ function App() { } /> } /> } /> + } /> }> } /> diff --git a/src/api/coop/index.ts b/src/api/coop/index.ts new file mode 100644 index 00000000..4791562f --- /dev/null +++ b/src/api/coop/index.ts @@ -0,0 +1,15 @@ +import { accessClient } from 'api'; +import { DiningImages, SoldOut } from 'model/Coop'; + +export const getDining = async () => { + const { data } = await accessClient.get('/dinings'); + return data; +}; + +export const uploadDiningImage = async (data: DiningImages) => { + await accessClient.patch('/coop/dining/image', data); +}; + +export const updateSoldOut = async (data: SoldOut) => { + await accessClient.patch('/coop/dining/soldout', data); +}; diff --git a/src/assets/svg/coop/photo.svg b/src/assets/svg/coop/photo.svg new file mode 100644 index 00000000..072b2982 --- /dev/null +++ b/src/assets/svg/coop/photo.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/component/common/Header/index.tsx b/src/component/common/Header/index.tsx index ba553a3a..e98a1d5f 100644 --- a/src/component/common/Header/index.tsx +++ b/src/component/common/Header/index.tsx @@ -89,7 +89,7 @@ function Header() { )} - {pathname === '/' ? ( + {pathname === '/' || pathname === '/coop' ? ( ) : (CATEGORY .flatMap((categoryValue) => categoryValue.submenu) diff --git a/src/model/Coop/index.ts b/src/model/Coop/index.ts new file mode 100644 index 00000000..cab6b90d --- /dev/null +++ b/src/model/Coop/index.ts @@ -0,0 +1,41 @@ +import z from 'zod'; + +export type Menus = '아침' | '점심' | '저녁'; + +export type DiningTypes = 'BREAKFAST' | 'LUNCH' | 'DINNER'; + +export const DINING_TYPES: Record = { + 아침: 'BREAKFAST', + 점심: 'LUNCH', + 저녁: 'DINNER', +}; + +export const Dinings = z.object({ + date: z.string(), + id: z.number(), + kcal: z.number(), + menu: z.array(z.string()), + place: z.string(), + price_card: z.number(), + price_cash: z.number(), + type: z.string(), + updated_at: z.string(), + sold_out: z.boolean(), + is_changed: z.boolean(), +}); + +export type Dinings = z.infer; + +export const DiningImages = z.object({ + menuId: z.number(), + imageUrl: z.string(), +}); + +export type DiningImages = z.infer; + +export const SoldOut = z.object({ + menuId: z.number(), + soldOut: z.boolean(), +}); + +export type SoldOut = z.infer; diff --git a/src/page/Coop/Coop.module.scss b/src/page/Coop/Coop.module.scss new file mode 100644 index 00000000..32e9f901 --- /dev/null +++ b/src/page/Coop/Coop.module.scss @@ -0,0 +1,49 @@ +.container { + display: flex; + flex-direction: column; + background-color: #f5f5f5; + width: 390px; + min-height: 100vh; + height: 100%; +} + +.container-wrapper { + display: flex; + justify-content: center; + align-items: center; +} + +.place { + &__container { + display: flex; + align-items: center; + gap: 8px; + margin-top: 24px; + margin-left: 24px; + } + + &__button--selected { + width: 60px; + height: 30px; + background-color: #175c8e; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + border-radius: 999px; + cursor: pointer; + } + + &__button--unselected { + width: 60px; + height: 30px; + background-color: #fff; + color: #175c8e; + display: flex; + align-items: center; + justify-content: center; + border: solid 1px #175c8e; + border-radius: 999px; + cursor: pointer; + } +} diff --git a/src/page/Coop/components/MenuCard/MenuCard.module.scss b/src/page/Coop/components/MenuCard/MenuCard.module.scss new file mode 100644 index 00000000..561bd48c --- /dev/null +++ b/src/page/Coop/components/MenuCard/MenuCard.module.scss @@ -0,0 +1,69 @@ +.container { + display: flex; + flex-direction: column; +} + +.card { + display: flex; + flex-direction: column; + background-color: #fff; + margin: 10px 20px; + border-radius: 4px; + box-shadow: 0 1px 9px 1px rgb(0 0 0 / 6%); + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #e0e0e0; + padding: 10px; + } + + &__title { + font-size: 18px; + font-weight: 500; + } + + &__content { + display: flex; + flex-direction: column; + font-size: 12px; + font-weight: 400; + line-height: 15px; + width: 150px; + } + + &__image { + width: 150px; + height: 100px; + flex-shrink: 0; + border-radius: 4px; + border: 1px solid #cacaca; + background: #fafafa; + display: flex; + align-items: center; + justify-content: center; + object-fit: scale-down; + } + + &__soldout-wrapper { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + + &__wrapper { + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + gap: 40px; + } + + &__soldout { + color: #8e8e8e; + font-size: 13px; + font-weight: 400; + } +} diff --git a/src/page/Coop/components/MenuCard/index.tsx b/src/page/Coop/components/MenuCard/index.tsx new file mode 100644 index 00000000..19a4a118 --- /dev/null +++ b/src/page/Coop/components/MenuCard/index.tsx @@ -0,0 +1,87 @@ +import { useGetDining } from 'query/coop'; +import { Dinings, Menus, DINING_TYPES } from 'model/Coop'; +import SoldoutToggle from 'page/Coop/components/SoldoutToggle'; +import { ReactComponent as Photo } from 'assets/svg/coop/photo.svg'; +import { useRef, useState } from 'react'; +import styles from './MenuCard.module.scss'; + +interface MenuCardProps { + selectedMenuType: Menus; +} + +export default function MenuCard({ selectedMenuType }: MenuCardProps) { + const { data } = useGetDining(); + const [selectedImages, setSelectedImages] = useState<{ [key: number]: string }>({}); + const fileInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({}); + + const handleImageChange = (menuId: number) => (event: React.ChangeEvent) => { + if (event.target.files && event.target.files[0]) { + const fileReader = new FileReader(); + fileReader.onload = (e) => { + setSelectedImages((prevImages) => ({ + ...prevImages, + [menuId]: e.target?.result as string, + })); + }; + fileReader.readAsDataURL(event.target.files[0]); + } + }; + + const handleImageClick = (menuId: number) => () => { + fileInputRefs.current[menuId]?.click(); + }; + + const getDiningType = (menuType: Menus) => DINING_TYPES[menuType]; + + const filteredData = data?.filter((menu:Dinings) => { + const diningType = getDiningType(selectedMenuType); + return menu.type === diningType && ['A코너', 'B코너', 'C코너'].includes(menu.place); + }); + + return ( +
+ {filteredData?.map((menu: Dinings) => ( +
+
+ {menu.place} +
+ 품절 + +
+
+
+
{ + if (event.key === 'Enter') handleImageClick(menu.id)(); + }} + role="button" + tabIndex={0} + > + {selectedImages[menu.id] ? ( + + ) : ( + + )} +
+
+ {menu.menu.map((item) => ( +
{item}
+ ))} +
+
+ { + fileInputRefs.current[menu.id] = el; + }} + /> +
+ ))} +
+ ); +} diff --git a/src/page/Coop/components/MenuType/MenuType.module.scss b/src/page/Coop/components/MenuType/MenuType.module.scss new file mode 100644 index 00000000..b9c5006c --- /dev/null +++ b/src/page/Coop/components/MenuType/MenuType.module.scss @@ -0,0 +1,34 @@ +.place { + &__container { + display: flex; + align-items: center; + gap: 8px; + margin-top: 24px; + margin-left: 24px; + } + + &__button { + width: 60px; + height: 33px; + background-color: #fff; + color: #175c8e; + display: flex; + align-items: center; + justify-content: center; + border: solid 1px #175c8e; + border-radius: 999px; + cursor: pointer; + } + + &__button--selected { + width: 60px; + height: 33px; + background-color: #175c8e; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + border-radius: 999px; + cursor: pointer; + } +} diff --git a/src/page/Coop/components/MenuType/index.tsx b/src/page/Coop/components/MenuType/index.tsx new file mode 100644 index 00000000..92b07fff --- /dev/null +++ b/src/page/Coop/components/MenuType/index.tsx @@ -0,0 +1,51 @@ +import cn from 'utils/ts/className'; +import { Menus } from 'model/Coop'; +import styles from './MenuType.module.scss'; + +interface MenuTypeProps { + selectedMenuType: Menus; + setSelectedMenuType: (menuType: Menus) => void; +} + +export default function MenuType({ selectedMenuType, setSelectedMenuType }: MenuTypeProps) { + return ( +
+ + + +
+ ); +} diff --git a/src/page/Coop/components/SoldoutToggle/SoldoutToggle.module.scss b/src/page/Coop/components/SoldoutToggle/SoldoutToggle.module.scss new file mode 100644 index 00000000..345e1d9b --- /dev/null +++ b/src/page/Coop/components/SoldoutToggle/SoldoutToggle.module.scss @@ -0,0 +1,15 @@ +.toggle-button { + width: 46px; + height: 23px; + cursor: pointer; + + .background { + rx: 9.5; + stroke-width: 3; + } + + .circle { + r: 8; + fill: white; + } +} diff --git a/src/page/Coop/components/SoldoutToggle/index.tsx b/src/page/Coop/components/SoldoutToggle/index.tsx new file mode 100644 index 00000000..65ff7277 --- /dev/null +++ b/src/page/Coop/components/SoldoutToggle/index.tsx @@ -0,0 +1,17 @@ +import { useState } from 'react'; +import styles from './SoldoutToggle.module.scss'; + +export default function SoldoutToggle() { + const [isActive, setIsActive] = useState(true); + + const handleToggle = () => { + setIsActive(!isActive); + }; + + return ( + + + + + ); +} diff --git a/src/page/Coop/index.tsx b/src/page/Coop/index.tsx new file mode 100644 index 00000000..88a0e2e2 --- /dev/null +++ b/src/page/Coop/index.tsx @@ -0,0 +1,17 @@ +import { useState } from 'react'; +import { Menus } from 'model/Coop'; +import MenuCard from './components/MenuCard'; +import MenuType from './components/MenuType'; +import styles from './Coop.module.scss'; + +export default function Coop() { + const [selectedMenuType, setSelectedMenuType] = useState('아침'); + return ( +
+
+ + +
+
+ ); +} diff --git a/src/query/coop.ts b/src/query/coop.ts new file mode 100644 index 00000000..2a0fa2e5 --- /dev/null +++ b/src/query/coop.ts @@ -0,0 +1,41 @@ +import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; +import { getDining, updateSoldOut, uploadDiningImage } from 'api/coop'; +import { DiningImages, SoldOut } from 'model/Coop'; + +export const useGetDining = () => { + const { data } = useSuspenseQuery( + { + queryKey: ['dining'], + queryFn: getDining, + }, + ); + return { + data, + }; +}; + +export const useUpdateSoldOut = () => { + const queryClient = useQueryClient(); + const { mutate: updateSoldOutMutation } = useMutation({ + mutationFn: (data: SoldOut) => updateSoldOut(data), + onSuccess: () => { + queryClient.invalidateQueries(); + }, + }); + return { + updateSoldOutMutation, + }; +}; + +export const useUploadDiningImage = () => { + const queryClient = useQueryClient(); + const { mutate: uploadDiningImageMutation } = useMutation({ + mutationFn: (data: DiningImages) => uploadDiningImage(data), + onSuccess: () => { + queryClient.invalidateQueries(); + }, + }); + return { + uploadDiningImageMutation, + }; +};