59 Commits

Author SHA1 Message Date
a8dc6c317d Další zoufalé pokusy o Typescript 2025-01-21 23:33:51 +01:00
cfcbd7a68b Otypování dnů v týdnu 2025-01-20 23:16:22 +01:00
57c22958be Oprava chybné detekce některých jídel TechTower jako polévka 2025-01-20 14:41:18 +01:00
Michal Hájek
fe9cee3a80 Odfiltrovat ze selectboxu pro výběr preferovaného odchodu časy z minulosti 2025-01-15 00:35:17 +01:00
Michal Hájek
1d995faf8e Odfiltrovat ze selectboxu pro výběr preferovaného odchodu časy z minulosti 2025-01-15 00:34:47 +01:00
Michal Hájek
62fff22a12 Přidání restaurace Zastávka u Michala do výběru "Jak to dnes vidíš s obědem" 2025-01-15 00:03:15 +01:00
Michal Hájek
0fd1482810 Přidání restaurace Zastávka u Michala 2025-01-14 23:45:06 +01:00
02de6691a8 Migrace z pořadových indexů na unikátní klíče 2025-01-09 22:05:20 +01:00
774cb4f9d2 Oprava syntaxe - zapomenutá migrace interface 2025-01-09 21:04:12 +01:00
fd9aa547e2 Migrace "interface" na "type" 2025-01-08 20:53:48 +01:00
e611d36995 Otypování requestů na API 2025-01-08 17:58:49 +01:00
414664b2d7 Úprava API pro podporu TypeScript 2025-01-08 17:43:47 +01:00
a2167038da Redukce velikosti obrázku 2025-01-07 15:52:10 +01:00
219f7ffbc8 Zimní atmosféra 2025-01-07 15:49:22 +01:00
4d2ec529bb Skrytí podniku U Motlíků 2025-01-07 15:44:39 +01:00
86af490e94 Oprava parsování TechTower 2025-01-07 15:21:10 +01:00
e21da059c6 Aktualizace posledních změn 2024-12-11 23:31:55 +01:00
e990108140 Migrace na React 19 2024-12-11 23:04:03 +01:00
18f2b72133 Migrace sestavování klienta na Vite 2024-12-11 22:54:57 +01:00
b0d8a1a830 Povýšení závislostí 2024-12-11 20:24:06 +01:00
7e4fa236b1 Podpora easter eggů 2024-12-11 20:09:45 +01:00
98f2b2a1e0 Přidání vánočních prvků 2024-12-06 16:53:24 +01:00
9b7abb0703 throw error 2024-11-19 12:10:02 +01:00
5678e4a606 Snad fix timeout 2024-11-19 12:01:59 +01:00
582216015c Přidání easter-eggs.json do .gitignore 2024-11-13 23:30:54 +01:00
31daf4fb36 Začištění Dockerfile a compose.yml 2024-10-30 13:05:05 +01:00
4f858a19d8 Oprava parsování TechTower 2024-10-30 13:01:32 +01:00
91ea07a539 Oprava parsování TechTower 2024-07-08 20:45:09 +02:00
101bd60ddb Oprava case-sensitive parsování TechTower 2024-06-10 12:56:20 +02:00
7e061aa890 Nové možnosti hlasování 2024-04-11 22:00:34 +02:00
ff2d9e4fdb Vylepšení mobilního zobrazení 2024-04-09 17:40:13 +02:00
e261d32170 Úprava zápatí 2024-03-24 18:53:51 +01:00
731fd2eeb9 Oprava validace délky poznámky 2024-03-24 18:51:38 +01:00
93ba8def03 Oprava posunů mezi dny v inputech 2024-03-05 23:10:38 +01:00
1e280e9d05 Možnost zadání obecné poznámky k volbě 2024-03-04 23:35:58 +01:00
44187bc316 Oprava vyhodnocení nastavení trusted headers 2024-03-04 23:33:22 +01:00
4bd825fbcf Aktualizace TODO 2024-02-26 20:34:14 +01:00
b087c790ad Povýšení závislostí 2024-02-26 20:23:14 +01:00
e4a146995f Oprava nodemon hotreload 2024-02-26 20:16:11 +01:00
2883e80658 urcite neco rozbije a pavel to najde jako prvni 2024-02-05 20:02:21 +01:00
5830cde9ac Aktualizace frází pro detekci polévek v TechTower 2024-02-02 22:05:54 +01:00
52c4a53b9e Aktualizace changelogu 2024-01-28 21:15:38 +01:00
e9ea42c636 Oprava varování linteru 2024-01-28 20:46:38 +01:00
e735af4fc1 Neuskakování šipek 2024-01-25 19:18:05 +01:00
56125eea2e Přidání možnosti "Rozhoduji se" 2024-01-24 19:29:09 +01:00
61b6ec04f4 Rozšíření výčtu polévek pro TechTower 2024-01-24 19:11:21 +01:00
b954374425 Aktualizace mock dat 2024-01-24 19:07:49 +01:00
72c7bfe80c Možnost skrytí polévek 2024-01-24 18:54:07 +01:00
2633d445cc Doplnění chybějící nedělitelné mezery 2024-01-23 08:00:21 +01:00
3fd6b7dfcb Text "na váhu" v případě neznámé ceny u TechTower 2024-01-22 21:32:04 +01:00
8e075dd904 Základní Pizza kalkulačka 2024-01-08 23:39:12 +01:00
74f6e1ab69 Oprava parsování Sladovnická
Opravena chyba, kdy docházelo k posunu nabídky o den, pokud nabídka nezačínala pondělím.
2024-01-03 14:03:38 +01:00
fcad338921 Oprava parsování U Motlíků
Nyní je počítáno s případy, kdy neexistuje nabídka pro první den v daném týdnu.
2024-01-03 14:01:45 +01:00
aca4055d57 Přidání trusted headers do .env.template 2023-12-02 21:07:15 +01:00
4991b813bf Umožnit zadání trusted IPs s bílými znaky 2023-12-02 20:54:39 +01:00
515d4bb47e Opravy překlepů 2023-12-02 20:50:19 +01:00
b9b2492cb4 Odstranění zbytečné proměnné 2023-12-02 20:47:38 +01:00
4ff5d70331 tohle prepsalo muj list ip adres 2023-12-02 19:09:12 +01:00
44de01f6eb doufam ze jsem to hodne rozjebal lol 2023-12-02 17:45:56 +01:00
51 changed files with 7196 additions and 11657 deletions

View File

@@ -1,28 +1,46 @@
# Builder # Builder
FROM node:18-alpine3.18 as builder FROM node:18-alpine3.18 AS builder
WORKDIR /build WORKDIR /build
COPY package.json . # Zkopírování závislostí - server
COPY yarn.lock .
COPY server/package.json ./server/ COPY server/package.json ./server/
COPY client/package.json ./client/ COPY server/yarn.lock ./server/
# Zkopírování závislostí - klient
COPY client/package.json ./client/
COPY client/yarn.lock ./client/
# Instalace závislostí - server
WORKDIR /build/server
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
# Instalace závislostí - klient
WORKDIR /build/client
RUN yarn install --frozen-lockfile
WORKDIR /build
# Zkopírování build závislostí - server
COPY server/tsconfig.json ./server/ COPY server/tsconfig.json ./server/
COPY server/src ./server/src/ COPY server/src ./server/src/
# Zkopírování build závislostí - klient
COPY client/tsconfig.json ./client/ COPY client/tsconfig.json ./client/
COPY client/vite.config.ts ./client/
COPY client/vite-env.d.ts ./client/
COPY client/index.html ./client/
COPY client/src ./client/src COPY client/src ./client/src
COPY client/public ./client/public COPY client/public ./client/public
# Zkopírování společných typů
COPY types ./types/ COPY types ./types/
# Sestavení serveru
WORKDIR /build/server WORKDIR /build/server
RUN yarn build RUN yarn build
# Sestavení klienta
WORKDIR /build/client WORKDIR /build/client
RUN yarn build RUN yarn build
@@ -33,11 +51,21 @@ ENV NODE_ENV production
WORKDIR /app WORKDIR /app
COPY --from=builder /build/node_modules ./node_modules # Vykopírování sestaveného serveru
COPY --from=builder /build/server/node_modules ./server/node_modules
COPY --from=builder /build/server/dist ./ COPY --from=builder /build/server/dist ./
COPY --from=builder /build/client/build ./public COPY server/resources ./server/resources
# Vykopírování sestaveného klienta
COPY --from=builder /build/client/dist ./public
# Zkopírování produkčních .env serveru
COPY /server/.env.production ./server/src COPY /server/.env.production ./server/src
# Zkopírování konfigurace easter eggů
# TODO tohle spadne když nebude existovat!
COPY /server/.easter-eggs.json ./server/
EXPOSE 3000 EXPOSE 3000
CMD [ "node", "./server/src/index.js" ] CMD [ "node", "./server/src/index.js" ]

View File

@@ -1,4 +1,7 @@
# TODO # TODO
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli
- [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
- [ ] Nemělo by se jít dostat na přihlašovací formulář (měla by tam být nanejvýš hláška nebo přesměrování)
- [ ] Možnost úhrady celé útraty jednou osobou - [ ] Možnost úhrady celé útraty jednou osobou
- Základní myšlenka: jedna osoba uhradí celou útratu (v zájmu rychlosti odbavení), ostatním se automaticky vygeneruje QR kód, kterým následně uhradí svoji část útraty - Základní myšlenka: jedna osoba uhradí celou útratu (v zájmu rychlosti odbavení), ostatním se automaticky vygeneruje QR kód, kterým následně uhradí svoji část útraty
- Obecně to bude problém např. pokud si někdo objedná něco navíc (pití apod.) - Obecně to bude problém např. pokud si někdo objedná něco navíc (pití apod.)

1
client/.gitignore vendored
View File

@@ -1,2 +1,3 @@
build build
dist
src/types src/types

21
client/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Luncher</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@@ -3,39 +3,41 @@
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"type": "module",
"homepage": ".", "homepage": ".",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0", "@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@types/jest": "^27.5.2", "@types/jest": "^29.5.12",
"@types/node": "^16.18.23", "@types/node": "^20.11.20",
"@types/react": "^18.0.33", "@types/react": "^19.0.0",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"bootstrap": "^5.2.3", "bootstrap": "^5.2.3",
"react": "^18.2.0", "react": "^19.0.0",
"react-bootstrap": "^2.7.2", "react-bootstrap": "^2.7.2",
"react-dom": "^18.2.0", "react-dom": "^19.0.0",
"react-jwt": "^1.2.0", "react-jwt": "^1.2.0",
"react-modal": "^3.16.1", "react-modal": "^3.16.1",
"react-scripts": "5.0.1",
"react-select-search": "^4.1.6", "react-select-search": "^4.1.6",
"react-toastify": "^9.1.3", "react-snowfall": "^2.2.0",
"react-toastify": "^10.0.4",
"sass": "^1.80.6",
"socket.io-client": "^4.6.1", "socket.io-client": "^4.6.1",
"typescript": "^4.9.5" "typescript": "^5.3.3",
"vite": "^6.0.3",
"vite-tsconfig-paths": "^5.1.4"
}, },
"scripts": { "scripts": {
"copy-types": "cp -r ../types ./src", "copy-types": "cp -r ../types ./src",
"start": "yarn copy-types && react-scripts start", "start": "yarn copy-types && vite",
"build": "yarn copy-types && react-scripts build", "build": "yarn copy-types && vite build"
"test": "react-scripts test",
"eject": "react-scripts eject"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
"react-app", "react-app"
"react-app/jest"
] ]
}, },
"browserslist": { "browserslist": {
@@ -52,6 +54,6 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"prettier": "^2.8.8" "prettier": "^3.2.5"
} }
} }

BIN
client/public/hat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Luncher</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
client/public/snowman.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -56,7 +56,7 @@
} }
.title { .title {
margin: 50px 30px; margin: 50px 20px;
} }
.food-tables { .food-tables {
@@ -123,4 +123,46 @@
display: flex; display: flex;
align-items: center; align-items: center;
font-size: xx-large; font-size: xx-large;
}
@keyframes bounce-in {
0% {
left: var(--start-left);
right: var(--start-right);
top: var(--start-top);
bottom: var(--start-bottom);
}
25% {
left: var(--end-left);
right: var(--end-right);
top: var(--end-top);
bottom: var(--end-bottom);
}
50% {
left: var(--start-left);
right: var(--start-right);
top: var(--start-top);
bottom: var(--start-bottom);
}
75% {
left: var(--end-left);
right: var(--end-right);
top: var(--end-top);
bottom: var(--end-bottom);
}
100% {
left: var(--start-left);
right: var(--start-right);
top: var(--start-top);
bottom: var(--start-bottom);
}
}
// TODO zjistit, zda to nedokážeme lépe - tohle je kvůli overflow easter egg obrázků, ale skrývá to úplně scrollbar
html {
overflow-x: hidden;
} }

View File

@@ -10,23 +10,37 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import PizzaOrderList from './components/PizzaOrderList'; import PizzaOrderList from './components/PizzaOrderList';
import SelectSearch, { SelectedOptionValue } from 'react-select-search'; import SelectSearch, { SelectedOptionValue } from 'react-select-search';
import 'react-select-search/style.css'; import 'react-select-search/style.css';
import './App.css'; import './App.scss';
import { SelectSearchOption } from 'react-select-search'; import { SelectSearchOption } from 'react-select-search';
import { faCircleCheck, faTrashCan } from '@fortawesome/free-regular-svg-icons'; import { faCircleCheck, faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { useBank } from './context/bank'; import { useSettings } from './context/settings';
import { ClientData, Restaurants, Food, Order, Locations, PizzaOrder, PizzaDayState, FoodChoices, DayMenu, DepartureTime } from './types'; import { ClientData, Restaurants, Food, Order, Locations, PizzaOrder, PizzaDayState, FoodChoices, DayMenu, DepartureTime, LocationKey } from './types';
import Footer from './components/Footer'; import Footer from './components/Footer';
import { faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons'; import { faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons';
import Loader from './components/Loader'; import Loader from './components/Loader';
import { getData, errorHandler, getQrUrl } from './api/Api'; import { getData, errorHandler, getQrUrl } from './api/Api';
import { addChoice, removeChoices, removeChoice, changeDepartureTime, jdemeObed } from './api/FoodApi'; import { addChoice, removeChoices, removeChoice, changeDepartureTime, jdemeObed, updateNote } from './api/FoodApi';
import { getHumanDateTime } from './Utils'; import { getHumanDateTime, isInTheFuture } from './Utils';
import NoteModal from './components/modals/NoteModal';
import { useEasterEgg } from './context/eggs';
import { getImage } from './api/EasterEggApi';
const EVENT_CONNECT = "connect" 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() { function App() {
const auth = useAuth(); const auth = useAuth();
const bank = useBank(); const settings = useSettings();
const [easterEgg, easterEggLoading] = useEasterEgg(auth);
const [isConnected, setIsConnected] = useState<boolean>(false); const [isConnected, setIsConnected] = useState<boolean>(false);
const [data, setData] = useState<ClientData>(); const [data, setData] = useState<ClientData>();
const [food, setFood] = useState<{ [key in Restaurants]?: DayMenu }>(); const [food, setFood] = useState<{ [key in Restaurants]?: DayMenu }>();
@@ -37,10 +51,13 @@ function App() {
const choiceRef = useRef<HTMLSelectElement>(null); const choiceRef = useRef<HTMLSelectElement>(null);
const foodChoiceRef = useRef<HTMLSelectElement>(null); const foodChoiceRef = useRef<HTMLSelectElement>(null);
const departureChoiceRef = useRef<HTMLSelectElement>(null); const departureChoiceRef = useRef<HTMLSelectElement>(null);
const poznamkaRef = useRef<HTMLInputElement>(null); const pizzaPoznamkaRef = useRef<HTMLInputElement>(null);
const [failure, setFailure] = useState<boolean>(false); const [failure, setFailure] = useState<boolean>(false);
const [dayIndex, setDayIndex] = useState<number>(); const [dayIndex, setDayIndex] = useState<number>();
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false); 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 // 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 // https://medium.com/@kishorkrishna/cant-access-latest-state-inside-socket-io-listener-heres-how-to-fix-it-1522a5abebdb
const dayIndexRef = useRef<number | undefined>(dayIndex); const dayIndexRef = useRef<number | undefined>(dayIndex);
@@ -124,11 +141,8 @@ function App() {
useEffect(() => { useEffect(() => {
if (choiceRef?.current?.value && choiceRef.current.value !== "") { if (choiceRef?.current?.value && choiceRef.current.value !== "") {
// TODO: wtf, cos pil, když jsi tohle psal? const locationKey = choiceRef.current.value as LocationKey;
const key = choiceRef?.current?.value; const restaurantKey = Object.keys(Restaurants).indexOf(locationKey);
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) { if (restaurantKey > -1 && food) {
const restaurant = Object.values(Restaurants)[restaurantKey]; const restaurant = Object.values(Restaurants)[restaurantKey];
setFoodChoiceList(food[restaurant]?.food); setFoodChoiceList(food[restaurant]?.food);
@@ -159,10 +173,27 @@ function App() {
} }
}, [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 doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const index = Object.keys(Locations).indexOf(event.target.value as unknown as Locations); const locationKey = event.target.value as LocationKey;
if (auth?.login) { if (auth?.login) {
await errorHandler(() => addChoice(index, undefined, dayIndex)); await errorHandler(() => addChoice(locationKey, undefined, dayIndex));
if (foodChoiceRef.current?.value) { if (foodChoiceRef.current?.value) {
foodChoiceRef.current.value = ""; foodChoiceRef.current.value = "";
} }
@@ -177,17 +208,16 @@ function App() {
const doAddFoodChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => { const doAddFoodChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (event.target.value && foodChoiceList?.length && choiceRef.current?.value) { if (event.target.value && foodChoiceList?.length && choiceRef.current?.value) {
const restaurantKey = choiceRef.current.value; const locationKey = choiceRef.current.value as LocationKey;
if (auth?.login) { if (auth?.login) {
const locationIndex = Object.keys(Locations).indexOf(restaurantKey as unknown as Locations); await errorHandler(() => addChoice(locationKey, Number(event.target.value), dayIndex));
await errorHandler(() => addChoice(locationIndex, Number(event.target.value), dayIndex));
} }
} }
} }
const doRemoveChoices = async (locationKey: string) => { const doRemoveChoices = async (locationKey: LocationKey) => {
if (auth?.login) { if (auth?.login) {
await errorHandler(() => removeChoices(Number(locationKey), dayIndex)); await errorHandler(() => removeChoices(locationKey, dayIndex));
// Vyresetujeme výběr, aby bylo jasné pro který případně vybíráme jídlo // Vyresetujeme výběr, aby bylo jasné pro který případně vybíráme jídlo
if (choiceRef?.current?.value) { if (choiceRef?.current?.value) {
choiceRef.current.value = ""; choiceRef.current.value = "";
@@ -198,9 +228,9 @@ function App() {
} }
} }
const doRemoveFoodChoice = async (locationKey: string, foodIndex: number) => { const doRemoveFoodChoice = async (locationKey: LocationKey, foodIndex: number) => {
if (auth?.login) { if (auth?.login) {
await errorHandler(() => removeChoice(Number(locationKey), foodIndex, dayIndex)); await errorHandler(() => removeChoice(locationKey, foodIndex, dayIndex));
if (choiceRef?.current?.value) { if (choiceRef?.current?.value) {
choiceRef.current.value = ""; choiceRef.current.value = "";
} }
@@ -210,6 +240,13 @@ function App() {
} }
} }
const saveNote = async (note?: string) => {
if (auth?.login) {
await errorHandler(() => updateNote(note, dayIndex));
setNoteModalOpen(false);
}
}
const pizzaSuggestions = useMemo(() => { const pizzaSuggestions = useMemo(() => {
if (!data?.pizzaList) { if (!data?.pizzaList) {
return []; return [];
@@ -243,12 +280,12 @@ function App() {
await removePizza(pizzaOrder); await removePizza(pizzaOrder);
} }
const handlePoznamkaChange = async () => { const handlePizzaPoznamkaChange = async () => {
if (poznamkaRef.current?.value && poznamkaRef.current.value.length > 100) { if (pizzaPoznamkaRef.current?.value && pizzaPoznamkaRef.current.value.length > 70) {
alert("Poznámka může mít maximálně 100 znaků"); alert("Poznámka může mít maximálně 70 znaků");
return; return;
} }
updatePizzaDayNote(poznamkaRef.current?.value); updatePizzaDayNote(pizzaPoznamkaRef.current?.value);
} }
// const addToCart = async () => { // const addToCart = async () => {
@@ -302,7 +339,7 @@ function App() {
} else if (menu?.food?.length > 0) { } else if (menu?.food?.length > 0) {
content = <Table striped bordered hover> content = <Table striped bordered hover>
<tbody> <tbody>
{menu.food.map((f: any, index: number) => {menu.food.filter(f => (settings?.hideSoups ? !f.isSoup : true)).map((f: any, index: number) =>
<tr key={index}> <tr key={index}>
<td>{f.amount}</td> <td>{f.amount}</td>
<td>{f.name}</td> <td>{f.name}</td>
@@ -314,7 +351,7 @@ function App() {
} else { } else {
content = <h3>Chyba načtení dat</h3> content = <h3>Chyba načtení dat</h3>
} }
return <Col md={12} lg={4}> return <Col md={12} lg={4} className='mt-3'>
<h3>{name}</h3> <h3>{name}</h3>
{menu?.lastUpdate && <small>Poslední aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>} {menu?.lastUpdate && <small>Poslední aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>}
{content} {content}
@@ -352,28 +389,35 @@ function App() {
const noOrders = data?.pizzaDay?.orders?.length === 0; const noOrders = data?.pizzaDay?.orders?.length === 0;
const canChangeChoice = dayIndex == null || data.todayWeekIndex == null || dayIndex >= data.todayWeekIndex; const canChangeChoice = dayIndex == null || data.todayWeekIndex == null || dayIndex >= data.todayWeekIndex;
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
return ( 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 /> <Header />
<div className='wrapper'> <div className='wrapper'>
{data.isWeekend ? <h4>Užívejte víkend :)</h4> : <> {data.isWeekend ? <h4>Užívejte víkend :)</h4> : <>
<Alert variant={'primary'}> <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: Poslední změny:
<ul> <ul>
<li>Tolerance existence menu na více týdnů pro restauraci U Motlíků</li> <li>Zimní atmosféra</li>
<li>Odstranění podniku U Motlíků</li>
</ul> </ul>
</Alert> </Alert>
{dayIndex != null && {dayIndex != null &&
<div className='day-navigator'> <div className='day-navigator'>
{dayIndex > 0 && <FontAwesomeIcon title="Předchozí den" icon={faChevronLeft} style={{ cursor: "pointer" }} onClick={() => handleDayChange(dayIndex - 1)} />} <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> <h1 className='title' style={{ color: dayIndex === data.todayWeekIndex ? 'black' : 'gray' }}>{data.date}</h1>
{dayIndex < 4 && <FontAwesomeIcon title="Následující den" icon={faChevronRight} style={{ cursor: "pointer" }} onClick={() => handleDayChange(dayIndex + 1)} />} <FontAwesomeIcon title="Následující den" icon={faChevronRight} style={{ cursor: "pointer", visibility: dayIndex < 4 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex + 1)} />
</div> </div>
} }
<Row className='food-tables'> <Row className='food-tables'>
{food[Restaurants.SLADOVNICKA] && renderFoodTable('Sladovnická', food[Restaurants.SLADOVNICKA])} {food[Restaurants.SLADOVNICKA] && renderFoodTable('Sladovnická', food[Restaurants.SLADOVNICKA])}
{food[Restaurants.UMOTLIKU] && renderFoodTable('U Motlíků', food[Restaurants.UMOTLIKU])} {/* {food[Restaurants.UMOTLIKU] && renderFoodTable('U Motlíků', food[Restaurants.UMOTLIKU])} */}
{food[Restaurants.TECHTOWER] && renderFoodTable('TechTower', food[Restaurants.TECHTOWER])} {food[Restaurants.TECHTOWER] && renderFoodTable('TechTower', food[Restaurants.TECHTOWER])}
{food[Restaurants.ZASTAVKAUMICHALA] && renderFoodTable('Zastávka u Michala', food[Restaurants.ZASTAVKAUMICHALA])}
</Row> </Row>
<div className='content-wrapper'> <div className='content-wrapper'>
<div className='content'> <div className='content'>
@@ -383,11 +427,8 @@ function App() {
<option></option> <option></option>
{Object.entries(Locations) {Object.entries(Locations)
.filter(entry => { .filter(entry => {
// TODO: wtf, cos pil, když jsi tohle psal? v2 const locationKey = entry[0] as LocationKey;
const key = entry[0]; const restaurantKey = Object.keys(Restaurants).indexOf(locationKey);
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]; const v = Object.values(Restaurants)[restaurantKey];
return v == null || !food[v]?.closed; return v == null || !food[v]?.closed;
}) })
@@ -405,18 +446,25 @@ function App() {
<p style={{ marginTop: "10px" }}>V kolik hodin preferuješ odchod?</p> <p style={{ marginTop: "10px" }}>V kolik hodin preferuješ odchod?</p>
<Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}> <Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}>
<option></option> <option></option>
{Object.values(DepartureTime).map(time => <option key={time} value={time}>{time}</option>)} {Object.values(DepartureTime)
.filter(time => isInTheFuture(time))
.map(time => <option key={time} value={time}>{time}</option>)}
</Form.Select> </Form.Select>
</>} </>}
</>} </>}
{Object.keys(data.choices).length > 0 ? {Object.keys(data.choices).length > 0 ?
<Table bordered className='mt-5'> <Table bordered className='mt-5'>
<tbody> <tbody>
{Object.keys(data.choices).map((locationKey: string) => { {Object.keys(data.choices).map(key => {
const locationName = Object.values(Locations)[Number(locationKey)]; const locationKey = key as LocationKey;
const locationLoginList = Object.entries(data.choices[Number(locationKey)]); const locationName = Locations[locationKey];
const loginObject = data.choices[locationKey];
if (!loginObject) {
return;
}
const locationLoginList = Object.entries(loginObject);
return ( return (
<tr key={locationKey}> <tr key={key}>
<td>{locationName}</td> <td>{locationName}</td>
<td className='p-0'> <td className='p-0'>
<Table> <Table>
@@ -427,27 +475,31 @@ function App() {
const userChoices = userPayload?.options; const userChoices = userPayload?.options;
const trusted = userPayload?.trusted || false; const trusted = userPayload?.trusted || false;
return <tr key={index}> return <tr key={index}>
<td className='text-nowrap'> <td>
{trusted && <span className='trusted-icon'> {trusted && <span className='trusted-icon'>
<FontAwesomeIcon title='Uživatel ověřený doménovým přihlášením' icon={faCircleCheck} style={{ cursor: "help" }} /> <FontAwesomeIcon title='Uživatel ověřený doménovým přihlášením' icon={faCircleCheck} style={{ cursor: "help" }} />
</span>} </span>}
{login} {login}
{userPayload.departureTime && <small> ({userPayload.departureTime})</small>} {userPayload.departureTime && <small> ({userPayload.departureTime})</small>}
{userPayload.note && <small style={{ overflowWrap: 'anywhere' }}> ({userPayload.note})</small>}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => { {login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => {
doRemoveChoices(locationKey); setNoteModalOpen(true);
}} title='Upravit poznámku' className='action-icon' icon={faNoteSticky} />}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => {
doRemoveChoices(key as LocationKey);
}} title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`} className='action-icon' icon={faTrashCan} />} }} title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`} className='action-icon' icon={faTrashCan} />}
</td> </td>
{userChoices?.length && food ? <td className='w-100'> {userChoices?.length && food ? <td>
<ul> <ul>
{userChoices?.map(foodIndex => { {userChoices?.map(foodIndex => {
const locationsKey = Object.keys(Locations)[Number(locationKey)] // TODO narovnat, tohle je zbytečně složité
const restaurantKey = Object.keys(Restaurants).indexOf(locationsKey); const restaurantKey = Object.keys(Restaurants).indexOf(key);
const restaurant = Object.values(Restaurants)[restaurantKey]; const restaurant = Object.values(Restaurants)[restaurantKey];
const foodName = food[restaurant]?.food[foodIndex].name; const foodName = food[restaurant]?.food[foodIndex].name;
return <li key={foodIndex}> return <li key={foodIndex}>
{foodName} {foodName}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => { {login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => {
doRemoveFoodChoice(locationKey, foodIndex); doRemoveFoodChoice(key as LocationKey, foodIndex);
}} title={`Odstranit ${foodName}`} className='action-icon' icon={faTrashCan} />} }} title={`Odstranit ${foodName}`} className='action-icon' icon={faTrashCan} />}
</li> </li>
})} })}
@@ -540,7 +592,7 @@ function App() {
await lockPizzaDay(); await lockPizzaDay();
}}>Vrátit do "uzamčeno"</Button> }}>Vrátit do "uzamčeno"</Button>
<Button className='danger mb-3' style={{ marginLeft: '20px' }} title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => { <Button className='danger mb-3' style={{ marginLeft: '20px' }} title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => {
await finishDelivery(bank?.bankAccount, bank?.holderName); await finishDelivery(settings?.bankAccount, settings?.holderName);
}}>Doručeno</Button> }}>Doručeno</Button>
</div> </div>
} }
@@ -560,16 +612,19 @@ function App() {
options={pizzaSuggestions} options={pizzaSuggestions}
placeholder='Vyhledat pizzu...' placeholder='Vyhledat pizzu...'
onChange={handlePizzaChange} onChange={handlePizzaChange}
onBlur={_ => { }}
onFocus={_ => { }}
/> />
Poznámka: <input ref={poznamkaRef} className='mt-3' type="text" onKeyDown={event => { Poznámka: <input ref={pizzaPoznamkaRef} className='mt-3' type="text" onKeyDown={event => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
handlePoznamkaChange(); handlePizzaPoznamkaChange();
} }
event.stopPropagation();
}} /> }} />
<Button <Button
style={{ marginLeft: '20px' }} style={{ marginLeft: '20px' }}
disabled={!myOrder?.pizzaList?.length} disabled={!myOrder?.pizzaList?.length}
onClick={handlePoznamkaChange}> onClick={handlePizzaPoznamkaChange}>
Uložit Uložit
</Button> </Button>
</div> </div>
@@ -590,6 +645,7 @@ function App() {
</>} </>}
</div> </div>
<Footer /> <Footer />
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
</> </>
); );
} }

View File

@@ -1,14 +1,4 @@
/** import {DepartureTime} from "../../types";
* Vrátí kořenovou URL serveru na základě aktuálního prostředí (vývojovou či produkční).
*
* @returns kořenová URL serveru
*/
export const getBaseUrl = (): string => {
if (process.env.PUBLIC_URL) {
return process.env.PUBLIC_URL;
}
return 'http://127.0.0.1:3001';
}
const TOKEN_KEY = "token"; const TOKEN_KEY = "token";
@@ -55,3 +45,15 @@ export function getHumanDateTime(datetime: Date) {
return `${day}.${month}.${year} ${hours}:${minutes}`; return `${day}.${month}.${year} ${hours}:${minutes}`;
} }
} }
/**
* Vrátí true, pokud je předaný čas větší než aktuální čas.
*/
export function isInTheFuture(time: DepartureTime) {
const now = new Date();
const currentHours = now.getHours();
const currentMinutes = now.getMinutes();
const [hours, minutes] = time.split(':').map(Number);
return hours > currentHours || (hours === currentHours && minutes > currentMinutes);
}

View File

@@ -1,5 +1,5 @@
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { getBaseUrl, getToken } from "../Utils"; import { getToken } from "../Utils";
/** /**
* Wrapper pro volání API, u kterých chceme automaticky zobrazit toaster s chybou ze serveru. * Wrapper pro volání API, u kterých chceme automaticky zobrazit toaster s chybou ze serveru.
@@ -23,13 +23,38 @@ async function request<TResponse>(
config.headers = config?.headers ? new Headers(config.headers) : new Headers(); config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`); config.headers.set("Authorization", `Bearer ${getToken()}`);
try { try {
const response = await fetch(getBaseUrl() + url, config); const response = await fetch(url, config);
if (!response.ok) {
// TODO tohle je blbě, jelikož automaticky očekáváme, že v případě chyby přijde vždy JSON, což není pravda
const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error);
}
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
return response.json() as TResponse;
} else {
return response.text() as TResponse;
}
} catch (e) {
return Promise.reject(e);
}
}
async function blobRequest(
url: string,
config: RequestInit = {}
): Promise<Blob> {
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
try {
const response = await fetch(url, config);
if (!response.ok) { if (!response.ok) {
const json = await response.json(); const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler // Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error); throw new Error(json.error);
} }
return response.json() as TResponse; return response.blob()
} catch (e) { } catch (e) {
return Promise.reject(e); return Promise.reject(e);
} }
@@ -37,11 +62,12 @@ async function request<TResponse>(
export const api = { export const api = {
get: <TResponse>(url: string) => request<TResponse>(url), get: <TResponse>(url: string) => request<TResponse>(url),
post: <TBody extends BodyInit, TResponse>(url: string, body: TBody) => request<TResponse>(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }), blobGet: (url: string) => blobRequest(url),
post: <TBody, TResponse>(url: string, body?: TBody) => request<TResponse>(url, { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }),
} }
export const getQrUrl = (login: string) => { export const getQrUrl = (login: string) => {
return `${getBaseUrl()}/api/qr?login=${login}`; return `/api/qr?login=${login}`;
} }
export const getData = async (dayIndex?: number) => { export const getData = async (dayIndex?: number) => {
@@ -53,5 +79,5 @@ export const getData = async (dayIndex?: number) => {
} }
export const login = async (login?: string) => { export const login = async (login?: string) => {
return await api.post<any, any>('/api/login', JSON.stringify({ login })); return await api.post<any, any>('/api/login', { login });
} }

View File

@@ -0,0 +1,12 @@
import { EasterEgg } from "../types";
import { api } from "./Api";
const EASTER_EGGS_API_PREFIX = '/api/easterEggs';
export const getEasterEgg = async (): Promise<EasterEgg | undefined> => {
return await api.get<EasterEgg>(`${EASTER_EGGS_API_PREFIX}`);
}
export const getImage = async (url: string) => {
return await api.blobGet(`${EASTER_EGGS_API_PREFIX}/${url}`);
}

View File

@@ -1,23 +1,28 @@
import { AddChoiceRequest, ChangeDepartureTimeRequest, LocationKey, RemoveChoiceRequest, RemoveChoicesRequest, UpdateNoteRequest } from "../types";
import { api } from "./Api"; import { api } from "./Api";
const FOOD_API_PREFIX = '/api/food'; const FOOD_API_PREFIX = '/api/food';
export const addChoice = async (locationIndex: number, foodIndex?: number, dayIndex?: number) => { export const addChoice = async (locationKey: LocationKey, foodIndex?: number, dayIndex?: number) => {
return await api.post<any, any>(`${FOOD_API_PREFIX}/addChoice`, JSON.stringify({ locationIndex, foodIndex, dayIndex })); return await api.post<AddChoiceRequest, void>(`${FOOD_API_PREFIX}/addChoice`, { locationKey, foodIndex, dayIndex });
} }
export const removeChoices = async (locationIndex: number, dayIndex?: number) => { export const removeChoices = async (locationKey: LocationKey, dayIndex?: number) => {
return await api.post<any, any>(`${FOOD_API_PREFIX}/removeChoices`, JSON.stringify({ locationIndex, dayIndex })); return await api.post<RemoveChoicesRequest, void>(`${FOOD_API_PREFIX}/removeChoices`, { locationKey, dayIndex });
} }
export const removeChoice = async (locationIndex: number, foodIndex: number, dayIndex?: number) => { export const removeChoice = async (locationKey: LocationKey, foodIndex: number, dayIndex?: number) => {
return await api.post<any, any>(`${FOOD_API_PREFIX}/removeChoice`, JSON.stringify({ locationIndex, foodIndex, dayIndex })); return await api.post<RemoveChoiceRequest, void>(`${FOOD_API_PREFIX}/removeChoice`, { locationKey, foodIndex, dayIndex });
}
export const updateNote = async (note?: string, dayIndex?: number) => {
return await api.post<UpdateNoteRequest, void>(`${FOOD_API_PREFIX}/updateNote`, { note, dayIndex });
} }
export const changeDepartureTime = async (time: string, dayIndex?: number) => { export const changeDepartureTime = async (time: string, dayIndex?: number) => {
return await api.post<any, any>(`${FOOD_API_PREFIX}/changeDepartureTime`, JSON.stringify({ time, dayIndex })); return await api.post<ChangeDepartureTimeRequest, void>(`${FOOD_API_PREFIX}/changeDepartureTime`, { time, dayIndex });
} }
export const jdemeObed = async () => { export const jdemeObed = async () => {
return await api.post<any, any>(`${FOOD_API_PREFIX}/jdemeObed`, JSON.stringify({})); return await api.post<undefined, void>(`${FOOD_API_PREFIX}/jdemeObed`);
} }

View File

@@ -1,44 +1,44 @@
import { PizzaOrder } from "../types"; import { AddPizzaRequest, FinishDeliveryRequest, PizzaOrder, RemovePizzaRequest, UpdatePizzaDayNoteRequest, UpdatePizzaFeeRequest } from "../types";
import { api } from "./Api"; import { api } from "./Api";
const PIZZADAY_API_PREFIX = '/api/pizzaDay'; const PIZZADAY_API_PREFIX = '/api/pizzaDay';
export const createPizzaDay = async () => { export const createPizzaDay = async () => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/create`, undefined); return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/create`);
} }
export const deletePizzaDay = async () => { export const deletePizzaDay = async () => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/delete`, undefined); return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/delete`);
} }
export const lockPizzaDay = async () => { export const lockPizzaDay = async () => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/lock`, undefined); return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/lock`);
} }
export const unlockPizzaDay = async () => { export const unlockPizzaDay = async () => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/unlock`, undefined); return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/unlock`);
} }
export const finishOrder = async () => { export const finishOrder = async () => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/finishOrder`, undefined); return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/finishOrder`);
} }
export const finishDelivery = async (bankAccount?: string, bankAccountHolder?: string) => { export const finishDelivery = async (bankAccount?: string, bankAccountHolder?: string) => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/finishDelivery`, JSON.stringify({ bankAccount, bankAccountHolder })); return await api.post<FinishDeliveryRequest, void>(`${PIZZADAY_API_PREFIX}/finishDelivery`, { bankAccount, bankAccountHolder });
} }
export const addPizza = async (pizzaIndex: number, pizzaSizeIndex: number) => { export const addPizza = async (pizzaIndex: number, pizzaSizeIndex: number) => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/add`, JSON.stringify({ pizzaIndex, pizzaSizeIndex })); return await api.post<AddPizzaRequest, void>(`${PIZZADAY_API_PREFIX}/add`, { pizzaIndex, pizzaSizeIndex });
} }
export const removePizza = async (pizzaOrder: PizzaOrder) => { export const removePizza = async (pizzaOrder: PizzaOrder) => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/remove`, JSON.stringify({ pizzaOrder })); return await api.post<RemovePizzaRequest, void>(`${PIZZADAY_API_PREFIX}/remove`, { pizzaOrder });
} }
export const updatePizzaDayNote = async (note?: string) => { export const updatePizzaDayNote = async (note?: string) => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/updatePizzaDayNote`, JSON.stringify({ note })); return await api.post<UpdatePizzaDayNoteRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaDayNote`, { note });
} }
export const updatePizzaFee = async (login: string, text?: string, price?: number) => { export const updatePizzaFee = async (login: string, text?: string, price?: number) => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/updatePizzaFee`, JSON.stringify({ login, text, price })); return await api.post<UpdatePizzaFeeRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaFee`, { login, text, price });
} }

View File

@@ -1,12 +1,12 @@
import { FeatureRequest } from "../types"; import { FeatureRequest, UpdateFeatureVoteRequest } from "../types";
import { api } from "./Api"; import { api } from "./Api";
const VOTING_API_PREFIX = '/api/voting'; const VOTING_API_PREFIX = '/api/voting';
export const getFeatureVotes = async () => { export const getFeatureVotes = async () => {
return await api.get<any>(`${VOTING_API_PREFIX}/getVotes`); return await api.get<FeatureRequest[]>(`${VOTING_API_PREFIX}/getVotes`);
} }
export const updateFeatureVote = async (option: FeatureRequest, active: boolean) => { export const updateFeatureVote = async (option: FeatureRequest, active: boolean) => {
return await api.post<any, any>(`${VOTING_API_PREFIX}/updateVote`, JSON.stringify({ option, active })); return await api.post<UpdateFeatureVoteRequest, void>(`${VOTING_API_PREFIX}/updateVote`, { option, active });
} }

View File

@@ -2,6 +2,6 @@ import { Navbar } from "react-bootstrap";
export default function Footer() { export default function Footer() {
return <Navbar className="text-light" variant='dark' expand="lg" style={{ display: "flex", justifyContent: "center" }}> return <Navbar className="text-light" variant='dark' expand="lg" style={{ display: "flex", justifyContent: "center" }}>
<span>🄯 Kancelář 51, Marbes s.r.o. Žádná práva nevyhrazena. TODO a zdrojové kódy dostupné <a href="https://gitea.melancholik.eu/mates/Luncher">zde</a>.</span> <span>🄯 Žádná práva nevyhrazena. TODO a zdrojové kódy dostupné <a href="https://gitea.melancholik.eu/mates/Luncher">zde</a>.</span>
</Navbar > </Navbar >
} }

View File

@@ -1,19 +1,21 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Navbar, Nav, NavDropdown } from "react-bootstrap"; import { Navbar, Nav, NavDropdown } from "react-bootstrap";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import BankAccountModal from "./modals/BankAccountModal"; import SettingsModal from "./modals/SettingsModal";
import { useBank } from "../context/bank"; import { useSettings } from "../context/settings";
import FeaturesVotingModal from "./modals/FeaturesVotingModal"; import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import { FeatureRequest } from "../types"; import { FeatureRequest } from "../types";
import { errorHandler } from "../api/Api"; import { errorHandler } from "../api/Api";
import { getFeatureVotes, updateFeatureVote } from "../api/VotingApi"; import { getFeatureVotes, updateFeatureVote } from "../api/VotingApi";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
export default function Header() { export default function Header() {
const auth = useAuth(); const auth = useAuth();
const bank = useBank(); const settings = useSettings();
const [bankModalOpen, setBankModalOpen] = useState<boolean>(false); const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false); const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[]>([]); const [featureVotes, setFeatureVotes] = useState<FeatureRequest[]>([]);
useEffect(() => { useEffect(() => {
@@ -24,14 +26,18 @@ export default function Header() {
} }
}, [auth?.login]); }, [auth?.login]);
const closeBankModal = () => { const closeSettingsModal = () => {
setBankModalOpen(false); setSettingsModalOpen(false);
} }
const closeVotingModal = () => { const closeVotingModal = () => {
setVotingModalOpen(false); setVotingModalOpen(false);
} }
const closePizzaModal = () => {
setPizzaModalOpen(false);
}
const isValidInteger = (str: string) => { const isValidInteger = (str: string) => {
str = str.trim(); str = str.trim();
if (!str) { if (!str) {
@@ -42,7 +48,7 @@ export default function Header() {
return n !== Infinity && String(n) === str && n >= 0; return n !== Infinity && String(n) === str && n >= 0;
} }
const saveBankAccount = (bankAccountNumber?: string, bankAccountHolderName?: string) => { const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => {
if (bankAccountNumber) { if (bankAccountNumber) {
try { try {
// Validace kódu banky // Validace kódu banky
@@ -84,9 +90,10 @@ export default function Header() {
return return
} }
} }
bank?.setBankAccountNumber(bankAccountNumber); settings?.setBankAccountNumber(bankAccountNumber);
bank?.setBankAccountHolderName(bankAccountHolderName); settings?.setBankAccountHolderName(bankAccountHolderName);
closeBankModal(); settings?.setHideSoupsOption(hideSoupsOption);
closeSettingsModal();
} }
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => { const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
@@ -106,13 +113,16 @@ export default function Header() {
<Navbar.Collapse id="basic-navbar-nav"> <Navbar.Collapse id="basic-navbar-nav">
<Nav className="nav"> <Nav className="nav">
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown"> <NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={() => setBankModalOpen(true)}>Nastavit číslo účtu</NavDropdown.Item> <NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item> <NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Divider />
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item> <NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
</NavDropdown> </NavDropdown>
</Nav> </Nav>
</Navbar.Collapse> </Navbar.Collapse>
<BankAccountModal isOpen={bankModalOpen} onClose={closeBankModal} onSave={saveBankAccount} /> <SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} /> <FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
</Navbar> </Navbar>
} }

View File

@@ -19,7 +19,7 @@ export default function FeaturesVotingModal({ isOpen, onClose, onChange, initial
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title> <Modal.Title>
Hlasujte pro nové funkce Hlasujte pro nové funkce
<p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 3 možnosti</p> <p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 4 možnosti</p>
</Modal.Title> </Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>

View File

@@ -0,0 +1,36 @@
import { useRef } from "react";
import { Modal, Button, Form } from "react-bootstrap"
type Props = {
isOpen: boolean,
onClose: () => void,
onSave: (note?: string) => void,
}
/** Modální dialog pro úpravu obecné poznámky. */
export default function NoteModal({ isOpen, onClose, onSave }: Props) {
const note = useRef<HTMLInputElement>(null);
const save = () => {
onSave(note?.current?.value);
}
return <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Úprava poznámky</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Control ref={note} autoFocus={true} type="text" id="note" onKeyDown={event => {
if (event.key === 'Enter') {
save();
}
event.stopPropagation();
}} />
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={save}>
Uložit
</Button>
</Modal.Footer>
</Modal>
}

View File

@@ -0,0 +1,141 @@
import { useRef, useState } from "react";
import { Modal, Button, Row, Col } from "react-bootstrap"
type Props = {
isOpen: boolean,
onClose: () => void,
}
type Result = {
pizza1?: {
diameter?: number,
area?: number,
pricePerM?: number,
},
pizza2?: {
diameter?: number,
area?: number,
pricePerM?: number,
}
choice?: number,
ratio?: number,
diameterDiff?: number,
}
/** Modální dialog pro výpočet výhodnosti pizzy. */
export default function PizzaCalculatorModal({ isOpen, onClose }: Props) {
const diameter1Ref = useRef<HTMLInputElement>(null);
const price1Ref = useRef<HTMLInputElement>(null);
const diameter2Ref = useRef<HTMLInputElement>(null);
const price2Ref = useRef<HTMLInputElement>(null);
const [result, setResult] = useState<Result | null>(null);
const recalculate = () => {
const r: Result = { ...result }
// 1. pizza
if (diameter1Ref.current?.value) {
const diameter1 = parseInt(diameter1Ref.current?.value);
if (!r.pizza1) {
r.pizza1 = {};
}
if (diameter1 && diameter1 > 0) {
r.pizza1.diameter = diameter1;
r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2);
if (price1Ref.current?.value) {
const price1 = parseInt(price1Ref.current?.value);
if (price1) {
r.pizza1.pricePerM = price1 / r.pizza1.area;
} else {
r.pizza1.pricePerM = undefined;
}
}
} else {
r.pizza1.area = undefined;
}
}
// 2. pizza
if (diameter2Ref.current?.value) {
const diameter2 = parseInt(diameter2Ref.current?.value);
if (!r.pizza2) {
r.pizza2 = {};
}
if (diameter2 && diameter2 > 0) {
r.pizza2.diameter = diameter2;
r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2);
if (price2Ref.current?.value) {
const price2 = parseInt(price2Ref.current?.value);
if (price2) {
r.pizza2.pricePerM = price2 / r.pizza2.area;
} else {
r.pizza2.pricePerM = undefined;
}
}
} else {
r.pizza2.area = undefined;
}
}
// Srovnání
if (r.pizza1?.pricePerM && r.pizza2?.pricePerM && r.pizza1.diameter && r.pizza2.diameter) {
r.choice = r.pizza1.pricePerM < r.pizza2.pricePerM ? 1 : 2;
const bigger = r.pizza1.pricePerM > r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM;
const smaller = r.pizza1.pricePerM < r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM;
r.ratio = (bigger / smaller) - 1;
r.diameterDiff = Math.abs(r.pizza1.diameter - r.pizza2.diameter);
} else {
r.choice = undefined;
r.ratio = undefined;
r.diameterDiff = undefined;
}
setResult(r);
}
const close = () => {
setResult(null);
onClose();
}
return <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Pizza kalkulačka</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Zadejte parametry pizzy pro jejich srovnání.</p>
<Row>
<Col size="6">
<input className="mb-3" ref={diameter1Ref} type="number" step="1" min="1" placeholder="Průměr 1. pizzy (cm)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
<Col size="6">
<input className="mb-3" ref={diameter2Ref} type="number" step="1" min="1" placeholder="Průměr 2. pizzy (cm)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
</Row>
<Row>
<Col size="6">
<input className="mb-3" ref={price1Ref} type="number" min="1" placeholder="Cena 1. pizzy (Kč)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
<Col size="6">
<input className="mb-3" ref={price2Ref} type="number" min="1" placeholder="Cena 2. pizzy (Kč)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
</Row>
<Row>
<Col size="6">
{result?.pizza1?.area && <p>Plocha: <b>{Math.round(result.pizza1.area * 10) / 10}</b> cm²</p>}
{result?.pizza1?.pricePerM && <p>Cena za m²: <b>{Math.round(result.pizza1.pricePerM * 1000000) / 100}</b> </p>}
</Col>
<Col size="6">
{result?.pizza2?.area && <p>Plocha: <b>{Math.round(result.pizza2.area * 10) / 10}</b> cm²</p>}
{result?.pizza2?.pricePerM && <p>Cena za m²: <b>{Math.round(result.pizza2.pricePerM * 1000000) / 100}</b> </p>}
</Col>
</Row>
{(result?.choice && result?.ratio && result?.ratio > 0 && result?.diameterDiff != null && <p><b>{result.choice}. pizza</b> je zhruba o <b>{Math.round(result.ratio * 1000) / 10}%</b> výhodnější než {result.choice === 1 ? "2" : "1"}. pizza.</p>) || ''}
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={close}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
}

View File

@@ -1,33 +1,40 @@
import { useRef } from "react"; import { useRef } from "react";
import { Modal, Button } from "react-bootstrap" import { Modal, Button } from "react-bootstrap"
import { useBank } from "../../context/bank"; import { useSettings } from "../../context/settings";
type Props = { type Props = {
isOpen: boolean, isOpen: boolean,
onClose: () => void, onClose: () => void,
onSave: (bankAccountNumber?: string, bankAccountHolderName?: string) => void, onSave: (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => void,
} }
/** Modální dialog pro nastavení čísla účtu a jména majitele. */ /** Modální dialog pro uživatelská nastavení. */
export default function BankAccountModal({ isOpen, onClose, onSave }: Props) { export default function SettingsModal({ isOpen, onClose, onSave }: Props) {
const bank = useBank(); const settings = useSettings();
const bankAccountRef = useRef<HTMLInputElement>(null); const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null); const nameRef = useRef<HTMLInputElement>(null);
const hideSoupsRef = useRef<HTMLInputElement>(null);
return <Modal show={isOpen} onHide={onClose} size="lg"> return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title>Bankovní účet</Modal.Title> <Modal.Title><h2>Nastavení</h2></Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<h4>Obecné</h4>
<span title="V nabídkách nebudou zobrazovány polévky. Tato funkce je experimentální, a zejména u TechTower bývá často problém polévky spolehlivě rozeznat. V případě využití této funkce průběžně nahlašujte stále se zobrazující polévky." style={{ "cursor": "help" }}>
<input ref={hideSoupsRef} type="checkbox" defaultChecked={settings?.hideSoups} /> Skrýt polévky
</span>
<hr />
<h4>Bankovní účet</h4>
<p>Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.<br />Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.<br /><br />Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.</p> <p>Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.<br />Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.<br /><br />Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.</p>
Číslo účtu: <input className="mb-3" ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" defaultValue={bank?.bankAccount} /> <br /> Číslo účtu: <input className="mb-3" ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" defaultValue={settings?.bankAccount} onKeyDown={e => e.stopPropagation()} /> <br />
Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={bank?.holderName} /> Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={settings?.holderName} onKeyDown={e => e.stopPropagation()} />
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button variant="secondary" onClick={onClose}> <Button variant="secondary" onClick={onClose}>
Storno Storno
</Button> </Button>
<Button variant="primary" onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value)}> <Button variant="primary" onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value, hideSoupsRef.current?.checked)}>
Uložit Uložit
</Button> </Button>
</Modal.Footer> </Modal.Footer>

View File

@@ -5,6 +5,7 @@ import { deleteToken, getToken, storeToken } from "../Utils";
export type AuthContextProps = { export type AuthContextProps = {
login?: string, login?: string,
trusted?: boolean,
setToken: (name: string) => void, setToken: (name: string) => void,
logout: () => void, logout: () => void,
} }
@@ -26,6 +27,7 @@ export const useAuth = () => {
function useProvideAuth(): AuthContextProps { function useProvideAuth(): AuthContextProps {
const [loginName, setLoginName] = useState<string | undefined>(); const [loginName, setLoginName] = useState<string | undefined>();
const [trusted, setTrusted] = useState<boolean | undefined>();
const [token, setToken] = useState<string | null>(getToken()); const [token, setToken] = useState<string | null>(getToken());
const { decodedToken } = useJwt(token || ''); const { decodedToken } = useJwt(token || '');
@@ -40,8 +42,10 @@ function useProvideAuth(): AuthContextProps {
useEffect(() => { useEffect(() => {
if (decodedToken) { if (decodedToken) {
setLoginName((decodedToken as any).login); setLoginName((decodedToken as any).login);
setTrusted((decodedToken as any).trusted);
} else { } else {
setLoginName(undefined); setLoginName(undefined);
setTrusted(undefined);
} }
}, [decodedToken]); }, [decodedToken]);
@@ -50,6 +54,7 @@ function useProvideAuth(): AuthContextProps {
const logoutUrl = (decodedToken as any).logoutUrl; const logoutUrl = (decodedToken as any).logoutUrl;
setToken(null); setToken(null);
setLoginName(undefined); setLoginName(undefined);
setTrusted(undefined);
if (trusted && logoutUrl?.length) { if (trusted && logoutUrl?.length) {
window.location.replace(logoutUrl); window.location.replace(logoutUrl);
} }
@@ -57,6 +62,7 @@ function useProvideAuth(): AuthContextProps {
return { return {
login: loginName, login: loginName,
trusted,
setToken, setToken,
logout, logout,
} }

View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
import { getEasterEgg } from "../api/EasterEggApi";
import { AuthContextProps } from "./auth";
import { EasterEgg } from "../types";
export const useEasterEgg = (auth?: AuthContextProps | null): [EasterEgg | undefined, boolean] => {
const [result, setResult] = useState<EasterEgg | undefined>();
const [loading, setLoading] = useState(false);
useEffect(() => {
async function fetchEasterEgg() {
if (auth?.login) {
setLoading(true);
const egg = await getEasterEgg();
setResult(egg);
setLoading(false);
}
}
fetchEasterEgg();
}, [auth?.login]);
return [result, loading];
}

View File

@@ -3,32 +3,36 @@ import { useEffect } from "react"
const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number'; const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name'; const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
const HIDE_SOUPS_KEY = 'hide_soups';
export type BankContextProps = { export type SettingsContextProps = {
bankAccount?: string, bankAccount?: string,
holderName?: string, holderName?: string,
hideSoups?: boolean,
setBankAccountNumber: (accountNumber?: string) => void, setBankAccountNumber: (accountNumber?: string) => void,
setBankAccountHolderName: (holderName?: string) => void, setBankAccountHolderName: (holderName?: string) => void,
setHideSoupsOption: (hideSoups?: boolean) => void,
} }
type ContextProps = { type ContextProps = {
children: ReactNode children: ReactNode
} }
const bankContext = React.createContext<BankContextProps | null>(null); const settingsContext = React.createContext<SettingsContextProps | null>(null);
export function ProvideBank(props: ContextProps) { export function ProvideSettings(props: ContextProps) {
const bank = useProvideBank(); const settings = useProvideSettings();
return <bankContext.Provider value={bank}>{props.children}</bankContext.Provider> return <settingsContext.Provider value={settings}>{props.children}</settingsContext.Provider>
} }
export const useBank = () => { export const useSettings = () => {
return useContext(bankContext); return useContext(settingsContext);
} }
function useProvideBank(): BankContextProps { function useProvideSettings(): SettingsContextProps {
const [bankAccount, setBankAccount] = useState<string | undefined>(); const [bankAccount, setBankAccount] = useState<string | undefined>();
const [holderName, setHolderName] = useState<string | undefined>(); const [holderName, setHolderName] = useState<string | undefined>();
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
useEffect(() => { useEffect(() => {
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY); const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
@@ -39,6 +43,10 @@ function useProvideBank(): BankContextProps {
if (holderName) { if (holderName) {
setHolderName(holderName); setHolderName(holderName);
} }
const hideSoups = localStorage.getItem(HIDE_SOUPS_KEY);
if (hideSoups !== null) {
setHideSoups(hideSoups === 'true' ? true : false);
}
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -57,6 +65,14 @@ function useProvideBank(): BankContextProps {
} }
}, [holderName]); }, [holderName]);
useEffect(() => {
if (hideSoups) {
localStorage.setItem(HIDE_SOUPS_KEY, hideSoups ? 'true' : 'false');
} else {
localStorage.removeItem(HIDE_SOUPS_KEY);
}
}, [hideSoups]);
function setBankAccountNumber(bankAccount?: string) { function setBankAccountNumber(bankAccount?: string) {
setBankAccount(bankAccount); setBankAccount(bankAccount);
} }
@@ -65,10 +81,16 @@ function useProvideBank(): BankContextProps {
setHolderName(holderName); setHolderName(holderName);
} }
function setHideSoupsOption(hideSoups?: boolean) {
setHideSoups(hideSoups);
}
return { return {
bankAccount, bankAccount,
holderName, holderName,
hideSoups,
setBankAccountNumber, setBankAccountNumber,
setBankAccountHolderName, setBankAccountHolderName,
setHideSoupsOption,
} }
} }

View File

@@ -4,9 +4,10 @@ import App from './App';
import { SocketContext, socket } from './context/socket'; import { SocketContext, socket } from './context/socket';
import { ProvideAuth } from './context/auth'; import { ProvideAuth } from './context/auth';
import { ToastContainer } from 'react-toastify'; import { ToastContainer } from 'react-toastify';
import { ProvideBank } from './context/bank'; import { ProvideSettings } from './context/settings';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import './index.css'; import './index.css';
import Snowfall from 'react-snowfall';
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement document.getElementById('root') as HTMLElement
@@ -14,12 +15,19 @@ const root = ReactDOM.createRoot(
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<ProvideAuth> <ProvideAuth>
<ProvideBank> <ProvideSettings>
<SocketContext.Provider value={socket}> <SocketContext.Provider value={socket}>
<App /> <>
<Snowfall style={{
zIndex: 2,
position: 'fixed',
width: '100vw',
height: '100vh'}} />
<App />
</>
<ToastContainer /> <ToastContainer />
</SocketContext.Provider> </SocketContext.Provider>
</ProvideBank> </ProvideSettings>
</ProvideAuth> </ProvideAuth>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -1 +0,0 @@
/// <reference types="react-scripts" />

View File

@@ -1,11 +1,14 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "ESNext",
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",
"esnext" "esnext"
], ],
"types": [
"vite/client"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,

1
client/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

16
client/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import viteTsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
// depending on your application, base can also be "/"
base: '',
plugins: [react(), viteTsconfigPaths()],
server: {
open: true,
port: 3000,
proxy: {
'/api': 'http://localhost:3001',
}
},
})

1556
client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,3 @@
version: '3.8'
services: services:
redis: redis:
image: redis/redis-stack-server:7.2.0-RC3 image: redis/redis-stack-server:7.2.0-RC3

View File

@@ -1,7 +0,0 @@
{
"private": true,
"workspaces": [
"client",
"server"
]
}

View File

@@ -28,3 +28,13 @@
#NTFY_HOST = "http://192.168.0.113:80" #NTFY_HOST = "http://192.168.0.113:80"
#NTFY_USERNAME="username" #NTFY_USERNAME="username"
#NTFY_PASSWD="password" #NTFY_PASSWD="password"
# Zapne přihlašování pomocí důvěryhodných hlaviček (trusted headers). Výchozí hodnota je false.
# V případě zapnutí je nutno vyplnit také HTTP_REMOTE_TRUSTED_IPS.
# HTTP_REMOTE_USER_ENABLED=true
# Seznam IP adres nebo rozsahů oddělených čárkou, ze kterých budou akceptovány důvěryhodné hlavičky.
# HTTP_REMOTE_TRUSTED_IPS=127.0.0.1,192.168.1.0/24
# Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'.
# HTTP_REMOTE_USER_HEADER_NAME=remote-user

4
server/.gitignore vendored
View File

@@ -2,4 +2,6 @@
/dist /dist
data.json data.json
.env.production .env.production
.env.development .env.development
.easter-eggs.json
/resources/easterEggs

View File

@@ -6,7 +6,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"start": "ts-node src/index.ts", "start": "ts-node src/index.ts",
"startReload": "nodemon src/index.ts", "startReload": "nodemon --watch src src/index.ts",
"build": "tsc -p .", "build": "tsc -p .",
"test": "jest" "test": "jest"
}, },
@@ -15,12 +15,13 @@
"@babel/preset-env": "^7.22.20", "@babel/preset-env": "^7.22.20",
"@babel/preset-typescript": "^7.23.0", "@babel/preset-typescript": "^7.23.0",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.2", "@types/jest": "^29.5.14",
"@types/node": "^20.2.5", "@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.11.20",
"@types/request-promise": "^4.1.48", "@types/request-promise": "^4.1.48",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"nodemon": "^2.0.22", "nodemon": "^3.1.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.0.2" "typescript": "^5.0.2"
}, },
@@ -28,11 +29,11 @@
"axios": "^1.4.0", "axios": "^1.4.0",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.1.3", "dotenv": "^16.4.5",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"redis": "^4.6.7", "redis": "^4.6.7",
"simple-json-db": "^2.0.0", "simple-json-db": "^2.0.0",
"socket.io": "^4.6.1" "socket.io": "^4.6.1"
} }
} }

View File

@@ -11,6 +11,7 @@ import { initWebsocket } from "./websocket";
import pizzaDayRoutes from "./routes/pizzaDayRoutes"; import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes from "./routes/foodRoutes"; import foodRoutes from "./routes/foodRoutes";
import votingRoutes from "./routes/votingRoutes"; import votingRoutes from "./routes/votingRoutes";
import easterEggRoutes from "./routes/easterEggRoutes";
const ENVIRONMENT = process.env.NODE_ENV || 'production'; const ENVIRONMENT = process.env.NODE_ENV || 'production';
dotenv.config({ path: path.resolve(__dirname, `./.env.${ENVIRONMENT}`) }); dotenv.config({ path: path.resolve(__dirname, `./.env.${ENVIRONMENT}`) });
@@ -31,26 +32,48 @@ app.use(cors({
origin: '*' origin: '*'
})); }));
// Zapínatelný login přes hlavičky - pokud je zapnutý nepovolí "basicauth"
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME || 'remote-user';
if (HTTP_REMOTE_USER_ENABLED) {
if (!process.env.HTTP_REMOTE_TRUSTED_IPS) {
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
}
const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim());
//TODO: nevim jak udelat console.log pouze pro "debug"
//console.log("Budu věřit hlavičkám z: " + HTTP_REMOTE_TRUSTED_IPS);
app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS);
console.log('Zapnutý login přes hlavičky z proxy.');
}
// ----------- Metody nevyžadující token -------------- // ----------- Metody nevyžadující token --------------
app.get("/api/whoami", (req, res) => { app.get("/api/whoami", (req, res) => {
res.send(req.header('remote-user')); if (!HTTP_REMOTE_USER_ENABLED) {
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
}
res.send(req.header(HTTP_REMOTE_USER_HEADER_NAME));
}) })
app.post("/api/login", (req, res) => { app.post("/api/login", (req, res) => {
// Autentizace pomocí trusted headers if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy')
const remoteUser = req.header('remote-user'); // Autentizace pomocí trusted headers
const remoteName = req.header('remote-name'); const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
if (remoteUser && remoteUser.length > 0 && remoteName && remoteName.length > 0) { const remoteName = req.header('remote-name');
res.status(200).json(generateToken(Buffer.from(remoteName, 'latin1').toString(), true)); if (remoteUser && remoteUser.length > 0 && remoteName && remoteName.length > 0) {
return; res.status(200).json(generateToken(Buffer.from(remoteName, 'latin1').toString(), true));
} else {
throw Error("Tohle nema nastat nekdo neco dela spatne.");
}
} else {
// Klasická autentizace loginem
if (!req.body?.login || req.body.login.trim().length === 0) {
throw Error("Nebyl předán login");
}
// TODO zavést podmínky pro délku loginu (min i max)
res.status(200).json(generateToken(req.body.login, false));
} }
// Klasická autentizace loginem
if (!req.body?.login || req.body.login.trim().length === 0) {
throw Error("Nebyl předán login");
}
// TODO zavést podmínky pro délku loginu (min i max)
res.status(200).json(generateToken(req.body.login, false));
}); });
// TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token // TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token
@@ -71,12 +94,14 @@ app.get("/api/qr", (req, res) => {
/** Middleware ověřující JWT token */ /** Middleware ověřující JWT token */
app.use("/api/", (req, res, next) => { app.use("/api/", (req, res, next) => {
const userHeader = req.header('remote-user'); if (HTTP_REMOTE_USER_ENABLED) {
const nameHeader = req.header('remote-name'); const userHeader = req.header(HTTP_REMOTE_USER_HEADER_NAME);
const emailHeader = req.header('remote-email'); const nameHeader = req.header('remote-name');
if (userHeader !== undefined && nameHeader !== undefined) { const emailHeader = req.header('remote-email');
const remoteName = Buffer.from(nameHeader, 'latin1').toString(); if (userHeader !== undefined && nameHeader !== undefined) {
console.log("Tvuj username, name a email: %s, %s, %s.", userHeader, remoteName, emailHeader); const remoteName = Buffer.from(nameHeader, 'latin1').toString();
console.log("Tvuj username, name a email: %s, %s, %s.", userHeader, remoteName, emailHeader);
}
} }
if (!req.headers.authorization) { if (!req.headers.authorization) {
return res.status(401).json({ error: 'Nebyl předán autentizační token' }); return res.status(401).json({ error: 'Nebyl předán autentizační token' });
@@ -104,6 +129,7 @@ app.get("/api/data", async (req, res) => {
app.use("/api/pizzaDay", pizzaDayRoutes); app.use("/api/pizzaDay", pizzaDayRoutes);
app.use("/api/food", foodRoutes); app.use("/api/food", foodRoutes);
app.use("/api/voting", votingRoutes); app.use("/api/voting", votingRoutes);
app.use("/api/easterEggs", easterEggRoutes);
app.use(express.static('public')) app.use(express.static('public'))
// Middleware pro zpracování chyb // Middleware pro zpracování chyb

View File

@@ -1,9 +1,7 @@
import { getDayOfWeekIndex } from "./utils";
// Mockovací data pro podporované podniky, na jeden týden // Mockovací data pro podporované podniky, na jeden týden
const MOCK_DATA = { const MOCK_DATA = {
'sladovnicka': [ 'sladovnicka': {
[ 'MONDAY': [
{ {
amount: "0,25l", amount: "0,25l",
name: "Kulajda", name: "Kulajda",
@@ -29,7 +27,7 @@ const MOCK_DATA = {
isSoup: false, isSoup: false,
} }
], ],
[ 'TUESDAY': [
{ {
amount: "0,25l", amount: "0,25l",
name: "Hovězí vývar s kapáním", name: "Hovězí vývar s kapáním",
@@ -55,7 +53,7 @@ const MOCK_DATA = {
isSoup: false, isSoup: false,
} }
], ],
[ 'WEDNESDAY': [
{ {
amount: "0,25l", amount: "0,25l",
name: "Zelná polévka s klobásou", name: "Zelná polévka s klobásou",
@@ -81,7 +79,7 @@ const MOCK_DATA = {
isSoup: false, isSoup: false,
} }
], ],
[ 'THURSDAY': [
{ {
amount: "0,25l", amount: "0,25l",
name: "Kuřecí vývar s nudlemi", name: "Kuřecí vývar s nudlemi",
@@ -107,7 +105,7 @@ const MOCK_DATA = {
isSoup: false, isSoup: false,
} }
], ],
[ 'FRIDAY': [
{ {
amount: "0,25l", amount: "0,25l",
name: "Dršťková polévka", name: "Dršťková polévka",
@@ -133,7 +131,7 @@ const MOCK_DATA = {
isSoup: false, isSoup: false,
} }
] ]
], },
'uMotliku': [ 'uMotliku': [
[ [
{ {
@@ -277,13 +275,25 @@ const MOCK_DATA = {
{ {
amount: "-", amount: "-",
name: "Čočka na kyselo, opečená klobása, okurka, chléb", name: "Čočka na kyselo, opečená klobása, okurka, chléb",
price: "120\xA0Kč", price: "130\xA0Kč",
isSoup: false, isSoup: false,
}, },
{ {
amount: "-", amount: "-",
name: "Kuřecí medailonky se sýrovou omáčkou, hranolky", name: "Smažená brokolice, brambory, tatarská omáčka",
price: "170\xA0", price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Uzený vepřový bůček, bramborové pyré",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Kuřecí medailonky v sýrové omáčce, hranolky",
price: "na\xA0váhu",
isSoup: false, isSoup: false,
} }
], ],
@@ -297,13 +307,25 @@ const MOCK_DATA = {
{ {
amount: "-", amount: "-",
name: "Zvěřinový guláš, knedlík", name: "Zvěřinový guláš, knedlík",
price: "120\xA0Kč", price: "130\xA0Kč",
isSoup: false, isSoup: false,
}, },
{ {
amount: "-", amount: "-",
name: "Smažený hermelín, brambory, tatarská omáčka", name: "Čínské nudle se zeleninou a vejcem",
price: "170\xA0", price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Jitrnice/jelito, brambory, zelný salát s křenem, hořčice",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Vídeňská roštěná se smaženou cibulkou, jasmínová rýže",
price: "na\xA0váhu",
isSoup: false, isSoup: false,
} }
], ],
@@ -317,13 +339,25 @@ const MOCK_DATA = {
{ {
amount: "-", amount: "-",
name: "Kuřecí směs se zeleninou, rýže", name: "Kuřecí směs se zeleninou, rýže",
price: "120\xA0Kč", price: "130\xA0Kč",
isSoup: false, isSoup: false,
}, },
{ {
amount: "-", amount: "-",
name: "Hambuger Black Angus s čedarem a slaninou, cibulové kroužky", name: "Tvarohové knedlíky s meruňkami, strouhaný tvaroh, máslo, cukr",
price: "220\xA0", price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Ovar, křen, hořčice, pečivo",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Telecí holandský řízek s uzeným sýrem, bramborové pyré",
price: "na\xA0váhu",
isSoup: false, isSoup: false,
} }
], ],
@@ -337,13 +371,25 @@ const MOCK_DATA = {
{ {
amount: "-", amount: "-",
name: "Rizoto s vepřovým masem, okurka", name: "Rizoto s vepřovým masem, okurka",
price: "120\xA0Kč", price: "130\xA0Kč",
isSoup: false, isSoup: false,
}, },
{ {
amount: "-", amount: "-",
name: "Steak z lososa, grilovaná zelenina", name: "Tortellini s parmezánovou omáčkou",
price: "220\xA0", price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Pečený prejt, brambory, zelný salát",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Chobotnice na grilu, grilovaná zelenina, bylinková bageta",
price: "na\xA0váhu",
isSoup: false, isSoup: false,
} }
], ],
@@ -357,16 +403,118 @@ const MOCK_DATA = {
{ {
amount: "-", amount: "-",
name: "Krůtí perkelt, těstoviny", name: "Krůtí perkelt, těstoviny",
price: "120\xA0Kč", price: "130\xA0Kč",
isSoup: false, isSoup: false,
}, },
{ {
amount: "-", amount: "-",
name: "Grilovaná vepřová panenka, parmazánové pyré", name: "Grilovaný hermelín, bulgurový salát se zeleninou",
price: "170\xA0", price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Zabijačkový guláš, karlovarský knedlík",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Vepřový plátek na žampionech, jasmínová rýže",
price: "na\xA0váhu",
isSoup: false, isSoup: false,
} }
] ]
],
'zastavkaUmichala': [
[
{
amount: "-",
name: "Fazolačka s klobásou & zakysačkou",
price: "39\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Zeleninová musaka lilek, cuketa, tomatové sugo & sýrový bešamel",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Slovácké strapačky s uzenou slaninou, zelím, mletým pepřem & sekanou petrželkou",
price: "140\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Hovězí guláš s vejcem, zeleninovou garniturkou & žemlovými knedlíky",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Kuřecí roláda s kaštanovou nádivkou, demi-glace & smetanovou bramborovou kaší",
price: "150\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Hovězí vývar se zeleninou a játrovou rýží",
price: "39\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Pečené vepřové koleno, křen, hořčice, chléb",
price: "320\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Zeleninová polévka s kuskusem",
price: "39\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Poutine (trhané vepřové, hranolky, sýr, čalamáda, pikantní omáčka)",
price: "190\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Hrachová polévka s uzeninou",
price: "39\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Vepřový řízek z kotlety, domácí bramborový salát",
price: "170\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Cibulačka se sýrem",
price: "39\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Burger z Chuck rollu, hranolky, tatarská omáčka",
price: "200\xA0Kč",
isSoup: false,
}
],
] ]
} }
@@ -1120,8 +1268,11 @@ const MOCK_PIZZA_LIST = [
} }
] ]
export const getTodayMock = () => { /**
return '2023-05-31'; // středa * Funkce vrací mock datu ve formátu YYYY-MM-DD
*/
export const getTodayMock = (): Date => {
return new Date('2025-01-08'); // pátek
} }
export const getMenuSladovnickaMock = () => { export const getMenuSladovnickaMock = () => {
@@ -1136,6 +1287,10 @@ export const getMenuTechTowerMock = () => {
return MOCK_DATA['techTower']; return MOCK_DATA['techTower'];
} }
export const getMenuZastavkaUmichalaMock = () => {
return MOCK_DATA['zastavkaUmichala'];
}
export const getPizzaListMock = () => { export const getPizzaListMock = () => {
return MOCK_PIZZA_LIST; return MOCK_PIZZA_LIST;
} }

View File

@@ -1,16 +1,32 @@
import axios from "axios"; import axios from "axios";
import { load } from 'cheerio'; import { load } from 'cheerio';
import { Food } from "../../types"; import { DayOfWeek, DayOfWeekEnum, DayOfWeekIndex, Food, RestaurantWeeklyMenu } from "../../types";
import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock } from "./mock"; import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock, getMenuZastavkaUmichalaMock } from "./mock";
// Fráze v názvech jídel, které naznačují že se jedná o polévku // Fráze v názvech jídel, které naznačují že se jedná o polévku
const SOUP_NAMES = ['polévka', 'česnečka', 'česnekový krém', 'cibulačka', 'vývar'] const SOUP_NAMES = [
'polévka',
'česnečka',
'česnekový krém',
'cibulačka',
'vývar',
'fazolová',
'cuketový krém',
'boršč',
'slepičí s ',
'zeleninová s ',
'hovězí s ',
'kachní kaldoun',
'dršťková'
];
const DAYS_IN_WEEK = ['pondělí', 'úterý', 'středa', 'čtvrtek', 'pátek', 'sobota', 'neděle']; const DAYS_IN_WEEK = ['pondělí', 'úterý', 'středa', 'čtvrtek', 'pátek', 'sobota', 'neděle'];
// URL na týdenní menu jednotlivých restaurací // URL na týdenní menu jednotlivých restaurací
const SLADOVNICKA_URL = 'https://sladovnicka.unasplzenchutna.cz/cz/denni-nabidka'; const SLADOVNICKA_URL = 'https://sladovnicka.unasplzenchutna.cz/cz/denni-nabidka';
const U_MOTLIKU_URL = 'https://www.umotliku.cz/menu'; const U_MOTLIKU_URL = 'https://www.umotliku.cz/menu';
const TECHTOWER_URL = 'https://www.equifarm.cz/restaurace-techtower'; const TECHTOWER_URL = 'https://www.equifarm.cz/restaurace-techtower';
const ZASTAVKAUMICHALA_URL = 'https://www.zastavkaumichala.cz';
const SENKSERIKOVA_URL = 'https://www.menicka.cz/6561-pivovarsky-senk-serikova.html';
/** /**
* Vrátí true, pokud předaný text obsahuje některé ze slov, které naznačuje, že se jedná o polévku. * Vrátí true, pokud předaný text obsahuje některé ze slov, které naznačuje, že se jedná o polévku.
@@ -53,7 +69,7 @@ const getHtml = async (url: string): Promise<any> => {
* @param mock zda vrátit mock data * @param mock zda vrátit mock data
* @returns seznam jídel pro daný týden * @returns seznam jídel pro daný týden
*/ */
export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => { export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = false): Promise<RestaurantWeeklyMenu> => {
if (mock) { if (mock) {
return getMenuSladovnickaMock(); return getMenuSladovnickaMock();
} }
@@ -62,7 +78,8 @@ export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = f
const $ = load(html); const $ = load(html);
const list = $('ul.tab-links').children(); const list = $('ul.tab-links').children();
const result: Food[][] = []; const result: RestaurantWeeklyMenu = {};
// TODO upravit až bude enum
for (let dayIndex = 0; dayIndex < 5; dayIndex++) { for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
const currentDate = new Date(firstDayOfWeek); const currentDate = new Date(firstDayOfWeek);
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex); currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
@@ -80,7 +97,8 @@ export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = f
}) })
if (index === undefined) { if (index === undefined) {
// Pravděpodobně svátek, nebo je zavřeno // Pravděpodobně svátek, nebo je zavřeno
result[dayIndex] = [{ const index: number = Object.keys(DayOfWeekEnum).indexOf('Casual'); // 1
result[dayIndex as DayOfWeekEnum] = [{
amount: undefined, amount: undefined,
name: "Pro daný den nebyla nalezena denní nabídka", name: "Pro daný den nebyla nalezena denní nabídka",
price: "", price: "",
@@ -137,7 +155,7 @@ export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = f
isSoup: false, isSoup: false,
}); });
}) })
result[index] = currentDayFood; result[dayIndex] = currentDayFood;
} }
return result; return result;
} }
@@ -157,23 +175,30 @@ export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = fals
const html = await getHtml(U_MOTLIKU_URL); const html = await getHtml(U_MOTLIKU_URL);
const $ = load(html); const $ = load(html);
// Najdeme první tabulku, nad kterou je v H3 datum začínající prvním dnem aktuálního týdne // Najdeme první tabulku, nad kterou je v H3 datum začínající co nejdřívějším dnem v aktuálním týdnu
// To může selhat mnoha způsoby, ale ty nemá cenu řešit dokud nenastanou
const tables = $('table.table.table-hover.Xtable-striped'); const tables = $('table.table.table-hover.Xtable-striped');
let usedTable; let usedTable;
const firstDayOfWeekString = `${firstDayOfWeek.getDate()}.${firstDayOfWeek.getMonth() + 1}.`; let usedDate = new Date(firstDayOfWeek.getTime());
for (const tableNode of tables) { for (let i = 0; i < 4; i++) {
const table = $(tableNode); const dayOfWeekString = `${usedDate.getDate()}.${usedDate.getMonth() + 1}.`;
const h3 = table.parent().prev(); for (const tableNode of tables) {
const s1 = h3.text().split("-")[0].split("."); const table = $(tableNode);
const foundFirstDayString = `${s1[0]}.${s1[1]}.`; const h3 = table.parent().prev();
if (foundFirstDayString === firstDayOfWeekString) { const s1 = h3.text().split("-")[0].split(".");
usedTable = table; const foundFirstDayString = `${s1[0]}.${s1[1]}.`;
if (foundFirstDayString === dayOfWeekString) {
usedTable = table;
}
} }
if (usedTable != null) {
break;
}
usedDate.setDate(usedDate.getDate() + 1);
} }
if (usedTable == null) { if (usedTable == null) {
throw Error(`Nepodařilo se najít tabulku pro datum ${firstDayOfWeekString}`); const firstDayOfWeekString = `${firstDayOfWeek.getDate()}.${firstDayOfWeek.getMonth() + 1}.`;
throw Error(`Nepodařilo se najít tabulku pro týden začínající ${firstDayOfWeekString}`);
} }
const body = usedTable.children().first(); const body = usedTable.children().first();
@@ -243,46 +268,55 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
const html = await getHtml(TECHTOWER_URL); const html = await getHtml(TECHTOWER_URL);
const $ = load(html); const $ = load(html);
const fonts = $('font.wsw-41'); let secondTry = false;
// První pokus - varianta "Obědy"
let fonts = $('font.wsw-41');
let font = undefined; let font = undefined;
fonts.each((i, f) => { fonts.each((i, f) => {
if ($(f).text().trim().startsWith('Obědy')) { if ($(f).text().trim().startsWith('Obědy')) {
font = f; font = f;
} }
}) })
// Druhý pokus - varianta "Jídelní lístek"
if (!font) {
fonts = $('font.wnd-font-size-90');
fonts.each((i, f) => {
if ($(f).text().trim().startsWith('Jídelní lístek')) {
font = f;
secondTry = true;
}
})
}
if (!font) { if (!font) {
throw Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.'); throw Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
} }
const result: Food[][] = []; const result: Food[][] = [];
// TODO validovat, že v textu nalezeného <font> je rozsah, do kterého spadá vstupní datum // TODO validovat, že v textu nalezeného <font> je rozsah, do kterého spadá vstupní datum
const siblings = $(font).parent().parent().siblings(); const siblings = secondTry ? $(font).parent().siblings() : $(font).parent().parent().siblings();
let parsing = false; let parsing = false;
let currentDayIndex = 0; let currentDayIndex = 0;
for (let i = 0; i < siblings.length; i++) { for (let i = 0; i < siblings.length; i++) {
const text = $(siblings.get(i)).text().trim().replace('\t', '').replace('\n', ' '); const text = $(siblings.get(i)).text().trim().replace('\t', '').replace('\n', ' ');
if (DAYS_IN_WEEK.includes(text)) { if (DAYS_IN_WEEK.includes(text.toLocaleLowerCase())) {
if (text === DAYS_IN_WEEK[currentDayIndex]) { // Zjistíme aktuální index
// Našli jsme dnešní den, odtud začínáme parsovat jídla currentDayIndex = DAYS_IN_WEEK.indexOf(text.toLocaleLowerCase());
if (!parsing) {
// Našli jsme libovolný den v týdnu a ještě neparsujeme, tak začneme
parsing = true; parsing = true;
continue
} else if (parsing) {
// Už parsujeme jídla, ale narazili jsme na následující den - posouváme index
currentDayIndex += 1;
continue;
} }
} else if (parsing) { } else if (parsing) {
if (text.length == 0) { if (text.length == 0) {
// Prázdná řádka - končíme (je za pátečním menu TechTower) // Prázdná řádka - bývá na zcela náhodných místech ¯\_(ツ)_/¯
break; continue;
} }
let price = '? Kč'; let price = 'na\xA0váhu';
let name = text; let name = text.replace('•', '');
if (text.toLowerCase().endsWith('kč')) { if (text.toLowerCase().endsWith('kč')) {
const tmp = text.replace('\xA0', ' ').split(' '); const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2)); const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
price = `${split.slice(1)[0]}\xA0Kč` price = `${split.slice(1)[0]}\xA0Kč`
name = split[0] name = split[0].replace('•', '');
} }
if (result[currentDayIndex] == null) { if (result[currentDayIndex] == null) {
result[currentDayIndex] = []; result[currentDayIndex] = [];
@@ -296,4 +330,54 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
} }
} }
return result; return result;
} }
/**
* Získá obědovou nabídku ZastavkaUmichala pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuZastavkaUmichala = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuZastavkaUmichalaMock();
}
const nowDate = new Date().getDate();
const result: Food[][] = [];
for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
const currentDate = new Date(firstDayOfWeek);
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
// if (currentDate < now) {
if (currentDate.getDate() !== nowDate) {
result[dayIndex] = [{
amount: undefined,
name: "Pro tento den není uveřejněna nabídka jídel",
price: "",
isSoup: false,
}];
continue;
} else {
// let dateString = formatDate(currentDate, 'DD.MM.YYYY');
// const html = await getHtml(ZASTAVKAUMICHALA_URL + '/?do=dailyMenu-changeDate&dailyMenu-dateString=' + dateString);
const html = await getHtml(ZASTAVKAUMICHALA_URL);
const $ = load(html);
// const row = $($('.foodsList li')[0]).text();
const currentDayFood: Food[] = [];
$('.foodsList li').each((index, element) => {
currentDayFood.push({
amount: '-',
name: sanitizeText($(element).contents().not('span').text()),
price: sanitizeText($(element).find('span').text()),
isSoup: (index === 0),
});
});
result[dayIndex] = currentDayFood;
}
}
return result;
}

View File

@@ -0,0 +1,156 @@
import express, { NextFunction } from "express";
import { getLogin, getTrusted } from "../auth";
import { parseToken } from "../utils";
import path from "path";
import fs from "fs";
import { EasterEgg } from "../../../types";
const EASTER_EGGS_JSON_PATH = path.join(__dirname, "../../.easter-eggs.json");
const IMAGES_PATH = '../../resources/easterEggs';
type EasterEggsJson = {
[key: string]: EasterEgg[]
}
function generateUrl() {
let result = '';
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
let counter = 0;
while (counter < 32) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
counter += 1;
}
return result;
}
/**
* Vrátí náhodně jeden z definovaných easter egg obrázků pro přihlášeného uživatele.
*
* @param req request
* @param res response
* @param next next
* @returns náhodný easter egg obrázek, nebo 404 pokud žádný není definován
*/
function getEasterEggImage(req: any, res: any, next: NextFunction) {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
try {
// TODO vrátit!
// if (trusted) {
if (true) {
if (login in easterEggs) {
const imagePath = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)].path;
res.sendFile(path.join(__dirname, IMAGES_PATH, imagePath));
return;
}
}
res.sendStatus(404);
} catch (e: any) { next(e) }
}
function getRandomPosition(startOffset: number, endOffset: number) {
const choice = Math.floor(Math.random() * 4);
if (choice === 0) {
// Vlevo nahoře
return {
left: `${startOffset}px`,
startLeft: `${startOffset}px`,
"--start-left": `${startOffset}px`,
top: `${startOffset}px`,
startTop: `${startOffset}px`,
"--start-top": `${startOffset}px`,
endLeft: `${endOffset}px`,
"--end-left": `${endOffset}px`,
endTop: `${endOffset}px`,
"--end-top": `${endOffset}px`,
rotate: '135deg',
}
} else if (choice === 1) {
// Vpravo nahoře
return {
right: `${startOffset}px`,
startRight: `${startOffset}px`,
"--start-right": `${startOffset}px`,
top: `${startOffset}px`,
startTop: `${startOffset}px`,
"--start-top": `${startOffset}px`,
endRight: `${endOffset}px`,
"--end-right": `${endOffset}px`,
endTop: `${endOffset}px`,
"--end-top": `${endOffset}px`,
rotate: '-135deg',
}
} else if (choice === 2) {
// Vpravo dole
return {
right: `${startOffset}px`,
startRight: `${startOffset}px`,
"--start-right": `${startOffset}px`,
bottom: `${startOffset}px`,
startBottom: `${startOffset}px`,
"--start-bottom": `${startOffset}px`,
endRight: `${endOffset}px`,
"--end-right": `${endOffset}px`,
endBottom: `${endOffset}px`,
"--end-bottom": `${endOffset}px`,
rotate: '-45deg',
}
} else if (choice === 3) {
// Vlevo dole
return {
left: `${startOffset}px`,
startLeft: `${startOffset}px`,
"--start-left": `${startOffset}px`,
bottom: `${startOffset}px`,
startBottom: `${startOffset}px`,
"--start-bottom": `${startOffset}px`,
endLeft: `${endOffset}px`,
"--end-left": `${endOffset}px`,
endBottom: `${endOffset}px`,
"--end-bottom": `${endOffset}px`,
rotate: '45deg',
}
}
}
const router = express.Router();
let easterEggs: EasterEggsJson;
// Registrace náhodných URL pro všechny existující easter eggy
if (fs.existsSync(EASTER_EGGS_JSON_PATH)) {
const content = fs.readFileSync(EASTER_EGGS_JSON_PATH, 'utf-8');
easterEggs = JSON.parse(content);
for (const [key, eggs] of Object.entries(easterEggs)) {
for (const easterEgg of eggs) {
const url = generateUrl();
easterEgg.url = url;
router.get(`/${url}`, async (req, res, next) => {
return getEasterEggImage(req, res, next);
});
}
}
}
// Získání náhodného easter eggu pro přihlášeného uživatele
router.get("/", async (req, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
try {
// TODO vrátit!
// if (trusted) {
if (true) {
if (easterEggs && login in easterEggs) {
const randomEasterEgg = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)];
const { path, startOffset, endOffset, ...strippedEasterEgg } = randomEasterEgg; // Path klient k ničemu nepotřebuje a nemá ho znát
return res.status(200).json({ ...strippedEasterEgg, ...getRandomPosition(startOffset, endOffset) });
}
}
return res.status(200).send();
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -1,10 +1,10 @@
import express from "express"; import express, { Request } from "express";
import { getLogin, getTrusted } from "../auth"; import { getLogin, getTrusted } from "../auth";
import { addChoice, addVolatileData, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime } from "../service"; import { addChoice, addVolatileData, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote } from "../service";
import { getDayOfWeekIndex, parseToken } from "../utils"; import { getDayOfWeekIndex, parseToken } from "../utils";
import { getWebsocket } from "../websocket"; import { getWebsocket } from "../websocket";
import { callNotifikace } from "../notifikace"; import { callNotifikace } from "../notifikace";
import { UdalostEnum } from "../../../types"; import { AddChoiceRequest, ChangeDepartureTimeRequest, IDayIndex, RemoveChoiceRequest, RemoveChoicesRequest, UdalostEnum, UpdateNoteRequest } from "../../../types";
/** /**
* Ověří a vrátí index dne v týdnu z požadavku, za předpokladu, že byl předán, a je zároveň * Ověří a vrátí index dne v týdnu z požadavku, za předpokladu, že byl předán, a je zároveň
@@ -13,12 +13,12 @@ import { UdalostEnum } from "../../../types";
* @param req request * @param req request
* @returns index dne v týdnu * @returns index dne v týdnu
*/ */
const parseValidateFutureDayIndex = (req: any) => { const parseValidateFutureDayIndex = (req: Request<{}, any, IDayIndex>) => {
if (req.body.dayIndex == null) { if (req.body.dayIndex == null) {
throw Error(`Nebyl předán index dne v týdnu.`); throw Error(`Nebyl předán index dne v týdnu.`);
} }
const todayDayIndex = getDayOfWeekIndex(getToday()); const todayDayIndex = getDayOfWeekIndex(getToday());
const dayIndex = parseInt(req.body.dayIndex); const dayIndex = req.body.dayIndex;
if (isNaN(dayIndex)) { if (isNaN(dayIndex)) {
throw Error(`Neplatný index dne v týdnu: ${req.body.dayIndex}`); throw Error(`Neplatný index dne v týdnu: ${req.body.dayIndex}`);
} }
@@ -30,10 +30,74 @@ const parseValidateFutureDayIndex = (req: any) => {
const router = express.Router(); const router = express.Router();
router.post("/addChoice", async (req, res, next) => { router.post("/addChoice", async (req: Request<{}, any, AddChoiceRequest>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
if (req.body.locationIndex > -1) { let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
getWebsocket().emit("message", await addVolatileData(data));
return res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await removeChoices(login, trusted, req.body.locationKey, date);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/updateNote", async (req: Request<{}, any, UpdateNoteRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
const note = req.body.note;
try {
if (note && note.length > 70) {
throw Error("Poznámka může mít maximálně 70 znaků");
}
let date = undefined; let date = undefined;
if (req.body.dayIndex != null) { if (req.body.dayIndex != null) {
let dayIndex; let dayIndex;
@@ -44,56 +108,13 @@ router.post("/addChoice", async (req, res, next) => {
} }
date = getDateForWeekIndex(dayIndex); date = getDateForWeekIndex(dayIndex);
} }
try { const data = await updateNote(login, trusted, note, date);
const data = await addChoice(login, trusted, req.body.locationIndex, req.body.foodIndex, date);
getWebsocket().emit("message", await addVolatileData(data));
return res.status(200).json(data);
} catch (e: any) { next(e) }
}
return res.status(400); // TODO přidat popis chyby
});
router.post("/removeChoices", async (req, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await removeChoices(login, trusted, req.body.locationIndex, date);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data); res.status(200).json(data);
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
router.post("/removeChoice", async (req, res, next) => { router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await removeChoice(login, trusted, req.body.locationIndex, req.body.foodIndex, date);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/changeDepartureTime", async (req, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
let date = undefined; let date = undefined;
if (req.body.dayIndex != null) { if (req.body.dayIndex != null) {

View File

@@ -1,14 +1,15 @@
import express from "express"; import express, { Request } from "express";
import { getLogin } from "../auth"; import { getLogin } from "../auth";
import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee } from "../pizza"; import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee } from "../pizza";
import { parseToken } from "../utils"; import { parseToken } from "../utils";
import { getWebsocket } from "../websocket"; import { getWebsocket } from "../websocket";
import { addVolatileData } from "../service"; import { addVolatileData } from "../service";
import { AddPizzaRequest, FinishDeliveryRequest, RemovePizzaRequest, UpdatePizzaDayNoteRequest, UpdatePizzaFeeRequest } from "../../../types";
const router = express.Router(); const router = express.Router();
/** Založí pizza day pro aktuální den, za předpokladu že dosud neexistuje. */ /** Založí pizza day pro aktuální den, za předpokladu že dosud neexistuje. */
router.post("/create", async (req, res) => { router.post("/create", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const data = await createPizzaDay(login); const data = await createPizzaDay(login);
res.status(200).json(data); res.status(200).json(data);
@@ -16,13 +17,13 @@ router.post("/create", async (req, res) => {
}); });
/** Smaže pizza day pro aktuální den, za předpokladu že existuje. */ /** Smaže pizza day pro aktuální den, za předpokladu že existuje. */
router.post("/delete", async (req, res) => { router.post("/delete", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const data = await deletePizzaDay(login); const data = await deletePizzaDay(login);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", await addVolatileData(data));
}); });
router.post("/add", async (req, res) => { router.post("/add", async (req: Request<{}, any, AddPizzaRequest>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
if (isNaN(req.body?.pizzaIndex)) { if (isNaN(req.body?.pizzaIndex)) {
throw Error("Nebyl předán index pizzy"); throw Error("Nebyl předán index pizzy");
@@ -47,7 +48,7 @@ router.post("/add", async (req, res) => {
res.status(200).json({}); res.status(200).json({});
}); });
router.post("/remove", async (req, res) => { router.post("/remove", async (req: Request<{}, any, RemovePizzaRequest>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
if (!req.body?.pizzaOrder) { if (!req.body?.pizzaOrder) {
throw Error("Nebyla předána objednávka"); throw Error("Nebyla předána objednávka");
@@ -57,45 +58,47 @@ router.post("/remove", async (req, res) => {
res.status(200).json({}); res.status(200).json({});
}); });
router.post("/lock", async (req, res) => { router.post("/lock", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const data = await lockPizzaDay(login); const data = await lockPizzaDay(login);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({}); res.status(200).json({});
}); });
router.post("/unlock", async (req, res) => { router.post("/unlock", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const data = await unlockPizzaDay(login); const data = await unlockPizzaDay(login);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({}); res.status(200).json({});
}); });
router.post("/finishOrder", async (req, res) => { router.post("/finishOrder", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const data = await finishPizzaOrder(login); const data = await finishPizzaOrder(login);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({}); res.status(200).json({});
}); });
router.post("/finishDelivery", async (req, res) => { router.post("/finishDelivery", async (req: Request<{}, any, FinishDeliveryRequest>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const data = await finishPizzaDelivery(login, req.body.bankAccount, req.body.bankAccountHolder); const data = await finishPizzaDelivery(login, req.body.bankAccount, req.body.bankAccountHolder);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({}); res.status(200).json({});
}); });
router.post("/updatePizzaDayNote", async (req, res) => { router.post("/updatePizzaDayNote", async (req: Request<{}, any, UpdatePizzaDayNoteRequest>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
if (req.body.note && req.body.note.length > 100) { try {
throw Error("Poznámka může mít maximálně 100 znaků"); if (req.body.note && req.body.note.length > 70) {
} throw Error("Poznámka může mít maximálně 70 znaků");
const data = await updatePizzaDayNote(login, req.body.note); }
getWebsocket().emit("message", await addVolatileData(data)); const data = await updatePizzaDayNote(login, req.body.note);
res.status(200).json(data); getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
}); });
router.post("/updatePizzaFee", async (req, res, next) => { router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeRequest>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
if (!req.body.login) { if (!req.body.login) {
return res.status(400).json({ error: "Nebyl předán login cílového uživatele" }); return res.status(400).json({ error: "Nebyl předán login cílového uživatele" });

View File

@@ -1,17 +1,18 @@
import express from "express"; import express, { Request, Response } from "express";
import { getLogin } from "../auth"; import { getLogin } from "../auth";
import { parseToken } from "../utils"; import { parseToken } from "../utils";
import { getUserVotes, updateFeatureVote } from "../voting"; import { getUserVotes, updateFeatureVote } from "../voting";
import { FeatureRequest, UpdateFeatureVoteRequest } from "../../../types";
const router = express.Router(); const router = express.Router();
router.get("/getVotes", async (req, res) => { router.get("/getVotes", async (req: Request<{}, any, undefined>, res: Response<FeatureRequest[]>) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const data = await getUserVotes(login); const data = await getUserVotes(login);
res.status(200).json(data); res.status(200).json(data);
}); });
router.post("/updateVote", async (req, res, next) => { router.post("/updateVote", async (req: Request<{}, any, UpdateFeatureVoteRequest>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
if (req.body?.option == null || req.body?.active == null) { if (req.body?.option == null || req.body?.active == null) {
res.status(400).json({ error: "Chybné parametry volání" }); res.status(400).json({ error: "Chybné parametry volání" });

View File

@@ -1,7 +1,7 @@
import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getHumanTime, getIsWeekend, getLastWorkDayOfWeek, getWeekNumber } from "./utils"; import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getIsWeekend, getWeekNumber } from "./utils";
import { ClientData, Locations, Restaurants, DayMenu, DepartureTime, DayData, WeekMenu } from "../../types"; import { ClientData, Restaurants, RestaurantDailyMenu, DepartureTime, DayData, WeekMenu, LocationKey, DayOfWeekIndex, daysOfWeeksIndices, DayOfWeekEnum, DayOfWeek } from "../../types";
import getStorage from "./storage"; import getStorage from "./storage";
import { getMenuSladovnicka, getMenuTechTower, getMenuUMotliku } from "./restaurants"; import { getMenuSladovnicka, getMenuTechTower, getMenuUMotliku, getMenuZastavkaUmichala } from "./restaurants";
import { getTodayMock } from "./mock"; import { getTodayMock } from "./mock";
const storage = getStorage(); const storage = getStorage();
@@ -10,7 +10,7 @@ const MENU_PREFIX = 'menu';
/** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */ /** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */
export function getToday(): Date { export function getToday(): Date {
if (process.env.MOCK_DATA === 'true') { if (process.env.MOCK_DATA === 'true') {
return new Date(getTodayMock()); return getTodayMock();
} }
return new Date(); return new Date();
} }
@@ -59,8 +59,9 @@ export async function getData(date?: Date): Promise<ClientData> {
let clientData: ClientData = { ...data }; let clientData: ClientData = { ...data };
clientData.menus = { clientData.menus = {
[Restaurants.SLADOVNICKA]: await getRestaurantMenu(Restaurants.SLADOVNICKA, date), [Restaurants.SLADOVNICKA]: await getRestaurantMenu(Restaurants.SLADOVNICKA, date),
[Restaurants.UMOTLIKU]: await getRestaurantMenu(Restaurants.UMOTLIKU, date), // [Restaurants.UMOTLIKU]: await getRestaurantMenu(Restaurants.UMOTLIKU, date),
[Restaurants.TECHTOWER]: await getRestaurantMenu(Restaurants.TECHTOWER, date), [Restaurants.TECHTOWER]: await getRestaurantMenu(Restaurants.TECHTOWER, date),
[Restaurants.ZASTAVKAUMICHALA]: await getRestaurantMenu(Restaurants.ZASTAVKAUMICHALA, date),
} }
clientData = await addVolatileData(clientData); clientData = await addVolatileData(clientData);
return clientData; return clientData;
@@ -96,7 +97,7 @@ async function getMenu(date: Date): Promise<WeekMenu | undefined> {
* @param date datum, ke kterému získat menu * @param date datum, ke kterému získat menu
* @param mock příznak, zda chceme pouze mock data * @param mock příznak, zda chceme pouze mock data
*/ */
export async function getRestaurantMenu(restaurant: Restaurants, date?: Date): Promise<DayMenu> { export async function getRestaurantMenu(restaurant: Restaurants, date?: Date): Promise<RestaurantDailyMenu> {
const usedDate = date ?? getToday(); const usedDate = date ?? getToday();
const dayOfWeekIndex = getDayOfWeekIndex(usedDate); const dayOfWeekIndex = getDayOfWeekIndex(usedDate);
const now = new Date().getTime(); const now = new Date().getTime();
@@ -110,9 +111,9 @@ export async function getRestaurantMenu(restaurant: Restaurants, date?: Date): P
let menus = await getMenu(usedDate); let menus = await getMenu(usedDate);
if (menus == null) { if (menus == null) {
menus = []; menus = {};
} }
for (let i = 0; i < 5; i++) { daysOfWeeksIndices.forEach(i => {
if (menus[i] == null) { if (menus[i] == null) {
menus[i] = {}; menus[i] = {};
} }
@@ -123,6 +124,9 @@ export async function getRestaurantMenu(restaurant: Restaurants, date?: Date): P
food: [], food: [],
}; };
} }
})
if (!menus[dayOfWeekIndex]) {
menus[dayOfWeekIndex] = {};
} }
if (!menus[dayOfWeekIndex][restaurant]?.food?.length) { if (!menus[dayOfWeekIndex][restaurant]?.food?.length) {
const firstDay = getFirstWorkDayOfWeek(usedDate); const firstDay = getFirstWorkDayOfWeek(usedDate);
@@ -130,7 +134,15 @@ export async function getRestaurantMenu(restaurant: Restaurants, date?: Date): P
switch (restaurant) { switch (restaurant) {
case Restaurants.SLADOVNICKA: case Restaurants.SLADOVNICKA:
try { try {
// TODO tady jsme v háji, protože z následujících metod vracíme arbitrárně dlouhé pole (musíme vracet omezené na maximálně 0-7 prvků)
const sladovnickaFood = await getMenuSladovnicka(firstDay, mock); const sladovnickaFood = await getMenuSladovnicka(firstDay, mock);
for (const i in DayOfWeekEnum) {
menus[i][restaurant]!.food = sladovnickaFood[i];
// Velice chatrný a nespolehlivý způsob detekce uzavření...
if (sladovnickaFood[i].length === 1 && sladovnickaFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
menus[i][restaurant]!.closed = true;
}
}
for (let i = 0; i < sladovnickaFood.length; i++) { for (let i = 0; i < sladovnickaFood.length; i++) {
menus[i][restaurant]!.food = sladovnickaFood[i]; menus[i][restaurant]!.food = sladovnickaFood[i];
// Velice chatrný a nespolehlivý způsob detekce uzavření... // Velice chatrný a nespolehlivý způsob detekce uzavření...
@@ -142,25 +154,25 @@ export async function getRestaurantMenu(restaurant: Restaurants, date?: Date): P
console.error("Selhalo načtení jídel pro podnik Sladovnická", e); console.error("Selhalo načtení jídel pro podnik Sladovnická", e);
} }
break; break;
case Restaurants.UMOTLIKU: // case Restaurants.UMOTLIKU:
try { // try {
const uMotlikuFood = await getMenuUMotliku(firstDay, mock); // const uMotlikuFood = await getMenuUMotliku(firstDay, mock);
for (let i = 0; i < uMotlikuFood.length; i++) { // for (let i = 0; i < uMotlikuFood.length; i++) {
menus[i][restaurant]!.food = uMotlikuFood[i]; // menus[i][restaurant]!.food = uMotlikuFood[i];
if (uMotlikuFood[i].length === 1 && uMotlikuFood[i][0].name.toLowerCase() === 'zavřeno') { // if (uMotlikuFood[i].length === 1 && uMotlikuFood[i][0].name.toLowerCase() === 'zavřeno') {
menus[i][restaurant]!.closed = true; // menus[i][restaurant]!.closed = true;
} // }
} // }
} catch (e: any) { // } catch (e: any) {
console.error("Selhalo načtení jídel pro podnik U Motlíků", e); // console.error("Selhalo načtení jídel pro podnik U Motlíků", e);
} // }
break; // break;
case Restaurants.TECHTOWER: case Restaurants.TECHTOWER:
try { try {
const techTowerFood = await getMenuTechTower(firstDay, mock); const techTowerFood = await getMenuTechTower(firstDay, mock);
for (let i = 0; i < techTowerFood.length; i++) { for (let i = 0; i < techTowerFood.length; i++) {
menus[i][restaurant]!.food = techTowerFood[i]; menus[i][restaurant]!.food = techTowerFood[i];
if (techTowerFood[i].length === 1 && techTowerFood[i][0].name.toLowerCase() === 'svátek') { if (techTowerFood[i]?.length === 1 && techTowerFood[i][0].name.toLowerCase() === 'svátek') {
menus[i][restaurant]!.closed = true; menus[i][restaurant]!.closed = true;
} }
} }
@@ -168,6 +180,19 @@ export async function getRestaurantMenu(restaurant: Restaurants, date?: Date): P
} catch (e: any) { } catch (e: any) {
console.error("Selhalo načtení jídel pro podnik TechTower", e); console.error("Selhalo načtení jídel pro podnik TechTower", e);
} }
case Restaurants.ZASTAVKAUMICHALA:
try {
const zastavkaUmichalaFood = await getMenuZastavkaUmichala(firstDay, mock);
for (let i = 0; i < zastavkaUmichalaFood.length; i++) {
menus[i][restaurant]!.food = zastavkaUmichalaFood[i];
if (zastavkaUmichalaFood[i]?.length === 1 && zastavkaUmichalaFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
menus[i][restaurant]!.closed = true;
}
}
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Zastávka u Michala", e);
}
} }
await storage.setData(getMenuKey(usedDate), menus); await storage.setData(getMenuKey(usedDate), menus);
} }
@@ -192,19 +217,19 @@ export async function initIfNeeded(date?: Date) {
* *
* @param login login uživatele * @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele * @param trusted příznak, zda se jedná o ověřeného uživatele
* @param location vybrané "umístění" * @param locationKey vybrané "umístění"
* @param date datum, ke kterému se volba vztahuje * @param date datum, ke kterému se volba vztahuje
* @returns * @returns
*/ */
export async function removeChoices(login: string, trusted: boolean, location: Locations, date?: Date) { export async function removeChoices(login: string, trusted: boolean, locationKey: LocationKey, date?: Date) {
const selectedDay = formatDate(date ?? getToday()); const selectedDay = formatDate(date ?? getToday());
let data: DayData = await storage.getData(selectedDay); let data: DayData = await storage.getData(selectedDay);
validateTrusted(data, login, trusted); validateTrusted(data, login, trusted);
if (location in data.choices) { if (locationKey in data.choices) {
if (login in data.choices[location]) { if (data.choices[locationKey] && login in data.choices[locationKey]) {
delete data.choices[location][login] delete data.choices[locationKey][login]
if (Object.keys(data.choices[location]).length === 0) { if (Object.keys(data.choices[locationKey]).length === 0) {
delete data.choices[location] delete data.choices[locationKey]
} }
await storage.setData(selectedDay, data); await storage.setData(selectedDay, data);
} }
@@ -218,20 +243,20 @@ export async function removeChoices(login: string, trusted: boolean, location: L
* *
* @param login login uživatele * @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele * @param trusted příznak, zda se jedná o ověřeného uživatele
* @param location vybrané "umístění" * @param locationKey vybrané "umístění"
* @param foodIndex index jídla v jídelním lístku daného umístění, pokud existuje * @param foodIndex index jídla v jídelním lístku daného umístění, pokud existuje
* @param date datum, ke kterému se volba vztahuje * @param date datum, ke kterému se volba vztahuje
* @returns * @returns
*/ */
export async function removeChoice(login: string, trusted: boolean, location: Locations, foodIndex: number, date?: Date) { export async function removeChoice(login: string, trusted: boolean, locationKey: LocationKey, foodIndex: number, date?: Date) {
const selectedDay = formatDate(date ?? getToday()); const selectedDay = formatDate(date ?? getToday());
let data: DayData = await storage.getData(selectedDay); let data: DayData = await storage.getData(selectedDay);
validateTrusted(data, login, trusted); validateTrusted(data, login, trusted);
if (location in data.choices) { if (locationKey in data.choices) {
if (login in data.choices[location]) { if (data.choices[locationKey] && login in data.choices[locationKey]) {
const index = data.choices[location][login].options.indexOf(foodIndex); const index = data.choices[locationKey][login].options.indexOf(foodIndex);
if (index > -1) { if (index > -1) {
data.choices[location][login].options.splice(index, 1) data.choices[locationKey][login].options.splice(index, 1)
await storage.setData(selectedDay, data); await storage.setData(selectedDay, data);
} }
} }
@@ -247,10 +272,11 @@ export async function removeChoice(login: string, trusted: boolean, location: Lo
async function removeChoiceIfPresent(login: string, date: string) { async function removeChoiceIfPresent(login: string, date: string) {
let data: DayData = await storage.getData(date); let data: DayData = await storage.getData(date);
for (const key of Object.keys(data.choices)) { for (const key of Object.keys(data.choices)) {
if (login in data.choices[key]) { const locationKey = key as LocationKey;
delete data.choices[key][login]; if (data.choices[locationKey] && login in data.choices[locationKey]) {
if (Object.keys(data.choices[key]).length === 0) { delete data.choices[locationKey][login];
delete data.choices[key]; if (Object.keys(data.choices[locationKey]).length === 0) {
delete data.choices[locationKey];
} }
await storage.setData(date, data); await storage.setData(date, data);
} }
@@ -285,13 +311,13 @@ function validateTrusted(data: ClientData, login: string, trusted: boolean) {
* *
* @param login login uživatele * @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele * @param trusted příznak, zda se jedná o ověřeného uživatele
* @param location vybrané "umístění" * @param locationKey vybrané "umístění"
* @param foodIndex volitelný index jídla v daném umístění * @param foodIndex volitelný index jídla v daném umístění
* @param trusted příznak, zda se jedná o ověřeného uživatele * @param trusted příznak, zda se jedná o ověřeného uživatele
* @param date datum, ke kterému se volba vztahuje * @param date datum, ke kterému se volba vztahuje
* @returns aktuální data * @returns aktuální data
*/ */
export async function addChoice(login: string, trusted: boolean, location: Locations, foodIndex?: number, date?: Date) { export async function addChoice(login: string, trusted: boolean, locationKey: LocationKey, foodIndex?: number, date?: Date) {
const usedDate = date ?? getToday(); const usedDate = date ?? getToday();
await initIfNeeded(usedDate); await initIfNeeded(usedDate);
const selectedDate = formatDate(usedDate); const selectedDate = formatDate(usedDate);
@@ -301,22 +327,52 @@ export async function addChoice(login: string, trusted: boolean, location: Locat
if (foodIndex == null) { if (foodIndex == null) {
data = await removeChoiceIfPresent(login, selectedDate); data = await removeChoiceIfPresent(login, selectedDate);
} }
if (!(location in data.choices)) { // TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
data.choices[location] = {}; if (!(data.choices[locationKey])) {
data.choices[locationKey] = {}
} }
if (!(login in data.choices[location])) { if (!(login in data.choices[locationKey])) {
data.choices[location][login] = { if (!data.choices[locationKey]) {
data.choices[locationKey] = {}
}
data.choices[locationKey][login] = {
trusted, trusted,
options: [] options: []
}; };
} }
if (foodIndex != null && !data.choices[location][login].options.includes(foodIndex)) { if (foodIndex != null && !data.choices[locationKey][login].options.includes(foodIndex)) {
data.choices[location][login].options.push(foodIndex); data.choices[locationKey][login].options.push(foodIndex);
} }
await storage.setData(selectedDate, data); await storage.setData(selectedDate, data);
return data; return data;
} }
/**
* Aktualizuje poznámku k aktuálně vybrané možnosti.
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param note poznámka
* @param date datum, ke kterému se volba vztahuje
*/
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate);
const selectedDate = formatDate(usedDate);
let data: DayData = await storage.getData(selectedDate);
validateTrusted(data, login, trusted);
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
if (userEntry) {
if (!note || !note.length) {
delete userEntry[1][login].note;
} else {
userEntry[1][login].note = note;
}
await storage.setData(selectedDate, data);
}
return data;
}
/** /**
* Aktualizuje preferovaný čas odchodu strávníka. * Aktualizuje preferovaný čas odchodu strávníka.
* *

View File

@@ -1,11 +1,13 @@
import { Choices } from "../../types"; import { Choices, DayOfWeekIndex, LocationKey } from "../../types";
/** Vrátí datum v ISO formátu. */ /** Vrátí datum v ISO formátu. */
export function formatDate(date: Date) { export function formatDate(date: Date, format?: string) {
let currentDay = String(date.getDate()).padStart(2, '0'); let day = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0"); let month = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear(); let year = String(date.getFullYear());
return `${currentYear}-${currentMonth}-${currentDay}`;
const f = (format === undefined) ? 'YYYY-MM-DD' : format;
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
} }
/** Vrátí human-readable reprezentaci předaného data pro zobrazení. */ /** Vrátí human-readable reprezentaci předaného data pro zobrazení. */
@@ -30,9 +32,9 @@ export function getHumanTime(time: Date) {
* @param date datum * @param date datum
* @returns index dne v týdnu * @returns index dne v týdnu
*/ */
export const getDayOfWeekIndex = (date: Date) => { export const getDayOfWeekIndex = (date: Date): DayOfWeekIndex => {
// https://stackoverflow.com/a/4467559 // https://stackoverflow.com/a/4467559
return (((date.getDay() - 1) % 7) + 7) % 7; return ((((date.getDay() - 1) % 7) + 7) % 7) as DayOfWeekIndex;
} }
/** Vrátí true, pokud je předané datum o víkendu. */ /** Vrátí true, pokud je předané datum o víkendu. */
@@ -110,19 +112,19 @@ export const checkBodyParams = (req: any, paramNames: string[]) => {
// TODO umístit do samostatného souboru // TODO umístit do samostatného souboru
export class InsufficientPermissions extends Error { } export class InsufficientPermissions extends Error { }
export const getUsersByLocation = (data: Choices, login: string): string[] => { export const getUsersByLocation = (choices: Choices, login: string): string[] => {
const result: string[] = []; const result: string[] = [];
for (const location in data) { for (const location of Object.entries(choices)) {
if (data.hasOwnProperty(location)) { const locationKey = location[0] as LocationKey;
if (data[location][login]) { const locationValue = location[1];
for (const username in data[location]) { if (locationValue[login]) {
if (data[location].hasOwnProperty(username)) { for (const username in choices[locationKey]) {
result.push(username); if (choices[locationKey].hasOwnProperty(username)) {
} result.push(username);
} }
break;
} }
break;
} }
} }

View File

@@ -46,8 +46,8 @@ export async function updateFeatureVote(login: string, option: FeatureRequest, a
} }
} }
} else if (active) { } else if (active) {
if (data[login].length == 3) { if (data[login].length == 4) {
throw Error('Je možné hlasovat pro maximálně 3 možnosti'); throw Error('Je možné hlasovat pro maximálně 4 možnosti');
} }
data[login].push(option); data[login].push(option);
} }

4123
server/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

56
types/RequestTypes.ts Normal file
View File

@@ -0,0 +1,56 @@
import { FeatureRequest, LocationKey, PizzaOrder } from "./Types";
export type ILocationKey = {
locationKey: LocationKey,
}
export type IDayIndex = {
dayIndex?: number,
}
export type AddChoiceRequest = IDayIndex & ILocationKey & {
foodIndex?: number,
}
export type RemoveChoicesRequest = IDayIndex & ILocationKey;
export type RemoveChoiceRequest = IDayIndex & ILocationKey & {
foodIndex: number,
}
export type UpdateNoteRequest = IDayIndex & {
note?: string,
}
export type ChangeDepartureTimeRequest = IDayIndex & {
time: string,
}
export type FinishDeliveryRequest = {
bankAccount?: string,
bankAccountHolder?: string,
}
export type AddPizzaRequest = {
pizzaIndex: number,
pizzaSizeIndex: number,
}
export type RemovePizzaRequest = {
pizzaOrder: PizzaOrder,
}
export type UpdatePizzaDayNoteRequest = {
note?: string,
}
export type UpdatePizzaFeeRequest = {
login: string,
text?: string,
price?: number,
}
export type UpdateFeatureVoteRequest = {
option: FeatureRequest,
active: boolean,
}

View File

@@ -1,24 +1,27 @@
/** Výčtový typ pro restaurace, pro které umíme získat a parsovat obědové menu. */ /** Výčtový typ pro restaurace, pro které umíme získat a parsovat obědové menu. */
export enum Restaurants { export enum Restaurants {
SLADOVNICKA = 'sladovnicka', SLADOVNICKA = 'sladovnicka',
UMOTLIKU = 'uMotliku', // UMOTLIKU = 'uMotliku',
TECHTOWER = 'techTower', TECHTOWER = 'techTower',
ZASTAVKAUMICHALA = 'zastavkaUmichala',
} }
export interface FoodChoices { export type FoodChoices = {
trusted: boolean, trusted: boolean,
options: number[], options: number[],
departureTime?: string, departureTime?: string,
note?: string,
} }
export interface Choices { // TODO okomentovat / rozdělit
[location: string]: { export type Choices = {
[location in LocationKey]?: {
[login: string]: FoodChoices [login: string]: FoodChoices
}, }
} }
/** Velikost konkrétní pizzy */ /** Velikost konkrétní pizzy */
export interface PizzaSize { export type PizzaSize = {
varId: number, // unikátní ID varianty pizzy varId: number, // unikátní ID varianty pizzy
size: string, // velikost pizzy, např. "30cm" size: string, // velikost pizzy, např. "30cm"
pizzaPrice: number, // cena samotné pizzy pizzaPrice: number, // cena samotné pizzy
@@ -27,14 +30,14 @@ export interface PizzaSize {
} }
/** Jedna konkrétní pizza */ /** Jedna konkrétní pizza */
export interface Pizza { export type Pizza = {
name: string, // název pizzy name: string, // název pizzy
ingredients: string[], // seznam ingrediencí ingredients: string[], // seznam ingrediencí
sizes: PizzaSize[], // dostupné velikosti pizzy sizes: PizzaSize[], // dostupné velikosti pizzy
} }
/** Objednávka jedné konkrétní pizzy */ /** Objednávka jedné konkrétní pizzy */
export interface PizzaOrder { export type PizzaOrder = {
varId: number, // unikátní ID varianty pizzy varId: number, // unikátní ID varianty pizzy
name: string, // název pizzy name: string, // název pizzy
size: string, // velikost pizzy jako string (30cm) size: string, // velikost pizzy jako string (30cm)
@@ -42,7 +45,7 @@ export interface PizzaOrder {
} }
/** Celková objednávka jednoho člověka */ /** Celková objednávka jednoho člověka */
export interface Order { export type Order = {
customer: string, // jméno objednatele customer: string, // jméno objednatele
pizzaList: PizzaOrder[], // seznam objednaných pizz pizzaList: PizzaOrder[], // seznam objednaných pizz
fee?: { text?: string, price: number }, // příplatek (např. za extra ingredience) fee?: { text?: string, price: number }, // příplatek (např. za extra ingredience)
@@ -67,39 +70,55 @@ interface PizzaDay {
orders: Order[], // seznam objednávek jednotlivých lidí orders: Order[], // seznam objednávek jednotlivých lidí
} }
/** Index dne v týdnu (0 = pondělí, 6 = neděle) */
// TODO tohle by měl být (seřazený) enum MONDAY-SUNDAY, ne číslo
export const daysOfWeeksIndices = [0, 1, 2, 3, 4, 5, 6] as const;
export type DayOfWeekIndex = typeof daysOfWeeksIndices[number]
const daysOfWeek = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] as const;
export type DayOfWeek = typeof daysOfWeek[number];
/** Denní menu všech dostupných podniků. */
export type DailyMenu = {
[restaurant in Restaurants]?: RestaurantDailyMenu
}
/** Týdenní menu jednotlivých restaurací. */ /** Týdenní menu jednotlivých restaurací. */
export interface WeekMenu { export type WeekMenu = {
[dayIndex: number]: { [dayIndex in DayOfWeek]?: DailyMenu
[restaurant in Restaurants]?: DayMenu }
}
/** Týdenní menu jedné restaurace. */
export type RestaurantWeeklyMenu = {
[key in DayOfWeek]?: Food[]
} }
/** Data vztahující se k jednomu konkrétnímu dni. */ /** Data vztahující se k jednomu konkrétnímu dni. */
export interface DayData { export type DayData = {
date: string, // datum dne date: string, // datum dne
isWeekend: boolean, // příznak, zda je datum víkend isWeekend: boolean, // příznak, zda je datum víkend
weekIndex: number, // index dne v týdnu (0-6) weekIndex: DayOfWeekIndex, // index dne v týdnu (0-6)
choices: Choices, // seznam voleb uživatelů choices: Choices, // seznam voleb uživatelů
menus?: { [restaurant in Restaurants]?: DayMenu }, // menu jednotlivých restaurací menus?: { [restaurant in Restaurants]?: RestaurantDailyMenu }, // menu jednotlivých restaurací
pizzaDay?: PizzaDay, // pizza day pro dnešní den, pokud existuje pizzaDay?: PizzaDay, // pizza day pro dnešní den, pokud existuje
pizzaList?: Pizza[], // seznam dostupných pizz pro dnešní den pizzaList?: Pizza[], // seznam dostupných pizz pro dnešní den
pizzaListLastUpdate?: Date, // datum a čas poslední aktualizace pizz pizzaListLastUpdate?: Date, // datum a čas poslední aktualizace pizz
} }
/** Veškerá data pro zobrazení na klientovi. */ /** Veškerá data pro zobrazení na klientovi. */
export interface ClientData extends DayData { export type ClientData = DayData & {
todayWeekIndex?: number, // index dnešního dne v týdnu (0-6) todayWeekIndex?: DayOfWeekIndex, // index dnešního dne v týdnu (0-6)
} }
/** Nabídka jídel jednoho podniku pro jeden konkrétní den. */ /** Nabídka jídel jednoho podniku pro jeden konkrétní den. */
export interface DayMenu { export type RestaurantDailyMenu = {
lastUpdate: number, // UNIX timestamp poslední aktualizace menu lastUpdate: number, // UNIX timestamp poslední aktualizace menu
closed: boolean, // příznak, zda je daný podnik v tento den zavřený closed: boolean, // příznak, zda je daný podnik v tento den zavřený
food: Food[], // seznam jídel v menu food: Food[], // seznam jídel v menu
} }
/** Jídlo z obědového menu restaurace. */ /** Jídlo z obědového menu restaurace. */
export interface Food { export type Food = {
amount?: string, // množství standardní porce, např. 0,33l nebo 150g amount?: string, // množství standardní porce, např. 0,33l nebo 150g
name: string, // název/popis jídla name: string, // název/popis jídla
price: string, // cena ve formátu '135 Kč' price: string, // cena ve formátu '135 Kč'
@@ -109,33 +128,38 @@ export interface Food {
// TODO tohle je dost špatné pojmenování, vzhledem k tomu co to obsahuje // TODO tohle je dost špatné pojmenování, vzhledem k tomu co to obsahuje
export enum Locations { export enum Locations {
SLADOVNICKA = 'Sladovnická', SLADOVNICKA = 'Sladovnická',
UMOTLIKU = 'U Motlíků', // UMOTLIKU = 'U Motlíků',
TECHTOWER = 'TechTower', TECHTOWER = 'TechTower',
ZASTAVKAUMICHALA = 'Zastávka u Michala',
SPSE = 'SPŠE', SPSE = 'SPŠE',
PIZZA = 'Pizza day', PIZZA = 'Pizza day',
OBJEDNAVAM = 'Budu objednávat', OBJEDNAVAM = 'Budu objednávat',
NEOBEDVAM = 'Mám vlastní/neobědvám', NEOBEDVAM = 'Mám vlastní/neobědvám',
ROZHODUJI = 'Rozhoduji se',
} }
// TODO totéž
export type LocationKey = keyof typeof Locations;
export enum UdalostEnum { export enum UdalostEnum {
ZAHAJENA_PIZZA = "Zahájen pizza day", ZAHAJENA_PIZZA = "Zahájen pizza day",
OBJEDNANA_PIZZA = "Objednána pizza", OBJEDNANA_PIZZA = "Objednána pizza",
JDEME_OBED = "Jdeme oběd", JDEME_OBED = "Jdeme oběd",
} }
export interface NotififaceInput { export type NotififaceInput = {
udalost: UdalostEnum, udalost: UdalostEnum,
user: string, user: string,
} }
export interface NotifikaceData { export type NotifikaceData = {
input: NotififaceInput, input: NotififaceInput,
gotify?: boolean, gotify?: boolean,
teams?: boolean, teams?: boolean,
ntfy?: boolean, ntfy?: boolean,
} }
export interface GotifyServer { export type GotifyServer = {
server: string; server: string;
api_keys: string[]; api_keys: string[];
} }
@@ -158,12 +182,55 @@ export enum DepartureTime {
} }
export enum FeatureRequest { export enum FeatureRequest {
SINGLE_PAYMENT = "Možnost úhrady v podniku jednou osobou a generování QR pro ostatní", CUSTOM_QR = "Ruční generování QR kódů mimo Pizza day (např. při objednává)",
NOTIFICATIONS = "Podpora push notifikací na mobil", FAVORITES = "Možnost označovat si jídla jako oblíbená (taková jídla by se uživateli následně zvýrazňovala)",
STATISTICS = "Statistiky (nejoblíbenější podnik, nejpopulárnější jídla, nejobjednávanější pizzy, ...)", SINGLE_PAYMENT = "Možnost úhrady v podniku za všechny jednou osobou a následné generování QR ostatním",
NO_WEEKENDS = "Zrušení \"užívejte víkend\", místo toho umožnit zpětně náhled na uplynulý týden",
QR_FOREVER = "Umožnění zobrazení vygenerovaného QR kódu i po následující dny (dokud ho uživatel ručně \"nezavře\", např. tlačítkem \"Zaplatil jsem\")",
PIZZA_PICTURES = "Zobrazování náhledů (fotografií) pizz v rámci Pizza day",
STATISTICS = "Statistiky (nejoblíbenější podnik, nejpopulárnější jídla, nejobjednávanější pizzy, nejčastější uživatelé, ...)",
RESPONSIVITY = "Vylepšení responzivního designu", RESPONSIVITY = "Vylepšení responzivního designu",
SECURITY = "Zvýšení zabezpečení aplikace", SECURITY = "Zvýšení zabezpečení aplikace",
SAFETY = "Zvýšená ochrana proti chybám uživatele (potvrzovací dialogy, překliky, ...)", SAFETY = "Zvýšená ochrana proti chybám uživatele (potvrzovací dialogy, překliky, ...)",
UI = "Celkové vylepšení UI/UX", UI = "Celkové vylepšení UI/UX",
DEVELOPMENT = "Zlepšení dokumentace/postupů pro ostatní vývojáře" DEVELOPMENT = "Zlepšení dokumentace/postupů pro ostatní vývojáře"
}
export type EasterEgg = {
path: string;
url: string;
startOffset: number;
endOffset: number;
duration: number;
width?: string;
zIndex?: number;
position?: "absolute";
animationName?: string;
animationDuration?: string;
animationTimingFunction?: string;
}
// TODO aktuálně se k ničemu nepoužívá
export type AnimationPosition = {
left?: string,
startLeft?: string,
"--start-left"?: string,
right?: string,
startRight?: string,
"--start-right"?: string,
top?: string,
startTop?: string,
"--start-top"?: string,
bottom?: string,
startBottom?: string,
"--start-bottom"?: string,
endLeft?: string,
"--end-left"?: string,
endRight?: string,
"--end-right"?: string,
endTop?: string,
"--end-top"?: string,
endBottom?: string,
"--end-bottom"?: string,
rotate?: string,
} }

View File

@@ -1 +1,2 @@
export * from './Types'; export * from './Types';
export * from './RequestTypes';

11200
yarn.lock

File diff suppressed because it is too large Load Diff