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 { addPizza, createPizzaDay, deletePizzaDay, finishDelivery, finishOrder, lockPizzaDay, removePizza, unlockPizzaDay, updatePizzaDayNote } from './api/PizzaDayApi'; 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 } from 'react-select-search'; import 'react-select-search/style.css'; import './App.scss'; import { SelectSearchOption } from 'react-select-search'; import { faCircleCheck, faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons'; import { useSettings } from './context/settings'; import { ClientData, Restaurants, Food, Order, Locations, PizzaOrder, PizzaDayState, FoodChoices, DayMenu, DepartureTime } from './types'; import Footer from './components/Footer'; import { faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons'; import Loader from './components/Loader'; import { getData, errorHandler, getQrUrl } from './api/Api'; import { addChoice, removeChoices, removeChoice, changeDepartureTime, jdemeObed, updateNote } from './api/FoodApi'; import { getHumanDateTime } from './Utils'; import NoteModal from './components/modals/NoteModal'; import { useEasterEgg } from './context/eggs'; import { getImage } from './api/EasterEggApi'; const EVENT_CONNECT = "connect" // Fixní styl pro všechny easter egg obrázky const EASTER_EGG_STYLE = { zIndex: 1, animationName: "bounce-in", animationTimingFunction: "ease" } // 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, easterEggLoading] = useEasterEgg(auth); const [isConnected, setIsConnected] = useState<boolean>(false); const [data, setData] = useState<ClientData>(); const [food, setFood] = useState<{ [key in Restaurants]?: DayMenu }>(); const [myOrder, setMyOrder] = useState<Order>(); const [foodChoiceList, setFoodChoiceList] = useState<Food[]>(); const [closed, setClosed] = useState<boolean>(false); const socket = useContext(SocketContext); const choiceRef = useRef<HTMLSelectElement>(null); const foodChoiceRef = useRef<HTMLSelectElement>(null); const departureChoiceRef = useRef<HTMLSelectElement>(null); const pizzaPoznamkaRef = useRef<HTMLInputElement>(null); const [failure, setFailure] = useState<boolean>(false); const [dayIndex, setDayIndex] = useState<number>(); const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false); const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false); const [eggImage, setEggImage] = useState<Blob>(); const eggRef = useRef<HTMLImageElement>(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<number | undefined>(dayIndex); // Načtení dat po přihlášení useEffect(() => { if (!auth || !auth.login) { return } getData().then((data: ClientData) => { setData(data); setDayIndex(data.weekIndex); dayIndexRef.current = data.weekIndex; setFood(data.menus); }).catch(e => { setFailure(true); }) }, [auth, auth?.login]); // Přenačtení pro zvolený den useEffect(() => { if (!auth || !auth.login) { return } getData(dayIndex).then((data: ClientData) => { setData(data); setFood(data.menus); }).catch(e => { setFailure(true); }) }, [dayIndex, auth]); // Registrace socket eventů useEffect(() => { socket.on(EVENT_CONNECT, () => { // console.log("Connected!"); setIsConnected(true); }); socket.on(EVENT_DISCONNECT, () => { // console.log("Disconnected!"); 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.weekIndex === dayIndexRef.current) { setData(newData); } }); return () => { socket.off(EVENT_CONNECT); socket.off(EVENT_DISCONNECT); socket.off(EVENT_MESSAGE); } }, [socket]); useEffect(() => { if (!auth || !auth.login) { return } // TODO tohle občas náhodně nezafunguje, nutno přepsat, viz https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780 // TODO nutno opravit // if (data?.choices && choiceRef.current) { // for (let entry of Object.entries(data.choices)) { // if (entry[1].includes(auth.login)) { // const value = entry[0] as any as number; // TODO tohle je absurdní // choiceRef.current.value = Object.values(Locations)[value]; // } // } // } }, [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 !== "") { // TODO: wtf, cos pil, když jsi tohle psal? const key = choiceRef?.current?.value; const locationIndex = Object.keys(Locations).indexOf(key as unknown as Locations); const locationsKey = Object.keys(Locations)[locationIndex]; const restaurantKey = Object.keys(Restaurants).indexOf(locationsKey); if (restaurantKey > -1 && food) { const restaurant = Object.values(Restaurants)[restaurantKey]; 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) { getImage(easterEgg.url).then(data => { if (data) { setEggImage(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 doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => { const index = Object.keys(Locations).indexOf(event.target.value as unknown as Locations); if (auth?.login) { await errorHandler(() => addChoice(index, undefined, dayIndex)); if (foodChoiceRef.current?.value) { foodChoiceRef.current.value = ""; } } } const doJdemeObed = async () => { if (auth?.login) { await jdemeObed(); } } const doAddFoodChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => { if (event.target.value && foodChoiceList?.length && choiceRef.current?.value) { const restaurantKey = choiceRef.current.value; if (auth?.login) { const locationIndex = Object.keys(Locations).indexOf(restaurantKey as unknown as Locations); await errorHandler(() => addChoice(locationIndex, Number(event.target.value), dayIndex)); } } } const doRemoveChoices = async (locationKey: string) => { if (auth?.login) { await errorHandler(() => removeChoices(Number(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: string, foodIndex: number) => { if (auth?.login) { await errorHandler(() => removeChoice(Number(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 errorHandler(() => updateNote(note, dayIndex)); setNoteModalOpen(false); } } 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 Error('Nepodporovaný typ hodnoty'); } const s = value.split('|'); const pizzaIndex = Number.parseInt(s[0]); const pizzaSizeIndex = Number.parseInt(s[1]); await addPizza(pizzaIndex, pizzaSizeIndex); } } const handlePizzaDelete = async (pizzaOrder: PizzaOrder) => { await removePizza(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(pizzaPoznamkaRef.current?.value); } // const addToCart = async () => { // TODO aktuálně nefunkční - nedokážeme poslat PHPSESSIONID cookie // if (data?.pizzaDay?.orders) { // for (const order of data?.pizzaDay?.orders) { // for (const pizzaOrder of order.pizzaList) { // const url = 'https://www.pizzachefie.cz/pridat.html'; // const payload = new URLSearchParams(); // payload.append('varId', pizzaOrder.varId.toString()); // await fetch(url, { // method: "POST", // mode: "no-cors", // cache: "no-cache", // credentials: "same-origin", // headers: { // 'Content-Type': 'application/x-www-form-urlencoded', // }, // body: payload, // }) // } // } // // TODO otevřít košík v nové záložce // } // } const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => { if (foodChoiceList?.length && choiceRef.current?.value) { await changeDepartureTime(event.target.value, 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 = (name: string, menu: DayMenu) => { let content; if (menu?.closed) { content = <h3>Zavřeno</h3> } else if (menu?.food?.length > 0) { content = <Table striped bordered hover> <tbody> {menu.food.filter(f => (settings?.hideSoups ? !f.isSoup : true)).map((f: any, index: number) => <tr key={index}> <td>{f.amount}</td> <td>{f.name}</td> <td>{f.price}</td> </tr> )} </tbody> </Table> } else { content = <h3>Chyba načtení dat</h3> } return <Col md={12} lg={6} className='mt-3'> <h3>{name}</h3> {menu?.lastUpdate && <small>Poslední aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>} {content} </Col> } if (!auth || !auth.login) { return <Login />; } if (!isConnected) { return <Loader icon={faSatelliteDish} description={'Zdá se, že máme problémy se spojením se serverem. Pokud problém přetrvává, kontaktujte správce systému.'} animation={'fa-beat-fade'} /> } if (failure) { return <Loader title="Něco se nám nepovedlo :(" icon={faChainBroken} description={'Ale to nevadí. To se stává, takový je život. Kontaktujte správce systému, který zajistí nápravu.'} /> } if (!data || !food) { return <Loader icon={faSearch} description={'Hledáme, co dobrého je aktuálně v nabídce'} animation={'fa-bounce'} /> } const noOrders = data?.pizzaDay?.orders?.length === 0; const canChangeChoice = dayIndex == null || data.todayWeekIndex == null || dayIndex >= data.todayWeekIndex; const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {}; return ( <> {easterEgg && eggImage && <img ref={eggRef} alt='' src={URL.createObjectURL(eggImage)} style={{ position: 'absolute', ...EASTER_EGG_STYLE, ...style, animationDuration: `${duration ?? EASTER_EGG_DEFAULT_DURATION}s` }} />} <Header /> <div className='wrapper'> {data.isWeekend ? <h4>Užívejte víkend :)</h4> : <> <Alert variant={'primary'}> <img src='hat.png' style={{ position: "absolute", width: "70px", rotate: "-45deg", left: -40, top: -58 }} /> <img src='snowman.png' style={{ position: "absolute", height: "110px", right: 10, top: 5 }} /> Poslední změny: <ul> <li>Zimní atmosféra</li> <li>Odstranění podniku U Motlíků</li> </ul> </Alert> {dayIndex != null && <div className='day-navigator'> <FontAwesomeIcon title="Předchozí den" icon={faChevronLeft} style={{ cursor: "pointer", visibility: dayIndex > 0 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex - 1)} /> <h1 className='title' style={{ color: dayIndex === data.todayWeekIndex ? 'black' : 'gray' }}>{data.date}</h1> <FontAwesomeIcon title="Následující den" icon={faChevronRight} style={{ cursor: "pointer", visibility: dayIndex < 4 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex + 1)} /> </div> } <Row className='food-tables'> {food[Restaurants.SLADOVNICKA] && renderFoodTable('Sladovnická', food[Restaurants.SLADOVNICKA])} {/* {food[Restaurants.UMOTLIKU] && renderFoodTable('U Motlíků', food[Restaurants.UMOTLIKU])} */} {food[Restaurants.TECHTOWER] && renderFoodTable('TechTower', food[Restaurants.TECHTOWER])} </Row> <div className='content-wrapper'> <div className='content'> {canChangeChoice && <> <p>{`Jak to ${dayIndex == null || dayIndex === data.todayWeekIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}</p> <Form.Select ref={choiceRef} onChange={doAddChoice}> <option></option> {Object.entries(Locations) .filter(entry => { // TODO: wtf, cos pil, když jsi tohle psal? v2 const key = entry[0]; const locationIndex = Object.keys(Locations).indexOf(key as unknown as Locations); const locationsKey = Object.keys(Locations)[locationIndex]; const restaurantKey = Object.keys(Restaurants).indexOf(locationsKey); const v = Object.values(Restaurants)[restaurantKey]; return v == null || !food[v]?.closed; }) .map(entry => <option key={entry[0]} value={entry[0]}>{entry[1]}</option>)} </Form.Select> <small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small> {foodChoiceList && !closed && <> <p style={{ marginTop: "10px" }}>Na co dobrého? <small>(nepovinné)</small></p> <Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}> <option></option> {foodChoiceList.map((food, index) => <option key={index} value={index}>{food.name}</option>)} </Form.Select> </>} {foodChoiceList && !closed && <> <p style={{ marginTop: "10px" }}>V kolik hodin preferuješ odchod?</p> <Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}> <option></option> {Object.values(DepartureTime).map(time => <option key={time} value={time}>{time}</option>)} </Form.Select> </>} </>} {Object.keys(data.choices).length > 0 ? <Table bordered className='mt-5'> <tbody> {Object.keys(data.choices).map((locationKey: string) => { const locationName = Object.values(Locations)[Number(locationKey)]; const locationLoginList = Object.entries(data.choices[Number(locationKey)]); return ( <tr key={locationKey}> <td>{locationName}</td> <td className='p-0'> <Table> <tbody> {locationLoginList.map((entry: [string, FoodChoices], index) => { const login = entry[0]; const userPayload = entry[1]; const userChoices = userPayload?.options; const trusted = userPayload?.trusted || false; return <tr key={index}> <td> {trusted && <span className='trusted-icon'> <FontAwesomeIcon title='Uživatel ověřený doménovým přihlášením' icon={faCircleCheck} style={{ cursor: "help" }} /> </span>} {login} {userPayload.departureTime && <small> ({userPayload.departureTime})</small>} {userPayload.note && <small style={{ overflowWrap: 'anywhere' }}> ({userPayload.note})</small>} {login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => { setNoteModalOpen(true); }} title='Upravit poznámku' className='action-icon' icon={faNoteSticky} />} {login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => { doRemoveChoices(locationKey); }} title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`} className='action-icon' icon={faTrashCan} />} </td> {userChoices?.length && food ? <td> <ul> {userChoices?.map(foodIndex => { const locationsKey = Object.keys(Locations)[Number(locationKey)] const restaurantKey = Object.keys(Restaurants).indexOf(locationsKey); const restaurant = Object.values(Restaurants)[restaurantKey]; const foodName = food[restaurant]?.food[foodIndex].name; return <li key={foodIndex}> {foodName} {login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => { doRemoveFoodChoice(locationKey, foodIndex); }} title={`Odstranit ${foodName}`} className='action-icon' icon={faTrashCan} />} </li> })} </ul> </td> : null} </tr> } )} </tbody> </Table> </td> </tr>) } )} </tbody> </Table> : <div className='mt-5'><i>Zatím nikdo nehlasoval...</i></div> } </div> {dayIndex === data.todayWeekIndex && <div className='mt-5'> {!data.pizzaDay && <div style={{ textAlign: 'center' }}> <p>Pro dnešní den není aktuálně založen Pizza day.</p> {loadingPizzaDay ? <span> <FontAwesomeIcon icon={faGear} className='fa-spin' /> Zjišťujeme dostupné pizzy </span> : <> <Button onClick={async () => { setLoadingPizzaDay(true); await createPizzaDay().then(() => setLoadingPizzaDay(false)); }}>Založit Pizza day</Button> <Button onClick={doJdemeObed} style={{ marginLeft: "14px" }}>Jdeme na oběd !</Button> </> } </div> } {data.pizzaDay && <div> <div style={{ textAlign: 'center' }}> <h3>Pizza day</h3> { data.pizzaDay.state === PizzaDayState.CREATED && <div> <p> Pizza Day je založen a spravován uživatelem {data.pizzaDay.creator}.<br /> Můžete upravovat své objednávky. </p> { data.pizzaDay.creator === auth.login && <> <Button className='danger mb-3' title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={async () => { await deletePizzaDay(); }}>Smazat Pizza day</Button> <Button className='mb-3' style={{ marginLeft: '20px' }} title={noOrders ? "Nelze uzamknout - neexistuje žádná objednávka" : "Zamezí přidávat/odebírat objednávky. Použij před samotným objednáním, aby již nemohlo docházet ke změnám."} disabled={noOrders} onClick={async () => { await lockPizzaDay(); }}>Uzamknout</Button> </> } </div> } { data.pizzaDay.state === PizzaDayState.LOCKED && <div> <p>Objednávky jsou uzamčeny uživatelem {data.pizzaDay.creator}</p> {data.pizzaDay.creator === auth.login && <> <Button className='danger mb-3' title="Umožní znovu editovat objednávky." onClick={async () => { await unlockPizzaDay(); }}>Odemknout</Button> {/* <Button className='danger mb-3' style={{ marginLeft: '20px' }} onClick={async () => { await addToCart(); }}>Přidat vše do košíku</Button> */} <Button className='danger mb-3' style={{ marginLeft: '20px' }} title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={async () => { await finishOrder(); }}>Objednáno</Button> </> } </div> } { data.pizzaDay.state === PizzaDayState.ORDERED && <div> <p>Pizzy byly objednány uživatelem {data.pizzaDay.creator}</p> {data.pizzaDay.creator === auth.login && <div> <Button className='danger mb-3' title="Vrátí stav do předchozího kroku (před objednáním)." onClick={async () => { await lockPizzaDay(); }}>Vrátit do "uzamčeno"</Button> <Button className='danger mb-3' style={{ marginLeft: '20px' }} title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => { await finishDelivery(settings?.bankAccount, settings?.holderName); }}>Doručeno</Button> </div> } </div> } { data.pizzaDay.state === PizzaDayState.DELIVERED && <div> <p>{`Pizzy byly doručeny.${myOrder?.hasQr ? ` Objednávku můžete uživateli ${data.pizzaDay.creator} uhradit pomocí QR kódu níže.` : ''}`}</p> </div> } </div> {data.pizzaDay.state === PizzaDayState.CREATED && <div style={{ textAlign: 'center' }}> <SelectSearch search={true} options={pizzaSuggestions} placeholder='Vyhledat pizzu...' onChange={handlePizzaChange} onBlur={_ => { }} onFocus={_ => { }} /> Poznámka: <input ref={pizzaPoznamkaRef} className='mt-3' type="text" onKeyDown={event => { if (event.key === 'Enter') { handlePizzaPoznamkaChange(); } event.stopPropagation(); }} /> <Button style={{ marginLeft: '20px' }} disabled={!myOrder?.pizzaList?.length} onClick={handlePizzaPoznamkaChange}> Uložit </Button> </div> } <PizzaOrderList state={data.pizzaDay.state} orders={data.pizzaDay.orders} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator} /> { data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr && <div className='qr-code'> <h3>QR platba</h3> <img src={getQrUrl(auth.login)} alt='QR kód' /> </div> } </div> } </div> } </div> </>} </div> <Footer /> <NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} /> </> ); } export default App;