From a51ed0243255bff2556126a560961575e722be95 Mon Sep 17 00:00:00 2001 From: Leo Jeong <80350771+33tm@users.noreply.github.com> Date: Sat, 28 Sep 2024 21:34:06 -0700 Subject: [PATCH] menu integration --- .../components/layout/PeriodActionButton.tsx | 77 ++++++++++ client/src/components/lists/MenuModal.tsx | 53 +++++++ .../src/components/lists/NutritionModal.tsx | 142 ++++++++++++++++++ client/src/components/schedule/Period.tsx | 5 +- client/src/components/schedule/Periods.tsx | 9 +- client/src/contexts/MenuContext.ts | 52 +++++++ client/src/hooks/useMenu.ts | 42 ++++++ 7 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 client/src/components/layout/PeriodActionButton.tsx create mode 100644 client/src/components/lists/MenuModal.tsx create mode 100644 client/src/components/lists/NutritionModal.tsx create mode 100644 client/src/contexts/MenuContext.ts create mode 100644 client/src/hooks/useMenu.ts diff --git a/client/src/components/layout/PeriodActionButton.tsx b/client/src/components/layout/PeriodActionButton.tsx new file mode 100644 index 00000000..d5b2d045 --- /dev/null +++ b/client/src/components/layout/PeriodActionButton.tsx @@ -0,0 +1,77 @@ +import { ReactNode, useContext, useState } from 'react'; +import { DateTime } from 'luxon'; +import MenuModal from '../lists/MenuModal'; +import MenuContext from '../../contexts/MenuContext'; + + +type ActionButtonProps = { + children: ReactNode, + now: boolean, + note?: string, + onClick?: () => void +} +function ActionButton(props: ActionButtonProps) { + const { children, now, note, onClick } = props; + return ( + + ) +} + +type PeriodActionButtonProps = { + date: DateTime, + name: string, + now: boolean, + note?: string +} +export default function PeriodActionButton(props: PeriodActionButtonProps) { + const { name } = props; + + if (name === 'PRIME' || name === 'Study Hall') + return + + if (name === 'Brunch' || name === 'Lunch') + return + + return <> +} + +function FlexiSCHEDAction(props: PeriodActionButtonProps) { + return ( + + + FlexiSCHED + + + ) +} + +function MenuAction(props: PeriodActionButtonProps) { + const { name, date } = props; + const { menu } = useContext(MenuContext); + const [modal, setModal] = useState(false); + + const formatted = date.toFormat('MM-dd'); + const meal = name.toLowerCase() as 'brunch' | 'lunch'; + + if (formatted in menu && menu[formatted][meal]) + return ( + <> + setModal(true)}> + Menu + + + + ) + + return <> +} \ No newline at end of file diff --git a/client/src/components/lists/MenuModal.tsx b/client/src/components/lists/MenuModal.tsx new file mode 100644 index 00000000..30a6b0bf --- /dev/null +++ b/client/src/components/lists/MenuModal.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import { Dialog } from '@headlessui/react'; + +import NutritionModal from './NutritionModal'; +import CenteredModal from '../layout/CenteredModal'; +import { DangerOutlineButton } from '../layout/OutlineButton'; + +import type { Entry } from '../../contexts/MenuContext'; + + +type MenuModalProps = { + name: string, + items: { [item: string]: Entry }, + isOpen: boolean, + setIsOpen: (open: boolean) => void +} +export default function MenuModal(props: MenuModalProps) { + const { name, items, isOpen, setIsOpen } = props; + const [nutritionModal, setNutritionModal] = useState(null); + + return ( + + + {name} Menu + + +
+ {Object.entries(items).map(([item, nutrition]) => ( +
+
setNutritionModal(item)} + > + {item} +
+ setNutritionModal(null)} + /> +
+ ))} +
+ +
+ setIsOpen(false)}> + Close + +
+
+ ) +} diff --git a/client/src/components/lists/NutritionModal.tsx b/client/src/components/lists/NutritionModal.tsx new file mode 100644 index 00000000..1413ee79 --- /dev/null +++ b/client/src/components/lists/NutritionModal.tsx @@ -0,0 +1,142 @@ +import { ReactNode } from 'react'; +import { Dialog } from '@headlessui/react'; + +import CenteredModal from '../layout/CenteredModal'; +import { DangerOutlineButton } from '../layout/OutlineButton'; + +import type { Entry } from '../../contexts/MenuContext'; + + +type ItemProps = { + children: ReactNode, + value?: number, + dv?: number +} +function Item(props: ItemProps) { + const { children, value, dv } = props; + + if (value === null) + return <> + + return ( + <> +
+
+ {children} + {dv && {Math.floor((value! / dv) * 100)}%} +
+ + ) +} + +type NutritionModalProps = { + item: string, + nutrition: Entry, + isOpen: boolean, + setIsOpen: (open: boolean) => void +} +export default function NutritionModal(props: NutritionModalProps) { + const { + item, + nutrition: { + serving, + nutrition, + ingredients + }, + isOpen, + setIsOpen + } = props; + + return ( + + + {item} + + + {nutrition ? ( +
+ Nutrition Facts +
+ {serving && serving.serving_size_amount && serving.serving_size_unit && ( + <> +
+ Serving size + {serving.serving_size_amount} {serving.serving_size_unit} +
+
+ + )} + {nutrition.calories && ( + <> +
+ Calories + {nutrition.calories} +
+
+ + )} +
+ % Daily Value* +
+ + +

Total Fat {nutrition.g_fat}g

+
+ +

Saturated Fat {nutrition.g_saturated_fat}g

+
+ +

Trans Fat {nutrition.g_trans_fat}g

+
+ + +

Cholesterol {nutrition.mg_cholesterol}mg

+
+ + +

Sodium {nutrition.mg_sodium}mg

+
+ + +

Total Carbohydrate {nutrition.g_carbs}g

+
+ +

Dietary Fiber {nutrition.g_fiber}g

+
+ +

Total Sugars {nutrition.g_sugar}g

+
+ +

Includes {nutrition.g_added_sugar}g Added Sugars

+
+ + +

Protein {nutrition.g_protein}g

+
+ + +

+ * Percent Daily Values are based on a 2,000 calorie diet. +

+
+ + {ingredients && ( + <> +
+ Ingredients +

{ingredients}

+ + )} +
+ ) : ( +

No nutrition information available.

+ )} + +
+ setIsOpen(false)}> + Close + +
+
+ ) +} diff --git a/client/src/components/schedule/Period.tsx b/client/src/components/schedule/Period.tsx index 59575e10..36e7a524 100644 --- a/client/src/components/schedule/Period.tsx +++ b/client/src/components/schedule/Period.tsx @@ -5,6 +5,7 @@ import {DateTime} from 'luxon'; // Components import PillClubComponent from '../lists/PillClubComponent'; +import PeriodActionButton from '../layout/PeriodActionButton'; // Contexts import UserDataContext from '../../contexts/UserDataContext'; @@ -109,9 +110,7 @@ export default function Period(props: PeriodProps) { )} - + ); } diff --git a/client/src/components/schedule/Periods.tsx b/client/src/components/schedule/Periods.tsx index 84e0a1c2..dcf99cfc 100644 --- a/client/src/components/schedule/Periods.tsx +++ b/client/src/components/schedule/Periods.tsx @@ -9,9 +9,11 @@ import NoSchoolImage from './NoSchoolImage'; // Contexts import CurrentTimeContext from '../../contexts/CurrentTimeContext'; import UserDataContext, {SgyPeriodData, UserData} from '../../contexts/UserDataContext'; +import {MenuProvider} from '../../contexts/MenuContext'; // Utils import {useSchedule} from '../../hooks/useSchedule'; +import {useMenu} from '../../hooks/useMenu'; import {periodNameDefault} from '@watt/shared/util/schedule'; @@ -28,6 +30,9 @@ export default function Periods(props: PeriodsProps) { const format = userData.options.time === '24' ? 'H:mm' : 'h:mm a'; const classes = userData.classes as {[key: string]: SgyPeriodData}; + // Brunch/Lunch menu + const menu = useMenu(); + // HTML for a school day, assumes periods is populated const schoolDay = () => { // End time of the last period of the day @@ -43,7 +48,7 @@ export default function Periods(props: PeriodsProps) { const displayIndicator = periods && minutes < periods[periods.length - 1].e && minutes >= periods[0].s - 20; return ( - <> +

School ends at {end.toFormat(format)} today.

@@ -62,7 +67,7 @@ export default function Periods(props: PeriodsProps) { grades={grades} /> ))} - +
) } diff --git a/client/src/contexts/MenuContext.ts b/client/src/contexts/MenuContext.ts new file mode 100644 index 00000000..894e0d75 --- /dev/null +++ b/client/src/contexts/MenuContext.ts @@ -0,0 +1,52 @@ +import { createContext } from 'react'; + + +export type Entry = { + serving?: { + serving_size_amount: string, + serving_size_unit: string + }, + nutrition?: { + calories?: number, + g_fat?: number, + g_saturated_fat?: number, + g_trans_fat?: number, + mg_cholesterol?: number, + g_carbs?: number, + g_added_sugar?: number, + g_sugar?: number, + mg_potassium?: number, + mg_sodium?: number, + g_fiber?: number, + g_protein?: number, + mg_iron?: number, + mg_calcium?: number, + mg_vitamin_c?: number, + iu_vitamin_a?: number, + re_vitamin_a?: number, + mcg_vitamin_a?: number, + mg_vitamin_d?: number, + mcg_vitamin_d?: number, + }, + ingredients?: string +} + +export type Menu = { + timestamp: string, + menu: { + [date: string]: { + brunch: { [item: string]: Entry }, + lunch: { [item: string]: Entry } + } + } +} + +export const defaultMenu: Menu = { + timestamp: new Date().toISOString(), + menu: {} +} + +const MenuContext = createContext(defaultMenu); + +export const MenuProvider = MenuContext.Provider; +export default MenuContext; \ No newline at end of file diff --git a/client/src/hooks/useMenu.ts b/client/src/hooks/useMenu.ts new file mode 100644 index 00000000..53d6150f --- /dev/null +++ b/client/src/hooks/useMenu.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; +import { defaultMenu, Menu } from '../contexts/MenuContext'; +import { DateTime } from 'luxon'; + +import { doc } from 'firebase/firestore'; +import { httpsCallable } from 'firebase/functions'; +import { useFirestore, useFirestoreDoc, useFunctions } from 'reactfire'; + + +export function useMenu() { + const firestore = useFirestore(); + const functions = useFunctions(); + + const localStorageRaw = localStorage.getItem('menu'); + const [menu, setMenu] = useState(tryParseLocalStorageMenu()); + const { status, data: firebaseDoc } = useFirestoreDoc(doc(firestore, 'gunn/menu')); + + useEffect(() => { + const parsed = tryParseLocalStorageMenu(); + setMenu(parsed); + // Regenerate daily + if (DateTime.fromISO(parsed.timestamp).plus({ day: 1 }) < DateTime.now()) + httpsCallable(functions, 'menu')(); + localStorage.setItem('menu', JSON.stringify(parsed)); + }, [localStorageRaw]); + + useEffect(() => { + if (status !== 'success' || !firebaseDoc.exists()) return; + localStorage.setItem('menu', JSON.stringify(firebaseDoc.data())); + }, [firebaseDoc]); + + function tryParseLocalStorageMenu() { + if (!localStorageRaw) return defaultMenu; + try { + return JSON.parse(localStorageRaw) as Menu; + } catch { + return defaultMenu + } + } + + return menu; +} \ No newline at end of file