import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react'; import 'bootstrap/dist/css/bootstrap.min.css'; import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket'; import { useAuth } from './context/auth'; import Login from './Login'; import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap'; import Header from './components/Header'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import PizzaOrderList from './components/PizzaOrderList'; import SelectSearch, { SelectedOptionValue, SelectSearchOption } from 'react-select-search'; import 'react-select-search/style.css'; import './App.scss'; import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons'; import { useSettings } from './context/settings'; import Footer from './components/Footer'; import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import Loader from './components/Loader'; import { getHumanDateTime, isInTheFuture } from './Utils'; import NoteModal from './components/modals/NoteModal'; import { useEasterEgg } from './context/eggs'; import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr } from '../../types'; import { getLunchChoiceName } from './enums'; // import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves'; // import './FallingLeaves.scss'; const EVENT_CONNECT = "connect" // Fixní styl pro všechny easter egg obrázky const EASTER_EGG_STYLE = { zIndex: 1, animationName: "bounce-in", animationTimingFunction: "ease" } // Mapování čísel alergenů na jejich názvy const ALLERGENS: { [key: number]: string } = { 1: "Obiloviny obsahující lepek", 2: "Korýši a výrobky z nich", 3: "Vejce a výrobky z nich", 4: "Ryby a výrobky z nich", 5: "Arašidy a výrobky z nich", 6: "Sója a výrobky z nich", 7: "Mléko a výrobky z nich (včetně laktózy)", 8: "Skořápkové plody", 9: "Celer a výrobky z něj", 10: "Hořčice a výrobky z ní", 11: "Sezamová semena a výrobky z nich", 12: "Oxid siřičitý a siřičitany", 13: "Vlčí bob (Lupina) a výrobky z něj", 14: "Měkkýši a výrobky z nich" } const LINK_ALLERGENS = 'https://www.strava.cz/Strava/Napoveda/cz/Prilohy/alergeny.pdf'; // Výchozí doba trvání animace v sekundách, pokud není přetíženo v konfiguračním JSONu const EASTER_EGG_DEFAULT_DURATION = 0.75; function App() { const auth = useAuth(); const settings = useSettings(); const [easterEgg, _] = useEasterEgg(auth); const [isConnected, setIsConnected] = useState(false); const [data, setData] = useState(); const [food, setFood] = useState(); const [myOrder, setMyOrder] = useState(); const [foodChoiceList, setFoodChoiceList] = useState(); const [closed, setClosed] = useState(false); const socket = useContext(SocketContext); const choiceRef = useRef(null); const foodChoiceRef = useRef(null); const departureChoiceRef = useRef(null); const pizzaPoznamkaRef = useRef(null); const [failure, setFailure] = useState(false); const [dayIndex, setDayIndex] = useState(); const [loadingPizzaDay, setLoadingPizzaDay] = useState(false); const [noteModalOpen, setNoteModalOpen] = useState(false); const [eggImage, setEggImage] = useState(); const eggRef = useRef(null); // Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu // https://medium.com/@kishorkrishna/cant-access-latest-state-inside-socket-io-listener-heres-how-to-fix-it-1522a5abebdb const dayIndexRef = useRef(dayIndex); // Načtení dat po přihlášení useEffect(() => { if (!auth?.login) { return } getData().then(response => { const data = response.data if (data) { setData(data); setDayIndex(data.dayIndex); dayIndexRef.current = data.dayIndex; setFood(data.menus); } }).catch(e => { setFailure(true); }) }, [auth, auth?.login]); // Přenačtení pro zvolený den useEffect(() => { if (!auth?.login) { return } getData({ query: { dayIndex: dayIndex } }).then(response => { const data = response.data; setData(data); if (data) { setFood(data.menus); } }).catch(e => { setFailure(true); }) }, [dayIndex, auth]); // Registrace socket eventů useEffect(() => { socket.on(EVENT_CONNECT, () => { setIsConnected(true); }); socket.on(EVENT_DISCONNECT, () => { setIsConnected(false); }); socket.on(EVENT_MESSAGE, (newData: ClientData) => { // console.log("Přijata nová data ze socketu", newData); // Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) { setData(newData); } }); return () => { socket.off(EVENT_CONNECT); socket.off(EVENT_DISCONNECT); socket.off(EVENT_MESSAGE); } }, [socket]); useEffect(() => { if (!auth?.login || !data?.choices) { return } // Pre-fill form refs from existing choices let foundKey: LunchChoice | undefined; let foundChoice: UserLunchChoice | undefined; for (const key of Object.keys(data.choices)) { const locationKey = key as LunchChoice; const locationChoices = data.choices[locationKey]; if (locationChoices && auth.login in locationChoices) { foundKey = locationKey; foundChoice = locationChoices[auth.login]; break; } } if (foundKey && choiceRef.current) { choiceRef.current.value = foundKey; const restaurantKey = Object.keys(Restaurant).indexOf(foundKey); if (restaurantKey > -1 && food) { const restaurant = Object.keys(Restaurant)[restaurantKey] as Restaurant; setFoodChoiceList(food[restaurant]?.food); setClosed(food[restaurant]?.closed ?? false); } } if (foundChoice?.departureTime && departureChoiceRef.current) { departureChoiceRef.current.value = foundChoice.departureTime; } }, [auth, auth?.login, data?.choices]) // Reference na mojí objednávku useEffect(() => { if (data?.pizzaDay?.orders) { const myOrder = data.pizzaDay.orders.find(o => o.customer === auth?.login); setMyOrder(myOrder); } }, [auth?.login, data?.pizzaDay?.orders]) useEffect(() => { if (choiceRef?.current?.value && choiceRef.current.value !== "") { const locationKey = choiceRef.current.value as LunchChoice; const restaurantKey = Object.keys(Restaurant).indexOf(locationKey); if (restaurantKey > -1 && food) { const restaurant = Object.keys(Restaurant)[restaurantKey] as Restaurant; setFoodChoiceList(food[restaurant]?.food); setClosed(food[restaurant]?.closed ?? false); } else { setFoodChoiceList(undefined); setClosed(false); } } else { setFoodChoiceList(undefined); setClosed(false); } }, [choiceRef.current?.value, food]) // Navigace mezi dny pomocí klávesových šípek const handleKeyDown = useCallback((e: any) => { if (e.keyCode === 37 && dayIndex != null && dayIndex > 0) { handleDayChange(dayIndex - 1); } else if (e.keyCode === 39 && dayIndex != null && dayIndex < 4) { handleDayChange(dayIndex + 1); } }, [dayIndex]); useEffect(() => { document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); } }, [handleKeyDown]); // Stažení a nastavení easter egg obrázku useEffect(() => { if (auth?.login && easterEgg?.url && !eggImage) { getEasterEggImage({ path: { url: easterEgg.url } }).then(response => { if (response.data) { setEggImage(response.data); // Smazání obrázku z DOMu po animaci setTimeout(() => { if (eggRef?.current) { eggRef.current.remove(); } }, (easterEgg.duration || EASTER_EGG_DEFAULT_DURATION) * 1000); } }); } }, [auth?.login, easterEgg?.duration, easterEgg?.url, eggImage]); const doAddClickFoodChoice = async (location: LunchChoice, foodIndex?: number) => { if (document.getSelection()?.type !== 'Range') { // pouze pokud se nejedná o výběr textu if (canChangeChoice && auth?.login) { await addChoice({ body: { locationKey: location, foodIndex, dayIndex } }); } } } const doAddChoice = async (event: React.ChangeEvent) => { const locationKey = event.target.value as LunchChoice; if (canChangeChoice && auth?.login) { await addChoice({ body: { locationKey, dayIndex } }); if (foodChoiceRef.current?.value) { foodChoiceRef.current.value = ""; } choiceRef.current?.blur(); } } const doJdemeObed = async () => { if (auth?.login) { await jdemeObed(); } } const doAddFoodChoice = async (event: React.ChangeEvent) => { if (event.target.value && foodChoiceList?.length && choiceRef.current?.value) { const locationKey = choiceRef.current.value as LunchChoice; if (auth?.login) { await addChoice({ body: { locationKey, foodIndex: Number(event.target.value), dayIndex } }); } } } const doRemoveChoices = async (locationKey: LunchChoice) => { if (auth?.login) { await removeChoices({ body: { locationKey, dayIndex } }); // Vyresetujeme výběr, aby bylo jasné pro který případně vybíráme jídlo if (choiceRef?.current?.value) { choiceRef.current.value = ""; } if (foodChoiceRef?.current?.value) { foodChoiceRef.current.value = ""; } } } const doRemoveFoodChoice = async (locationKey: LunchChoice, foodIndex: number) => { if (auth?.login) { await removeChoice({ body: { locationKey, foodIndex, dayIndex } }); if (choiceRef?.current?.value) { choiceRef.current.value = ""; } if (foodChoiceRef?.current?.value) { foodChoiceRef.current.value = ""; } } } const saveNote = async (note?: string) => { if (auth?.login) { await updateNote({ body: { note, dayIndex } }); setNoteModalOpen(false); } } const copyNote = async (note: string) => { if (auth?.login && note) { await updateNote({ body: { note, dayIndex } }); } } const markAsBuyer = async () => { if (auth?.login) { await setBuyer(); } } const pizzaSuggestions = useMemo(() => { if (!data?.pizzaList) { return []; } const suggestions: SelectSearchOption[] = []; data.pizzaList.forEach((pizza, index) => { const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] } pizza.sizes.forEach((size, sizeIndex) => { const name = `${size.size} (${size.price} Kč)`; const value = `${index}|${sizeIndex}`; group.items?.push({ name, value }); }) suggestions.push(group); }) return suggestions; }, [data?.pizzaList]); const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => { if (auth?.login && data?.pizzaList) { if (typeof value !== 'string') { throw new TypeError('Nepodporovaný typ hodnoty: ' + typeof value); } const s = value.split('|'); const pizzaIndex = Number.parseInt(s[0]); const pizzaSizeIndex = Number.parseInt(s[1]); await addPizza({ body: { pizzaIndex, pizzaSizeIndex } }); } } const handlePizzaDelete = async (pizzaOrder: PizzaVariant) => { await removePizza({ body: { pizzaOrder } }); } const handlePizzaPoznamkaChange = async () => { if (pizzaPoznamkaRef.current?.value && pizzaPoznamkaRef.current.value.length > 70) { alert("Poznámka může mít maximálně 70 znaků"); return; } updatePizzaDayNote({ body: { note: pizzaPoznamkaRef.current?.value } }); } const handleChangeDepartureTime = async (event: React.ChangeEvent) => { if (foodChoiceList?.length && choiceRef.current?.value) { await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } }); } } const handleDayChange = async (dayIndex: number) => { setDayIndex(dayIndex); dayIndexRef.current = dayIndex; if (choiceRef?.current?.value) { choiceRef.current.value = ""; } if (foodChoiceRef?.current?.value) { foodChoiceRef.current.value = ""; } if (departureChoiceRef?.current?.value) { departureChoiceRef.current.value = ""; } } const renderFoodTable = (location: Restaurant, menu: RestaurantDayMenu) => { let content; if (menu?.closed) { content =
Zavřeno
} else if (menu?.food?.length && menu.food.length > 0) { const hideSoups = settings?.hideSoups; content = {menu.food.map((f: Food, index: number) => (!hideSoups || !f.isSoup) && doAddClickFoodChoice(location, index)}> )}
{f.name} {f.allergens && f.allergens.length > 0 && ( {' '}({f.allergens.map((a, idx) => ( { e.stopPropagation(); window.open(LINK_ALLERGENS, '_blank'); }}>{a} {idx < f.allergens!.length - 1 && ', '} ))}) )}
{f.amount && f.amount !== '-' && {f.amount}} {f.price}
} else { content =
Chyba načtení dat
} return
doAddClickFoodChoice(location)}>

{getLunchChoiceName(location)}

{menu?.lastUpdate && Aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}} {menu?.warnings && menu.warnings.length > 0 && ( )}
{content}
} if (!auth?.login) { return ; } if (!isConnected) { return } if (failure) { return } if (!data || !food) { return } const noOrders = data?.pizzaDay?.orders?.length === 0; const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex; const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {}; return (
{easterEgg && eggImage && }
{data.todayDayIndex != null && data.todayDayIndex > 4 && Zobrazujete uplynulý týden } <> {dayIndex != null &&
0 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex - 1)} />

{data.date}

handleDayChange(dayIndex + 1)} />
} {Object.keys(Restaurant).map(key => { const locationKey = key as Restaurant; return food[locationKey] && renderFoodTable(locationKey, food[locationKey]); })}
{canChangeChoice &&

{`Jak to ${dayIndex == null || dayIndex === data.todayDayIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}

{Object.entries(LunchChoice) .filter(entry => { const locationKey = entry[0] as Restaurant; return !food[locationKey]?.closed; }) .map(entry => )} Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí. {foodChoiceList && !closed && <>

Na co dobrého? (nepovinné)

{foodChoiceList.map((food, index) => )} } {foodChoiceList && !closed && <>

V kolik hodin preferuješ odchod?

{Object.values(DepartureTime) .filter(time => isInTheFuture(time)) .map(time => )} }
} {Object.keys(data.choices).length > 0 ? {Object.keys(data.choices).map(key => { const locationKey = key as LunchChoice; const locationName = getLunchChoiceName(locationKey); const loginObject = data.choices[locationKey]; if (!loginObject) { return null; } const locationLoginList = Object.entries(loginObject); const locationPickCount = locationLoginList.length return ( ) } )}
{locationName} {(locationPickCount ?? 0) > 1 && ({locationPickCount})} {locationLoginList.map((entry: [string, UserLunchChoice]) => { const login = entry[0]; const userPayload = entry[1]; const userChoices = userPayload?.selectedFoods; const trusted = userPayload?.trusted || false; const isBuyer = userPayload?.isBuyer || false; return {userChoices?.length && food ? : null} } )}
{trusted && } {login} {userPayload.departureTime && ({userPayload.departureTime})} {userPayload.note && ({userPayload.note})} {login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && { markAsBuyer(); }} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{cursor: 'pointer'}} /> } {login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && { copyNote(userPayload.note!); }} icon={faBasketShopping} className='buyer-icon' /> } {login !== auth.login && canChangeChoice && userPayload?.note?.length && { copyNote(userPayload.note!); }} className='action-icon' icon={faComment} /> } {login === auth.login && canChangeChoice && { setNoteModalOpen(true); }} className='action-icon' icon={faNoteSticky} /> } {login === auth.login && canChangeChoice && { doRemoveChoices(key as LunchChoice); }} className='action-icon' icon={faTrashCan} /> }
{userChoices?.map(foodIndex => { const restaurantKey = key as Restaurant; const foodName = food[restaurantKey]?.food?.[foodIndex].name; return
{foodName} {login === auth.login && canChangeChoice && { doRemoveFoodChoice(restaurantKey, foodIndex); }} className='action-icon' icon={faTrashCan} /> }
})}
:
Zatím nikdo nehlasoval...
}
{dayIndex === data.todayDayIndex &&
{!data.pizzaDay && <>

Pizza Day

Pro dnešní den není aktuálně založen Pizza day.

{loadingPizzaDay ? Zjišťujeme dostupné pizzy :
} } {data.pizzaDay && <>

Pizza Day

{ data.pizzaDay.state === PizzaDayState.CREATED && <>

Pizza Day je založen a spravován uživatelem {data.pizzaDay.creator}.
Můžete upravovat své objednávky.

{ data.pizzaDay.creator === auth.login &&
} } { data.pizzaDay.state === PizzaDayState.LOCKED && <>

Objednávky jsou uzamčeny uživatelem {data.pizzaDay.creator}

{data.pizzaDay.creator === auth.login &&
} } { data.pizzaDay.state === PizzaDayState.ORDERED && <>

Pizzy byly objednány uživatelem {data.pizzaDay.creator}

{data.pizzaDay.creator === auth.login &&
} } { data.pizzaDay.state === PizzaDayState.DELIVERED &&

Pizzy byly doručeny. {myOrder?.hasQr ? ` Objednávku můžete uživateli ${data.pizzaDay.creator} uhradit pomocí QR kódu níže.` : ''}

} {data.pizzaDay.state === PizzaDayState.CREATED &&
{ }} onFocus={_ => { }} />
{ if (event.key === 'Enter') { handlePizzaPoznamkaChange(); } event.stopPropagation(); }} />
} { data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr &&

QR platba

QR kód
} }
}
{data.pendingQrs && data.pendingQrs.length > 0 &&

Nevyřízené platby

Máte neuhrazené QR kódy z předchozích Pizza day.

{data.pendingQrs.map(qr => (

{qr.date} — {qr.creator} ({qr.totalPrice} Kč)

QR kód
))}
}
{/* */}
); } export default App;