Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35cf2ca9d7
|
||
|
|
8a7bbef729
|
||
|
|
667f3c1ef1 | ||
|
|
5d13efb685 | ||
|
|
d4485db0b6 | ||
|
|
6beb15afe3 | ||
|
|
9a3987a2fa | ||
|
|
96dc3f0053 | ||
|
|
d2125a050b | ||
|
|
a8b67fd76e | ||
|
|
304c7656e9
|
||
|
|
bc2cc75d7d
|
||
|
|
fe12098ed0 | ||
|
|
95a35df6ab
|
||
|
|
297cb80662
|
||
|
|
ed64fc60e4
|
||
|
|
8a7dbd94c8 | ||
|
|
9fa00fa9c5
|
||
|
|
dc17fa0f0f
|
||
|
|
4bc2151cc4
|
||
|
|
e570f308cd
|
||
|
|
0cefaa62a0
|
||
|
|
2a54a1d41f | ||
|
|
5147651e7c | ||
|
|
6cb1214f08 | ||
|
|
abeee19259 | ||
|
|
f91193c7de
|
||
|
|
93d86fe0a5
|
||
|
|
bf631eb913
|
||
|
|
49508dacd0
|
||
|
|
9fee4a1538
|
||
|
|
7cda7cfdb6 | ||
|
|
89d2cc2bc3 | ||
|
|
a29e329c11
|
||
|
|
5f54925808 | ||
|
|
103445dba8 | ||
|
|
076b8db179 | ||
|
|
f9a7b791a0 | ||
|
|
bd52ae071e | ||
|
|
0a59f97c5c | ||
|
|
e7c4ba09ff | ||
|
|
e281615cc3 | ||
|
|
682deaf20b
|
||
|
|
92ea78a870
|
No files matched your search
+14
-11
@@ -231,8 +231,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Send webhook
|
- name: Send webhook
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK_ID: ${{ secrets.DISCORD_WEBHOOK_ID }}
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
|
|
||||||
NTFY_URL: ${{ secrets.NTFY_URL }}
|
NTFY_URL: ${{ secrets.NTFY_URL }}
|
||||||
BUILD_RESULT: ${{ needs.docker-build.result }}
|
BUILD_RESULT: ${{ needs.docker-build.result }}
|
||||||
RUN_NUMBER: ${{ github.run_number }}
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
@@ -250,12 +249,16 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
FULL_MSG="$(printf '%s\n\nPipeline: %s\nPoslední commit: %sAutor: %s' \
|
FULL_MSG="$(printf '%s\n\nPipeline: %s\nPoslední commit: %sAutor: %s' \
|
||||||
"$MSG" "$RUN_URL" "$COMMIT_MESSAGE" "$COMMIT_AUTHOR")"
|
"$MSG" "$RUN_URL" "$COMMIT_MESSAGE" "$COMMIT_AUTHOR")"
|
||||||
curl -s -X POST \
|
# Nevyplněné URL = daný kanál notifikací se přeskočí
|
||||||
"https://discord.com/api/webhooks/${DISCORD_WEBHOOK_ID}/${DISCORD_WEBHOOK_TOKEN}" \
|
if [ -n "$DISCORD_WEBHOOK_URL" ]; then
|
||||||
-H "Content-Type: application/json" \
|
curl -sS -f -X POST "${DISCORD_WEBHOOK_URL}" \
|
||||||
--data "$(jq -n --arg content "$FULL_MSG" '{content: $content}')"
|
-H "Content-Type: application/json" \
|
||||||
curl -s -X POST "${NTFY_URL}" \
|
--data "$(jq -n --arg content "$FULL_MSG" '{content: $content}')"
|
||||||
-H "Title: Luncher CI #${RUN_NUMBER}" \
|
fi
|
||||||
-H "Tags: ${NTFY_TAGS}" \
|
if [ -n "$NTFY_URL" ]; then
|
||||||
-H "Click: ${RUN_URL}" \
|
curl -sS -f -X POST "${NTFY_URL}" \
|
||||||
-d "${FULL_MSG}"
|
-H "Title: Luncher CI #${RUN_NUMBER}" \
|
||||||
|
-H "Tags: ${NTFY_TAGS}" \
|
||||||
|
-H "Click: ${RUN_URL}" \
|
||||||
|
-d "${FULL_MSG}"
|
||||||
|
fi
|
||||||
@@ -8,3 +8,4 @@ server/public/
|
|||||||
.claude/worktrees
|
.claude/worktrees
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
.idea/
|
.idea/
|
||||||
|
**/.yarn/install-state.gz
|
||||||
@@ -4,41 +4,41 @@
|
|||||||
- [ ] `foodRoutes.ts` per-pod rate limiter (`rateLimits` map) — s více replikami může uživatel překročit limit ~N× rychleji; přesunout do Redis (např. `INCR` + `EXPIRE`)
|
- [ ] `foodRoutes.ts` per-pod rate limiter (`rateLimits` map) — s více replikami může uživatel překročit limit ~N× rychleji; přesunout do Redis (např. `INCR` + `EXPIRE`)
|
||||||
- [ ] `easterEggRoutes.ts` — náhodně generované URL easter eggů jsou per-pod; URL funguje pouze na podu, který ji vygeneroval; zvážit deterministické seedy nebo sdílení přes Redis
|
- [ ] `easterEggRoutes.ts` — náhodně generované URL easter eggů jsou per-pod; URL funguje pouze na podu, který ji vygeneroval; zvážit deterministické seedy nebo sdílení přes Redis
|
||||||
- [ ] `service.ts` — komplexní víceúrovňové funkce (`addChoice`, `removeChoiceIfPresent`) provádějí více po sobě jdoucích zápisů do stejného Redis klíče; pro plnou atomicitu je potřeba per-klíčový distribuovaný zámek (Redlock nebo `SET NX EX`) nebo sloučení logiky do jednoho `updateData` volání
|
- [ ] `service.ts` — komplexní víceúrovňové funkce (`addChoice`, `removeChoiceIfPresent`) provádějí více po sobě jdoucích zápisů do stejného Redis klíče; pro plnou atomicitu je potřeba per-klíčový distribuovaný zámek (Redlock nebo `SET NX EX`) nebo sloučení logiky do jednoho `updateData` volání
|
||||||
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli
|
- [X] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli — kontroluje se skutečná adresa spojení (trustedHeaders.ts)
|
||||||
- [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
|
- [X] 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í)
|
- [X] 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
|
- [X] 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.)
|
||||||
- [ ] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul
|
- [X] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul
|
||||||
- [ ] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu
|
- [X] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu
|
||||||
- [ ] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům
|
- [X] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům
|
||||||
- [ ] Generování a zobrazení QR kódů ostatním strávníkům
|
- [X] Generování a zobrazení QR kódů ostatním strávníkům
|
||||||
- [ ] Umožnit u každého strávníka připočíst vlastní částku (např. za pití)
|
- [X] Umožnit u každého strávníka připočíst vlastní částku (např. za pití)
|
||||||
- [ ] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť)
|
- [X] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť)
|
||||||
- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
|
- [X] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
|
||||||
- [ ] Umožnit zadat URL/tokeny uživatelem
|
- [X] Umožnit zadat URL/tokeny uživatelem — ntfy, Discord, MS Teams (Gotify jen globálně na serveru)
|
||||||
- [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
|
- [X] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
|
||||||
- [ ] Zavést notifikace typu "Jdeme na oběd"
|
- [X] Zavést notifikace typu "Jdeme na oběd"
|
||||||
- [ ] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
|
- [X] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
|
||||||
- [ ] Vylepšit parsery restaurací
|
- [X] Vylepšit parsery restaurací
|
||||||
- [ ] Sladovnická
|
- [X] Sladovnická
|
||||||
- [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru
|
- [X] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru — parser mapuje dny přímo, TODO v parseru už není
|
||||||
- [ ] U Motlíků
|
- [X] U Motlíků — odpadá, podnik už v aplikaci není
|
||||||
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.')
|
- [X] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.')
|
||||||
- [ ] Jídelní lístek se stahuje jednou každý den, teoreticky by stačilo jednou týdně (za předpokladu, že se během týdne nemění)
|
- [X] Jídelní lístek se stahuje jednou každý den, teoreticky by stačilo jednou týdně (za předpokladu, že se během týdne nemění)
|
||||||
- [ ] TechTower
|
- [X] TechTower
|
||||||
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)')
|
- [X] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)') — finalizeTechTower, StaleWeekError
|
||||||
- [ ] Jídelní lístek se stahuje v rámci prvního požadavku daný den, ale často se jídelní lístek na stránkách aktualizuje až v průběhu pondělního dopoledne a ten zobrazený je proto neaktuální
|
- [X] Jídelní lístek se stahuje v rámci prvního požadavku daný den, ale často se jídelní lístek na stránkách aktualizuje až v průběhu pondělního dopoledne a ten zobrazený je proto neaktuální — zastaralé menu se po TTL stahuje znovu
|
||||||
- Stránka neposílá hlavičku o času poslední modifikace, takže o to se nelze opřít
|
- Stránka neposílá hlavičku o času poslední modifikace, takže o to se nelze opřít
|
||||||
- Nevím aktuálně jak řešit jinak, než častějším scrapováním celé stránky
|
- Nevím aktuálně jak řešit jinak, než častějším scrapováním celé stránky
|
||||||
- [X] Někdy jsou v názvech jídel přebytečné mezery kolem čárek ( , )
|
- [X] Někdy jsou v názvech jídel přebytečné mezery kolem čárek ( , )
|
||||||
- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru)
|
- [X] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru) — data jsou v Redis s volume /data
|
||||||
- [ ] Zavést složku /data
|
- [X] Zavést složku /data
|
||||||
- [ ] Mazat z databáze data z minulosti, aktuálně je to k ničemu
|
- [ ] Mazat z databáze data z minulosti, aktuálně je to k ničemu
|
||||||
- [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh)
|
- [X] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh) — run_dev.ps1
|
||||||
- [ ] Implementovat Pizza day
|
- [ ] Implementovat Pizza day
|
||||||
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
|
- [X] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
|
||||||
- [ ] Pizzy se samy budou při naklikání přidávat do košíku
|
- [ ] Pizzy se samy budou při naklikání přidávat do košíku
|
||||||
- [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch()
|
- [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch()
|
||||||
- [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní
|
- [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
|
<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="apple-touch-icon" href="/logo192.png" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=M+PLUS+Rounded+1c:wght@500;800&family=Nunito:wght@600;800;900&family=Fredoka:wght@500;600;700&family=Shippori+Mincho:wght@600;700;800&family=Zen+Maru+Gothic:wght@500;700;900&family=Yusei+Magic&family=Dela+Gothic+One&display=swap" rel="stylesheet" />
|
||||||
<title>Luncher</title>
|
<title>Luncher</title>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
@@ -24,6 +27,9 @@
|
|||||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
}
|
}
|
||||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||||
|
var style = localStorage.getItem('style_theme');
|
||||||
|
var styles = ['umamusume', 'mlp', 'hellgirl', 'kon', 'bocchi'];
|
||||||
|
document.documentElement.setAttribute('data-luncher-theme', styles.indexOf(style) >= 0 ? style : 'simple');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback pokud localStorage není dostupný
|
// Fallback pokud localStorage není dostupný
|
||||||
document.documentElement.setAttribute('data-bs-theme', 'light');
|
document.documentElement.setAttribute('data-bs-theme', 'light');
|
||||||
|
|||||||
+73
-6
@@ -10,19 +10,29 @@ import PizzaOrderList from './components/PizzaOrderList';
|
|||||||
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from 'react-select-search';
|
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from 'react-select-search';
|
||||||
import 'react-select-search/style.css';
|
import 'react-select-search/style.css';
|
||||||
import './App.scss';
|
import './App.scss';
|
||||||
|
import './themes/umamusume.scss';
|
||||||
|
import './themes/mlp.scss';
|
||||||
|
import './themes/hellgirl.scss';
|
||||||
|
import './themes/kon.scss';
|
||||||
|
import './themes/bocchi.scss';
|
||||||
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
|
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { useSettings } from './context/settings';
|
import { useSettings } from './context/settings';
|
||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
import { faArrowUpRightFromSquare, faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
import { faArrowUpRightFromSquare, faBasketShopping, faTag, faChainBroken, faChevronLeft, faChevronRight, faDharmachakra, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Loader from './components/Loader';
|
import Loader from './components/Loader';
|
||||||
import { getHumanDateTime, isInTheFuture } from './Utils';
|
import { getHumanDateTime, isInTheFuture } from './Utils';
|
||||||
import NoteModal from './components/modals/NoteModal';
|
import NoteModal from './components/modals/NoteModal';
|
||||||
|
import PriceModal from './components/modals/PriceModal';
|
||||||
|
import { parsePriceCzk } from './utils/parsePrice';
|
||||||
import PayForAllModal from './components/modals/PayForAllModal';
|
import PayForAllModal from './components/modals/PayForAllModal';
|
||||||
|
import SpinWheelModal, { WheelPick } from './components/modals/SpinWheelModal';
|
||||||
|
import EaterBadge from './components/EaterBadge';
|
||||||
import PendingPayments from './components/PendingPayments';
|
import PendingPayments from './components/PendingPayments';
|
||||||
import { useEasterEgg } from './context/eggs';
|
import { useEasterEgg } from './context/eggs';
|
||||||
import { ClientData, Food, MealSlot, PendingQr, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, generateQr } from '../../types';
|
import { ClientData, Food, MealSlot, PendingQr, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, updatePrice, changeDepartureTime, setBuyer, generateQr } from '../../types';
|
||||||
import { getLunchChoiceName } from './enums';
|
import { CHOICES_WITHOUT_PRICE, getLunchChoiceName } from './enums';
|
||||||
|
import { isArrowNavigationBlocked } from './utils/arrowNavigation';
|
||||||
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
||||||
// import './FallingLeaves.scss';
|
// import './FallingLeaves.scss';
|
||||||
|
|
||||||
@@ -80,7 +90,9 @@ function App() {
|
|||||||
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 [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
|
||||||
|
const [priceModalOpen, setPriceModalOpen] = useState<boolean>(false);
|
||||||
const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null);
|
const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null);
|
||||||
|
const [spinWheelOpen, setSpinWheelOpen] = useState<boolean>(false);
|
||||||
const [eggImage, setEggImage] = useState<Blob>();
|
const [eggImage, setEggImage] = useState<Blob>();
|
||||||
const eggRef = useRef<HTMLImageElement>(null);
|
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
|
||||||
@@ -236,6 +248,7 @@ function App() {
|
|||||||
|
|
||||||
// Navigace mezi dny pomocí klávesových šípek
|
// Navigace mezi dny pomocí klávesových šípek
|
||||||
const handleKeyDown = useCallback((e: any) => {
|
const handleKeyDown = useCallback((e: any) => {
|
||||||
|
if (isArrowNavigationBlocked(e)) return;
|
||||||
if (e.keyCode === 37 && dayIndex != null && dayIndex > 0) {
|
if (e.keyCode === 37 && dayIndex != null && dayIndex > 0) {
|
||||||
handleDayChange(dayIndex - 1);
|
handleDayChange(dayIndex - 1);
|
||||||
} else if (e.keyCode === 39 && dayIndex != null && dayIndex < 4) {
|
} else if (e.keyCode === 39 && dayIndex != null && dayIndex < 4) {
|
||||||
@@ -408,6 +421,24 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Převzetí výsledku kola štěstí — podnik s menu vybere i jídlo, rozvoz se zapíše do poznámky
|
||||||
|
const handleWheelPick = async (pick: WheelPick) => {
|
||||||
|
if (!auth?.login) return;
|
||||||
|
try {
|
||||||
|
if (pick.kind === 'menu') {
|
||||||
|
await addChoice({ body: { locationKey: pick.restaurant, foodIndex: pick.foodIndex, dayIndex } });
|
||||||
|
} else {
|
||||||
|
await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, dayIndex } });
|
||||||
|
// Server přijímá poznámku max. 70 znaků
|
||||||
|
const note = pick.dish ? `${pick.store}: ${pick.dish}` : pick.store;
|
||||||
|
await updateNote({ body: { note: note.length > 70 ? note.slice(0, 69) + '…' : note, dayIndex } });
|
||||||
|
}
|
||||||
|
await tryAutoSelectDepartureTime();
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(`Chyba při změně volby: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saveNote = async (note?: string) => {
|
const saveNote = async (note?: string) => {
|
||||||
if (auth?.login) {
|
if (auth?.login) {
|
||||||
await updateNote({ body: { note, dayIndex } });
|
await updateNote({ body: { note, dayIndex } });
|
||||||
@@ -415,6 +446,13 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savePrice = async (price?: number) => {
|
||||||
|
if (auth?.login) {
|
||||||
|
await updatePrice({ body: { price, dayIndex } });
|
||||||
|
setPriceModalOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const copyNote = async (note: string) => {
|
const copyNote = async (note: string) => {
|
||||||
if (auth?.login && note) {
|
if (auth?.login && note) {
|
||||||
await updateNote({ body: { note, dayIndex } });
|
await updateNote({ body: { note, dayIndex } });
|
||||||
@@ -587,7 +625,7 @@ function App() {
|
|||||||
} else {
|
} else {
|
||||||
content = <div className="restaurant-error">Chyba načtení dat</div>
|
content = <div className="restaurant-error">Chyba načtení dat</div>
|
||||||
}
|
}
|
||||||
return <Col md={6} lg={3} className='mt-3'>
|
return <Col md={6} lg className='mt-3'>
|
||||||
<div className="restaurant-card">
|
<div className="restaurant-card">
|
||||||
<div className="restaurant-header" style={{ cursor: canChangeChoice ? 'pointer' : 'default' }} onClick={() => doAddClickFoodChoice(location)}>
|
<div className="restaurant-header" style={{ cursor: canChangeChoice ? 'pointer' : 'default' }} onClick={() => doAddClickFoodChoice(location)}>
|
||||||
<div className="restaurant-header-content">
|
<div className="restaurant-header-content">
|
||||||
@@ -638,6 +676,15 @@ function App() {
|
|||||||
const noOrders = data?.pizzaDay?.orders?.length === 0;
|
const noOrders = data?.pizzaDay?.orders?.length === 0;
|
||||||
const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex;
|
const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex;
|
||||||
|
|
||||||
|
const ownChoice = auth?.login ? Object.entries(data.choices)
|
||||||
|
.map(([locationKey, loginMap]) => ({ locationKey: locationKey as LunchChoice, choice: loginMap?.[auth.login!] }))
|
||||||
|
.find(entry => entry.choice != null) as { locationKey: LunchChoice, choice: UserLunchChoice } | undefined : undefined;
|
||||||
|
// Součet cen vybraných jídel z menu jako návrh ceny; pokud některou cenu nelze přečíst, nenavrhujeme nic
|
||||||
|
const ownFoodPrices = ownChoice?.choice.selectedFoods?.map(index => parsePriceCzk(food[ownChoice.locationKey as Restaurant]?.food?.[index]?.price)) ?? [];
|
||||||
|
const ownSuggestedPrice = ownFoodPrices.length && !ownFoodPrices.includes(null)
|
||||||
|
? Math.round((ownFoodPrices as number[]).reduce((sum, price) => sum + price, 0) * 100)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
|
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -662,7 +709,7 @@ function App() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<Row className='food-tables'>
|
<Row className='food-tables justify-content-center'>
|
||||||
{Object.keys(Restaurant).map(key => {
|
{Object.keys(Restaurant).map(key => {
|
||||||
const locationKey = key as Restaurant;
|
const locationKey = key as Restaurant;
|
||||||
return food[locationKey] && renderFoodTable(locationKey, food[locationKey]);
|
return food[locationKey] && renderFoodTable(locationKey, food[locationKey]);
|
||||||
@@ -677,7 +724,11 @@ function App() {
|
|||||||
{Object.entries(LunchChoice)
|
{Object.entries(LunchChoice)
|
||||||
.filter(entry => {
|
.filter(entry => {
|
||||||
const locationKey = entry[0] as Restaurant;
|
const locationKey = entry[0] as Restaurant;
|
||||||
return !food[locationKey]?.closed;
|
// Podniky, pro které server nevrátil menu (dočasně uzavřené), nenabízíme
|
||||||
|
if (Object.keys(Restaurant).includes(locationKey) && !food?.[locationKey]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !food?.[locationKey]?.closed;
|
||||||
})
|
})
|
||||||
.map(entry => <option key={entry[0]} value={entry[0]}>{getLunchChoiceName(entry[1])}</option>)}
|
.map(entry => <option key={entry[0]} value={entry[0]}>{getLunchChoiceName(entry[1])}</option>)}
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
@@ -745,9 +796,11 @@ function App() {
|
|||||||
{trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'>
|
{trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'>
|
||||||
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
|
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
|
||||||
</span>}
|
</span>}
|
||||||
|
<EaterBadge items={userChoices?.length ?? 0} />
|
||||||
<strong>{login}</strong>
|
<strong>{login}</strong>
|
||||||
{userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>}
|
{userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>}
|
||||||
{userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>}
|
{userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>}
|
||||||
|
{login === auth.login && userPayload.price != null && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>{userPayload.price / 100} Kč</small>}
|
||||||
</div>
|
</div>
|
||||||
<div className="user-actions">
|
<div className="user-actions">
|
||||||
{login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'>
|
{login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'>
|
||||||
@@ -768,6 +821,12 @@ function App() {
|
|||||||
copyNote(userPayload.note!);
|
copyNote(userPayload.note!);
|
||||||
}} className='action-icon' icon={faComment} />
|
}} className='action-icon' icon={faComment} />
|
||||||
</span>}
|
</span>}
|
||||||
|
{login === auth.login && canChangeChoice && locationKey === LunchChoice.ROZHODUJI && <span title='Roztočit kolo štěstí'>
|
||||||
|
<FontAwesomeIcon onClick={() => setSpinWheelOpen(true)} className='action-icon spin-wheel-icon' icon={faDharmachakra} />
|
||||||
|
</span>}
|
||||||
|
{login === auth.login && canChangeChoice && !CHOICES_WITHOUT_PRICE.includes(locationKey) && <span title='Zadat cenu oběda do přehledu stravování'>
|
||||||
|
<FontAwesomeIcon onClick={() => setPriceModalOpen(true)} className='action-icon' icon={faTag} />
|
||||||
|
</span>}
|
||||||
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
|
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
|
||||||
<FontAwesomeIcon onClick={() => {
|
<FontAwesomeIcon onClick={() => {
|
||||||
setNoteModalOpen(true);
|
setNoteModalOpen(true);
|
||||||
@@ -931,7 +990,15 @@ function App() {
|
|||||||
leafVariants={LEAF_COLOR_THEMES.AUTUMN}
|
leafVariants={LEAF_COLOR_THEMES.AUTUMN}
|
||||||
/> */}
|
/> */}
|
||||||
<Footer />
|
<Footer />
|
||||||
|
{spinWheelOpen && <SpinWheelModal isOpen onClose={() => setSpinWheelOpen(false)} menus={food} onPick={handleWheelPick} />}
|
||||||
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
|
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
|
||||||
|
<PriceModal
|
||||||
|
isOpen={priceModalOpen}
|
||||||
|
onClose={() => setPriceModalOpen(false)}
|
||||||
|
price={ownChoice?.choice.price}
|
||||||
|
suggestedPrice={ownSuggestedPrice}
|
||||||
|
onSave={savePrice}
|
||||||
|
/>
|
||||||
{payForAllLocationKey && data && (
|
{payForAllLocationKey && data && (
|
||||||
<PayForAllModal
|
<PayForAllModal
|
||||||
isOpen
|
isOpen
|
||||||
|
|||||||
+29
-18
@@ -1,5 +1,5 @@
|
|||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import { ProvideSettings } from "./context/settings";
|
import { ProvideSettings, useSettings } from "./context/settings";
|
||||||
import FlyingButterflies, { BUTTERFLY_PRESETS } from "./FlyingButterflies";
|
import FlyingButterflies, { BUTTERFLY_PRESETS } from "./FlyingButterflies";
|
||||||
import "./FlyingButterflies.scss";
|
import "./FlyingButterflies.scss";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
@@ -8,39 +8,50 @@ import StatsPage from "./pages/StatsPage";
|
|||||||
import OrderGroupsPage from "./pages/OrderGroupsPage";
|
import OrderGroupsPage from "./pages/OrderGroupsPage";
|
||||||
import SuggestionsPage from "./pages/SuggestionsPage";
|
import SuggestionsPage from "./pages/SuggestionsPage";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import HellGirlFlames from "./components/HellGirlFlames";
|
||||||
|
|
||||||
export const STATS_URL = '/stats';
|
export const STATS_URL = '/stats';
|
||||||
export const OBJEDNANI_URL = '/objednani';
|
export const OBJEDNANI_URL = '/objednani';
|
||||||
export const NAVRHY_URL = '/navrhy';
|
export const NAVRHY_URL = '/navrhy';
|
||||||
|
|
||||||
|
/** Motýlci se vykreslují jen pokud je uživatel nevypnul v nastavení. */
|
||||||
|
function Butterflies() {
|
||||||
|
const settings = useSettings();
|
||||||
|
if (settings?.showButterflies === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <FlyingButterflies numButterflies={BUTTERFLY_PRESETS.NORMAL} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Doplňkové efekty vizuálních stylů — společné pro všechny stránky (např. plamen při otevření dialogu). */
|
||||||
|
function StyleThemeEffects() {
|
||||||
|
const settings = useSettings();
|
||||||
|
return settings?.styleTheme === 'hellgirl' ? <HellGirlFlames /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AppRoutes() {
|
export default function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<ProvideSettings>
|
||||||
<Route path={STATS_URL} element={<StatsPage />} />
|
<StyleThemeEffects />
|
||||||
<Route path={NAVRHY_URL} element={
|
<Routes>
|
||||||
<ProvideSettings>
|
<Route path={STATS_URL} element={<StatsPage />} />
|
||||||
<SuggestionsPage />
|
<Route path={NAVRHY_URL} element={<SuggestionsPage />} />
|
||||||
</ProvideSettings>
|
<Route path={OBJEDNANI_URL} element={
|
||||||
} />
|
|
||||||
<Route path={OBJEDNANI_URL} element={
|
|
||||||
<ProvideSettings>
|
|
||||||
<SocketContext.Provider value={socket}>
|
<SocketContext.Provider value={socket}>
|
||||||
<OrderGroupsPage />
|
<OrderGroupsPage />
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</SocketContext.Provider>
|
</SocketContext.Provider>
|
||||||
</ProvideSettings>
|
} />
|
||||||
} />
|
<Route path="/" element={
|
||||||
<Route path="/" element={
|
|
||||||
<ProvideSettings>
|
|
||||||
<SocketContext.Provider value={socket}>
|
<SocketContext.Provider value={socket}>
|
||||||
<>
|
<>
|
||||||
<FlyingButterflies numButterflies={BUTTERFLY_PRESETS.NORMAL} />
|
<Butterflies />
|
||||||
<App />
|
<App />
|
||||||
</>
|
</>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</SocketContext.Provider>
|
</SocketContext.Provider>
|
||||||
</ProvideSettings>
|
} />
|
||||||
} />
|
</Routes>
|
||||||
</Routes>
|
</ProvideSettings>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,3 +87,13 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-card > .hint {
|
||||||
|
color: var(--luncher-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
color: var(--luncher-danger);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
+70
-38
@@ -1,29 +1,37 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from 'react-bootstrap';
|
import { Button } from 'react-bootstrap';
|
||||||
import { useAuth } from './context/auth';
|
import { useAuth } from './context/auth';
|
||||||
import { login } from '../../types';
|
import { login } from '../../types';
|
||||||
|
import { useAppConfig } from './hooks/useGroupLimits';
|
||||||
import './Login.css';
|
import './Login.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formulář pro prvotní zadání přihlašovacího jména.
|
* Přihlášení. Podle konfigurace serveru buď přihlásí uživatele automaticky přes důvěryhodné
|
||||||
|
* hlavičky z proxy (bez formuláře), nebo zobrazí formulář pro zadání jména. Když se konfigurace
|
||||||
|
* nenačte, zkusí nejprve přihlášení „naprázdno" a jinak zobrazí formulář (původní chování).
|
||||||
*/
|
*/
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
const { loaded, trustedHeaders } = useAppConfig();
|
||||||
const loginRef = useRef<HTMLInputElement>(null);
|
const loginRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [trustedError, setTrustedError] = useState<string>();
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auth && !auth.login) {
|
if (!auth || auth.login || !loaded || trustedHeaders === false) return;
|
||||||
// Vyzkoušíme přihlášení "naprázdno", pokud projde, přihlásili nás trusted headers
|
// Přihlášení přes hlavičky z proxy (případně pokus „naprázdno", pokud nevíme, jak server přihlašuje)
|
||||||
login().then(response => {
|
setTrustedError(undefined);
|
||||||
const token = response.data;
|
login().then(response => {
|
||||||
if (token) {
|
const token = response.data;
|
||||||
auth?.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string
|
if (token) {
|
||||||
}
|
auth.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string
|
||||||
}).catch(error => {
|
} else if (trustedHeaders) {
|
||||||
// nezajímá nás
|
setTrustedError((response.error as any)?.error ?? 'Přihlášení přes proxy se nezdařilo.');
|
||||||
});
|
}
|
||||||
}
|
}).catch(() => {
|
||||||
}, [auth]);
|
if (trustedHeaders) setTrustedError('Server je nedostupný. Zkuste to prosím za chvíli.');
|
||||||
|
});
|
||||||
|
}, [auth, loaded, trustedHeaders, attempt]);
|
||||||
|
|
||||||
const doLogin = useCallback(async () => {
|
const doLogin = useCallback(async () => {
|
||||||
const length = loginRef?.current?.value.length && loginRef.current.value.replaceAll(/\s/g, '').length
|
const length = loginRef?.current?.value.length && loginRef.current.value.replaceAll(/\s/g, '').length
|
||||||
@@ -35,36 +43,60 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
}, [auth]);
|
}, [auth]);
|
||||||
|
|
||||||
if (!auth?.login) {
|
if (auth?.login) {
|
||||||
|
return <div>Neplatný stav</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = <>
|
||||||
|
<h1 className='login-logo'>Luncher</h1>
|
||||||
|
<p className='login-subtitle'>Aplikace pro profesionální management obědů</p>
|
||||||
|
</>;
|
||||||
|
|
||||||
|
// Přihlašuje proxy — formulář se jménem nedává smysl
|
||||||
|
if (!loaded || trustedHeaders) {
|
||||||
return (
|
return (
|
||||||
<div className='login-page'>
|
<div className='login-page'>
|
||||||
<div className='login-card'>
|
<div className='login-card'>
|
||||||
<h1 className='login-logo'>Luncher</h1>
|
{header}
|
||||||
<p className='login-subtitle'>Aplikace pro profesionální management obědů</p>
|
{trustedError ? (
|
||||||
<div className='login-form'>
|
<div className='login-form'>
|
||||||
<div>
|
<p className='login-error'>{trustedError}</p>
|
||||||
<label htmlFor="login-input">Zobrazované jméno</label>
|
<Button onClick={() => setAttempt(a => a + 1)}>Zkusit znovu</Button>
|
||||||
<input
|
|
||||||
id="login-input"
|
|
||||||
ref={loginRef}
|
|
||||||
type='text'
|
|
||||||
placeholder="Např. Jan Novák"
|
|
||||||
onKeyDown={event => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
doLogin()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p className='hint'>
|
|
||||||
Zadejte jméno nebo přezdívku, pod kterou vás kolegové snadno identifikují.
|
|
||||||
Jméno je možné kdykoli změnit.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={doLogin}>Pokračovat</Button>
|
) : (
|
||||||
</div>
|
<p className='hint'>Přihlašuji…</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <div>Neplatný stav</div>
|
|
||||||
|
return (
|
||||||
|
<div className='login-page'>
|
||||||
|
<div className='login-card'>
|
||||||
|
{header}
|
||||||
|
<div className='login-form'>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="login-input">Zobrazované jméno</label>
|
||||||
|
<input
|
||||||
|
id="login-input"
|
||||||
|
ref={loginRef}
|
||||||
|
type='text'
|
||||||
|
placeholder="Např. Jan Novák"
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
doLogin()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className='hint'>
|
||||||
|
Zadejte jméno nebo přezdívku, pod kterou vás kolegové snadno identifikují.
|
||||||
|
Jméno je možné kdykoli změnit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={doLogin}>Pokračovat</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
.duel-invite {
|
||||||
|
position: fixed;
|
||||||
|
left: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 1040;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--luncher-primary, #198754);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
animation: duel-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes duel-pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.04); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-game-modal {
|
||||||
|
.draw-topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-hint {
|
||||||
|
// Mezery mezi slovy v masce se nesmí slít, jinak nejde poznat počet slov
|
||||||
|
white-space: pre;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-word strong {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-timer.urgent {
|
||||||
|
color: #ef4444;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-canvas-col {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
touch-action: none;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-tools {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-color {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(127, 127, 127, 0.5);
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
outline: 3px solid var(--luncher-primary, #198754);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-size {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(127, 127, 127, 0.5);
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
outline: 3px solid var(--luncher-primary, #198754);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-sep {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-side {
|
||||||
|
flex: 0 0 260px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-scores {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guessed {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-chat {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid rgba(127, 127, 127, 0.35);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
.system {
|
||||||
|
color: #22c55e;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-players {
|
||||||
|
columns: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Na úzkém displeji (telefon na výšku) jde chat pod plátno, jinak by se plátno smrsklo na proužek
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.draw-topbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-side {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-chat {
|
||||||
|
min-height: 120px;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, Form, Modal, Table } from "react-bootstrap";
|
||||||
|
import { socket } from "../context/socket";
|
||||||
|
import { useAuth } from "../context/auth";
|
||||||
|
import { getToken } from "../Utils";
|
||||||
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
|
import "./DrawingGame.scss";
|
||||||
|
|
||||||
|
// Typy musí odpovídat server/src/drawingGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
||||||
|
type DrawPhase = 'idle' | 'lobby' | 'drawing' | 'reveal' | 'finished';
|
||||||
|
type DrawPlayer = { login: string, score: number, guessed: boolean, place?: number };
|
||||||
|
type ChatMessage = { login?: string, text: string, system?: boolean };
|
||||||
|
type Stroke = { color: string, size: number, points: [number, number][], start: boolean };
|
||||||
|
type DrawState = {
|
||||||
|
phase: DrawPhase,
|
||||||
|
host?: string,
|
||||||
|
players: DrawPlayer[],
|
||||||
|
drawer?: string,
|
||||||
|
round: number,
|
||||||
|
totalRounds: number,
|
||||||
|
hint?: string,
|
||||||
|
answer?: string,
|
||||||
|
remainingMs?: number,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
minPlayers: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPEN_EVENT = 'luncher:open-drawing';
|
||||||
|
|
||||||
|
/** Otevře okno Kreslení. */
|
||||||
|
export const openDrawingGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
|
||||||
|
|
||||||
|
const CANVAS_W = 800;
|
||||||
|
const CANVAS_H = 600;
|
||||||
|
/** Souřadnice tahů jsou 0–1000 v obou osách, nezávisle na velikosti plátna. */
|
||||||
|
const COORD_MAX = 1000;
|
||||||
|
const COLORS = ['#111827', '#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#a855f7', '#92400e', '#ffffff'];
|
||||||
|
const SIZES = [3, 8, 18];
|
||||||
|
/** Jak často se rozkreslený tah posílá ostatním (ms). */
|
||||||
|
const FLUSH_MS = 40;
|
||||||
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
|
|
||||||
|
function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke) {
|
||||||
|
const sx = CANVAS_W / COORD_MAX;
|
||||||
|
const sy = CANVAS_H / COORD_MAX;
|
||||||
|
ctx.strokeStyle = stroke.color;
|
||||||
|
ctx.fillStyle = stroke.color;
|
||||||
|
ctx.lineWidth = stroke.size;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
const [first, ...rest] = stroke.points;
|
||||||
|
if (rest.length === 0) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(first[0] * sx, first[1] * sy, stroke.size / 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(first[0] * sx, first[1] * sy);
|
||||||
|
rest.forEach(([x, y]) => ctx.lineTo(x * sx, y * sy));
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCanvas(ctx: CanvasRenderingContext2D) {
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kreslení — jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. */
|
||||||
|
export default function DrawingGame() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const login = auth?.login;
|
||||||
|
const [state, setState] = useState<DrawState | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [word, setWord] = useState<string | null>(null);
|
||||||
|
const [color, setColor] = useState(COLORS[0]);
|
||||||
|
const [size, setSize] = useState(SIZES[1]);
|
||||||
|
const [guess, setGuess] = useState('');
|
||||||
|
const [secondsLeft, setSecondsLeft] = useState(0);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const chatRef = useRef<HTMLDivElement>(null);
|
||||||
|
const prevPhase = useRef<DrawPhase>('idle');
|
||||||
|
/** Rozkreslený tah: body čekající na odeslání a poslední odeslaný bod pro navázání. */
|
||||||
|
const pending = useRef<{ points: [number, number][], start: boolean } | null>(null);
|
||||||
|
const drawingRef = useRef(false);
|
||||||
|
const icon = useGameIcon('drawing');
|
||||||
|
|
||||||
|
const send = useCallback((event: string, extra?: object) => {
|
||||||
|
setError(null);
|
||||||
|
socket.emit(event, { token: getToken(), ...extra });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ctx = () => canvasRef.current?.getContext('2d') ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onState = (s: DrawState) => setState(s);
|
||||||
|
const onError = (msg: string) => setError(msg);
|
||||||
|
const onOpen = () => setOpen(true);
|
||||||
|
const sync = () => socket.emit('draw:sync');
|
||||||
|
socket.on('draw:state', onState);
|
||||||
|
socket.on('draw:error', onError);
|
||||||
|
socket.on('connect', sync);
|
||||||
|
window.addEventListener(OPEN_EVENT, onOpen);
|
||||||
|
sync();
|
||||||
|
return () => {
|
||||||
|
socket.off('draw:state', onState);
|
||||||
|
socket.off('draw:error', onError);
|
||||||
|
socket.off('connect', sync);
|
||||||
|
window.removeEventListener(OPEN_EVENT, onOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isDrawer = !!login && state?.phase === 'drawing' && state.drawer === login;
|
||||||
|
const isDrawerRef = useRef(isDrawer);
|
||||||
|
isDrawerRef.current = isDrawer;
|
||||||
|
|
||||||
|
// Kresba a slovo chodí jen klientům s otevřeným oknem
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const watch = () => socket.emit('draw:watch', { token: getToken() });
|
||||||
|
const onStrokes = (strokes: Stroke[]) => {
|
||||||
|
const c = ctx();
|
||||||
|
if (!c) return;
|
||||||
|
clearCanvas(c);
|
||||||
|
strokes.forEach(s => drawStroke(c, s));
|
||||||
|
};
|
||||||
|
// Vlastní tahy kreslíř vykresluje hned lokálně
|
||||||
|
const onStroke = (s: Stroke) => {
|
||||||
|
const c = ctx();
|
||||||
|
if (c && !isDrawerRef.current) drawStroke(c, s);
|
||||||
|
};
|
||||||
|
const onClear = () => {
|
||||||
|
const c = ctx();
|
||||||
|
if (c) clearCanvas(c);
|
||||||
|
};
|
||||||
|
const onWord = (w: string) => setWord(w);
|
||||||
|
socket.on('draw:strokes', onStrokes);
|
||||||
|
socket.on('draw:stroke', onStroke);
|
||||||
|
socket.on('draw:clear', onClear);
|
||||||
|
socket.on('draw:word', onWord);
|
||||||
|
socket.on('connect', watch);
|
||||||
|
watch();
|
||||||
|
return () => {
|
||||||
|
socket.off('draw:strokes', onStrokes);
|
||||||
|
socket.off('draw:stroke', onStroke);
|
||||||
|
socket.off('draw:clear', onClear);
|
||||||
|
socket.off('draw:word', onWord);
|
||||||
|
socket.off('connect', watch);
|
||||||
|
socket.emit('draw:unwatch');
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
||||||
|
const phase = state?.phase ?? 'idle';
|
||||||
|
const inGame = phase === 'drawing' || phase === 'reveal';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevPhase.current === 'lobby' && phase === 'drawing' && isPlayer) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
prevPhase.current = phase;
|
||||||
|
}, [phase, isPlayer]);
|
||||||
|
|
||||||
|
// Slovo patří jen k aktuálnímu kolu
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDrawer) setWord(null);
|
||||||
|
}, [isDrawer, state?.round]);
|
||||||
|
|
||||||
|
// Plátno se po vykreslení (nové kolo, otevření okna) vyplní bílou a znovu vyžádá kresba
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && inGame) {
|
||||||
|
const c = ctx();
|
||||||
|
if (c) clearCanvas(c);
|
||||||
|
socket.emit('draw:watch', { token: getToken() });
|
||||||
|
}
|
||||||
|
}, [open, inGame]);
|
||||||
|
|
||||||
|
// Odpočet kola počítaný lokálně od posledního stavu ze serveru
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'drawing' || state?.remainingMs === undefined) {
|
||||||
|
setSecondsLeft(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const endsAt = Date.now() + state.remainingMs;
|
||||||
|
const update = () => setSecondsLeft(Math.max(0, Math.ceil((endsAt - Date.now()) / 1000)));
|
||||||
|
update();
|
||||||
|
const id = window.setInterval(update, 250);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [phase, state?.remainingMs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight });
|
||||||
|
}, [state?.messages.length]);
|
||||||
|
|
||||||
|
// Průběžné odesílání rozkresleného tahu
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDrawer) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
const p = pending.current;
|
||||||
|
if (!p || p.points.length === 0 || (p.points.length === 1 && !p.start)) return;
|
||||||
|
send('draw:stroke', { color, size, points: p.points, start: p.start });
|
||||||
|
// Další dávka začne posledním bodem, aby čára navazovala
|
||||||
|
pending.current = drawingRef.current ? { points: [p.points[p.points.length - 1]], start: false } : null;
|
||||||
|
}, FLUSH_MS);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [isDrawer, color, size, send]);
|
||||||
|
|
||||||
|
const toCoords = (e: React.PointerEvent<HTMLCanvasElement>): [number, number] => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = Math.round(((e.clientX - rect.left) / rect.width) * COORD_MAX);
|
||||||
|
const y = Math.round(((e.clientY - rect.top) / rect.height) * COORD_MAX);
|
||||||
|
return [Math.min(COORD_MAX, Math.max(0, x)), Math.min(COORD_MAX, Math.max(0, y))];
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!isDrawer) return;
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
drawingRef.current = true;
|
||||||
|
const point = toCoords(e);
|
||||||
|
pending.current = { points: [point], start: true };
|
||||||
|
const c = ctx();
|
||||||
|
if (c) drawStroke(c, { color, size, points: [point], start: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!isDrawer || !drawingRef.current || !pending.current) return;
|
||||||
|
const point = toCoords(e);
|
||||||
|
const prev = pending.current.points[pending.current.points.length - 1];
|
||||||
|
if (prev && prev[0] === point[0] && prev[1] === point[1]) return;
|
||||||
|
pending.current.points.push(point);
|
||||||
|
const c = ctx();
|
||||||
|
if (c && prev) drawStroke(c, { color, size, points: [prev, point], start: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = () => {
|
||||||
|
drawingRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitGuess = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!guess.trim()) return;
|
||||||
|
send('draw:guess', { text: guess });
|
||||||
|
setGuess('');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!login) return null;
|
||||||
|
|
||||||
|
const me = state?.players.find(p => p.login === login);
|
||||||
|
const canChat = isPlayer && !isDrawer && !(phase === 'drawing' && me?.guessed);
|
||||||
|
const ranked = [...(state?.players ?? [])].sort((a, b) => b.score - a.score);
|
||||||
|
const showInvite = !open && phase === 'lobby' && !isPlayer;
|
||||||
|
const showReturn = !open && isPlayer && inGame;
|
||||||
|
|
||||||
|
const renderTopBar = () => {
|
||||||
|
if (phase === 'reveal' || phase === 'finished') {
|
||||||
|
return <div className="draw-topbar">Bylo to: <strong>{state?.answer}</strong></div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="draw-topbar">
|
||||||
|
<span>Kolo {state?.round}/{state?.totalRounds} · kreslí <strong>{state?.drawer}</strong></span>
|
||||||
|
{isDrawer
|
||||||
|
? <span className="draw-word">Nakreslete: <strong>{word ?? '…'}</strong></span>
|
||||||
|
: <span className="draw-hint">{state?.hint}</span>}
|
||||||
|
<span className={secondsLeft <= 10 ? 'draw-timer urgent' : 'draw-timer'}>⏱ {secondsLeft} s</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGame = () => (
|
||||||
|
<>
|
||||||
|
{renderTopBar()}
|
||||||
|
<div className="draw-layout">
|
||||||
|
<div className="draw-canvas-col">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`draw-canvas ${isDrawer ? 'active' : ''}`}
|
||||||
|
width={CANVAS_W}
|
||||||
|
height={CANVAS_H}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}
|
||||||
|
/>
|
||||||
|
{isDrawer && (
|
||||||
|
<div className="draw-tools">
|
||||||
|
{COLORS.map(c => (
|
||||||
|
<button key={c} className={`draw-color ${c === color ? 'selected' : ''}`}
|
||||||
|
style={{ background: c }} onClick={() => setColor(c)} aria-label={`Barva ${c}`} />
|
||||||
|
))}
|
||||||
|
<span className="draw-sep" />
|
||||||
|
{SIZES.map(s => (
|
||||||
|
<button key={s} className={`draw-size ${s === size ? 'selected' : ''}`} onClick={() => setSize(s)}
|
||||||
|
aria-label={`Tloušťka ${s}`}>
|
||||||
|
<span style={{ width: s, height: s }} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="draw-sep" />
|
||||||
|
<Button size="sm" variant="outline-secondary" onClick={() => send('draw:clear')}>Smazat</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="draw-side">
|
||||||
|
<ul className="draw-scores">
|
||||||
|
{ranked.map(p => (
|
||||||
|
<li key={p.login} className={p.guessed && phase === 'drawing' ? 'guessed' : undefined}>
|
||||||
|
<span>{p.login === state?.drawer && phase === 'drawing' ? '✏️ ' : ''}{p.login}{p.login === login && ' (vy)'}</span>
|
||||||
|
<strong>{p.score}</strong>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{renderChat()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderChat = () => (
|
||||||
|
<>
|
||||||
|
<div className="draw-chat" ref={chatRef}>
|
||||||
|
{state?.messages.map((m, i) => (
|
||||||
|
<div key={i} className={m.system ? 'system' : undefined}>
|
||||||
|
{m.login && <strong>{m.login}: </strong>}{m.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Form onSubmit={submitGuess}>
|
||||||
|
<Form.Control
|
||||||
|
value={guess}
|
||||||
|
onChange={e => setGuess(e.target.value)}
|
||||||
|
placeholder={isDrawer ? 'Kreslíte…' : canChat ? 'Váš tip…' : 'Uhodli jste ✔'}
|
||||||
|
disabled={!canChat}
|
||||||
|
maxLength={100}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBody = () => {
|
||||||
|
switch (phase) {
|
||||||
|
case 'idle':
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
Jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. Stačí trefit hlavní slovo
|
||||||
|
(např. „guláš"), diakritika nevadí. Čím dřív uhodnete, tím víc bodů; kreslíř dostává body
|
||||||
|
za každého, kdo uhodl. Každý kreslí jednou.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
case 'lobby':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
|
||||||
|
<ul className="draw-players">
|
||||||
|
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
|
||||||
|
</ul>
|
||||||
|
{renderChat()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'drawing':
|
||||||
|
case 'reveal':
|
||||||
|
return renderGame();
|
||||||
|
case 'finished':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table size="sm">
|
||||||
|
<thead><tr><th>#</th><th>Hráč</th><th>Body</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{ranked.map((p, i) => (
|
||||||
|
<tr key={p.login}>
|
||||||
|
<td>{i < 3 ? MEDALS[i] : i + 1}</td>
|
||||||
|
<td>{p.login}{p.login === login && ' (vy)'}</td>
|
||||||
|
<td>{p.score}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
const buttons = [];
|
||||||
|
if (phase === 'idle' || phase === 'finished') {
|
||||||
|
buttons.push(<Button key="new" onClick={() => send('draw:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
||||||
|
}
|
||||||
|
if (phase === 'lobby') {
|
||||||
|
if (!isPlayer) {
|
||||||
|
buttons.push(<Button key="join" onClick={() => send('draw:join')}>Připojit se</Button>);
|
||||||
|
} else {
|
||||||
|
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('draw:leave')}>
|
||||||
|
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
||||||
|
</Button>);
|
||||||
|
if (login === state?.host) {
|
||||||
|
buttons.push(<Button key="start" onClick={() => send('draw:start')}
|
||||||
|
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru</Button>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
||||||
|
return buttons;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showInvite && (
|
||||||
|
<button className="duel-invite draw-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} <strong>{state?.host}</strong> zve na kreslení — připojit se
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showReturn && (
|
||||||
|
<button className="duel-invite draw-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} Kreslení běží — zpět do hry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Modal show={open} onHide={() => setOpen(false)} size="xl" fullscreen="sm-down" className="drawing-game-modal">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>{icon} Kreslení</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{renderBody()}
|
||||||
|
{error && <div className="text-danger mt-2">{error}</div>}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Kulatý portrét postavy vedle jména strávníka s více jídly (styl Umamusume)
|
||||||
|
.eater-badge {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex: none;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 0 2px #ff6fa3;
|
||||||
|
cursor: help;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(2);
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styl My Little Pony: cutie mark s emoji v kulatém odznaku
|
||||||
|
.eater-badge-mlp {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
background: radial-gradient(circle at 35% 30%, #fff, #f6ecff);
|
||||||
|
box-shadow: 0 0 0 2px #8e4fd1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styl Hell Girl: emoji v černém odznaku s rudým lemem (rudá nit)
|
||||||
|
.eater-badge-hellgirl {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
border-color: #1c1012;
|
||||||
|
background: radial-gradient(circle at 50% 60%, #3a0d16, #12070a);
|
||||||
|
box-shadow: 0 0 0 2px #c8102e, 0 0 6px rgb(200 16 46 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styl K-ON!: emoji na krémovém „trsátku" s lemem v barvě kytary Gitah (cherry sunburst)
|
||||||
|
.eater-badge-kon {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 50% 50% 50% 50% / 42% 42% 58% 58%;
|
||||||
|
background: radial-gradient(circle at 40% 35%, #fffaf0, #fbe6c8);
|
||||||
|
box-shadow: 0 0 0 2px #d43f62, 0 2px 0 3px #27315f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styl Bocchi the Rock!: emoji na žluté samolepce s černou linkou a růžovým stínem
|
||||||
|
.eater-badge-bocchi {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-color: #1d1a22;
|
||||||
|
background: #ffe066;
|
||||||
|
box-shadow: 2px 2px 0 #e0457b;
|
||||||
|
transform: rotate(-4deg);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: rotate(0deg) scale(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Odznak a jméno drží na jednom řádku (úzký sloupec „Člen" na stránce objednávek)
|
||||||
|
.user-info:has(.eater-badge) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||||
|
import { StyleTheme, useSettings } from "../context/settings";
|
||||||
|
import { UmaCharacter, loadUmaCharacters } from "../utils/umaCharacters";
|
||||||
|
import "./EaterBadge.scss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Úrovně „jedlíka" podle počtu jídel: Special Week sní hodně, Oguri Cap ještě víc.
|
||||||
|
* Seřazeno od nejvyšší úrovně.
|
||||||
|
*/
|
||||||
|
const LEVELS = [
|
||||||
|
{ minItems: 3, character: 'Oguri Cap', title: 'Úroveň Oguri Cap — bezedný žaludek' },
|
||||||
|
{ minItems: 2, character: 'Special Week', title: 'Úroveň Special Week — pořádný apetit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Úrovně ve stylu My Little Pony — místo portrétu cutie mark (emoji). Seřazeno od nejvyšší úrovně. */
|
||||||
|
const MLP_LEVELS = [
|
||||||
|
{ minItems: 3, emoji: '🧁', title: 'Úroveň Pinkie Pie — cupcaků není nikdy dost' },
|
||||||
|
{ minItems: 2, emoji: '🍎', title: 'Úroveň Applejack — poctivý apetit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Úrovně ve stylu Hell Girl — emoji v černém odznaku s rudým lemem. Seřazeno od nejvyšší úrovně. */
|
||||||
|
const HG_LEVELS = [
|
||||||
|
{ minItems: 3, emoji: '🔥', title: 'Úroveň Wanyūdō — hlad jako pekelný oheň' },
|
||||||
|
{ minItems: 2, emoji: '🥀', title: 'Úroveň Ai Enma — tichý, ale nenasytný apetit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Úrovně ve stylu K-ON! — emoji v odznaku jako trsátko. Seřazeno od nejvyšší úrovně. */
|
||||||
|
const KON_LEVELS = [
|
||||||
|
{ minItems: 3, emoji: '🍰', title: 'Úroveň Yui — sní všechno a nepřibere ani gram' },
|
||||||
|
{ minItems: 2, emoji: '☕', title: 'Úroveň Tea Time — k obědu ještě čaj a dortík' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Úrovně ve stylu Bocchi the Rock! — emoji na samolepce jako z flyeru live house. Seřazeno od nejvyšší úrovně. */
|
||||||
|
const BOCCHI_LEVELS = [
|
||||||
|
{ minItems: 3, emoji: '🌿', title: 'Úroveň Ryo — když dojdou peníze, sní i trávu u cesty' },
|
||||||
|
{ minItems: 2, emoji: '🥭', title: 'Úroveň Bocchi — dvojitý oběd v klidu v krabici od manga' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Úrovně podle stylu pro odznaky s emoji. */
|
||||||
|
const EMOJI_LEVELS: Partial<Record<StyleTheme, typeof MLP_LEVELS>> = {
|
||||||
|
mlp: MLP_LEVELS,
|
||||||
|
hellgirl: HG_LEVELS,
|
||||||
|
kon: KON_LEVELS,
|
||||||
|
bocchi: BOCCHI_LEVELS,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Počet jídel/položek, které si strávník vybral či objednal */
|
||||||
|
items: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Odznak s postavou u strávníků s více jídly — jen ve stylech Umamusume, My Little Pony, Hell Girl, K-ON! a Bocchi the Rock!. */
|
||||||
|
export default function EaterBadge({ items }: Readonly<Props>) {
|
||||||
|
const styleTheme = useSettings()?.styleTheme;
|
||||||
|
const isUma = styleTheme === 'umamusume';
|
||||||
|
const [characters, setCharacters] = useState<UmaCharacter[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUma) loadUmaCharacters().then(setCharacters);
|
||||||
|
}, [isUma]);
|
||||||
|
|
||||||
|
const emojiLevels = styleTheme && EMOJI_LEVELS[styleTheme];
|
||||||
|
if (emojiLevels) {
|
||||||
|
const emojiLevel = emojiLevels.find(l => items >= l.minItems);
|
||||||
|
if (!emojiLevel) return null;
|
||||||
|
return (
|
||||||
|
<OverlayTrigger placement="top" overlay={<Tooltip>{emojiLevel.title} ({items} jídla)</Tooltip>}>
|
||||||
|
<span className={`eater-badge eater-badge-${styleTheme}`} role="img" aria-label={emojiLevel.title}>{emojiLevel.emoji}</span>
|
||||||
|
</OverlayTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const level = LEVELS.find(l => items >= l.minItems);
|
||||||
|
const character = level && characters.find(c => c.name === level.character);
|
||||||
|
// Malý odznak potřebuje obličej — portrét (icon), případně kulatý obrázek postavy
|
||||||
|
const src = character?.icon ?? (character?.round ? character.images[0] : undefined);
|
||||||
|
if (!isUma || !level || !src) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OverlayTrigger placement="top" overlay={<Tooltip>{level.title} ({items} jídla)</Tooltip>}>
|
||||||
|
<img src={src} alt={level.character} className="eater-badge" />
|
||||||
|
</OverlayTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Bez zoomu dvojitým ťuknutím, jinak by iOS sérii ťuknutí na logo spolkl
|
||||||
|
.navbar-brand {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skryté pole pro kód minihry — musí jít zaměřit (kvůli softwarové klávesnici), proto ne display: none.
|
||||||
|
// Písmo aspoň 16px, jinak iOS při zaměření přiblíží stránku.
|
||||||
|
.minigame-secret-input {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nenápadné potvrzení, že logo přijalo sérii kliknutí a čeká na tajné slovo
|
||||||
|
.navbar-brand.duel-armed {
|
||||||
|
display: inline-block;
|
||||||
|
animation: duel-wiggle 0.35s ease-in-out 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes duel-wiggle {
|
||||||
|
0%, 100% { transform: rotate(0); }
|
||||||
|
25% { transform: rotate(-4deg); }
|
||||||
|
75% { transform: rotate(4deg); }
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import { useEffect, useState } from "react";
|
|||||||
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import SettingsModal from "./modals/SettingsModal";
|
import SettingsModal from "./modals/SettingsModal";
|
||||||
|
import DrawingGame from "./DrawingGame";
|
||||||
|
import "./Header.scss";
|
||||||
|
import { useMinigameSecret } from "../hooks/useMinigameSecret";
|
||||||
import { useSettings, ThemePreference } from "../context/settings";
|
import { useSettings, ThemePreference } from "../context/settings";
|
||||||
import HuePicker from "./HuePicker";
|
import HuePicker from "./HuePicker";
|
||||||
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
||||||
@@ -39,6 +42,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
const effectiveDark = settings?.effectiveDark ?? false;
|
const effectiveDark = settings?.effectiveDark ?? false;
|
||||||
|
const minigameSecret = useMinigameSecret("/");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth?.login) return;
|
if (!auth?.login) return;
|
||||||
@@ -77,6 +81,9 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
setQrModalOpen(true);
|
setQrModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tematické styly mají pevnou paletu, výběr barvy má smysl jen u jednoduchého stylu
|
||||||
|
const isSimpleStyle = (settings?.styleTheme ?? 'simple') === 'simple';
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
|
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
|
||||||
settings?.setThemePreference(newTheme);
|
settings?.setThemePreference(newTheme);
|
||||||
@@ -144,7 +151,9 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <Navbar variant='dark' expand="lg">
|
return <Navbar variant='dark' expand="lg">
|
||||||
<Navbar.Brand href="/">Luncher</Navbar.Brand>
|
<Navbar.Brand href="/" onClick={minigameSecret.onLogoClick} className={minigameSecret.armed ? "duel-armed" : undefined}>Luncher</Navbar.Brand>
|
||||||
|
<input ref={minigameSecret.inputRef} className="minigame-secret-input" tabIndex={-1} aria-hidden="true"
|
||||||
|
autoComplete="off" autoCorrect="off" autoCapitalize="none" spellCheck={false} />
|
||||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||||
<Navbar.Collapse id="basic-navbar-nav">
|
<Navbar.Collapse id="basic-navbar-nav">
|
||||||
<Nav className="nav">
|
<Nav className="nav">
|
||||||
@@ -156,11 +165,11 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
|
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
|
||||||
</button>
|
</button>
|
||||||
<HuePicker
|
{isSimpleStyle && <HuePicker
|
||||||
accentHue={settings?.accentHue ?? 142}
|
accentHue={settings?.accentHue ?? 142}
|
||||||
isDark={effectiveDark}
|
isDark={effectiveDark}
|
||||||
onChange={hue => settings?.setAccentHue(hue)}
|
onChange={hue => settings?.setAccentHue(hue)}
|
||||||
/>
|
/>}
|
||||||
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
||||||
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
||||||
@@ -192,6 +201,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
</NavDropdown>
|
</NavDropdown>
|
||||||
</Nav>
|
</Nav>
|
||||||
</Navbar.Collapse>
|
</Navbar.Collapse>
|
||||||
|
<DrawingGame />
|
||||||
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
||||||
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
||||||
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Plamen stylu Hell Girl — hoří na černé přes obsah dialogu (.modal-content je relative a ve stylu
|
||||||
|
// Hell Girl ořezává přetečení), pak černá odezní a odhalí obsah.
|
||||||
|
.hg-burn {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: #020204; // barva pozadí GIFu, aby kolem plamene nebyl vidět obdélník
|
||||||
|
pointer-events: none;
|
||||||
|
animation: hg-burn-reveal 1.5s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hg-burn-flame {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
// Dlouhý dialog může přesahovat obrazovku — plamen držet ve viditelné části
|
||||||
|
top: min(50%, 45vh);
|
||||||
|
height: min(85%, 60vh);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hg-burn-reveal {
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { createSymbolMask, loadHellGirlImages } from "../utils/hellGirlImages";
|
||||||
|
import "./HellGirlFlames.scss";
|
||||||
|
|
||||||
|
/** Plamen vzplane a rozpadne se v jiskry za ~1,2 s, pak GIF drží černý snímek — mezitím se obsah vynoří z černé. */
|
||||||
|
const BURN_MS = 1500;
|
||||||
|
|
||||||
|
/** `target` = obsah dialogu, ve kterém se hoří. */
|
||||||
|
type Burn = { src: string, target: HTMLElement };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doplňky stylu Hell Girl: při otevření dialogu jeho obsah zčerná, uprostřed jednou
|
||||||
|
* vzplane plamen („Ippen, shinde miru?") a obsah se pak z černé vynoří. Dále symbol ohně ze znělky
|
||||||
|
* u titulku dne (nastaví CSS proměnnou --hg-symbol, dokud se nenačte, zůstávají v titulku původní květy).
|
||||||
|
*/
|
||||||
|
const HellGirlFlames: React.FC = () => {
|
||||||
|
const [burning, setBurning] = useState<Burn>();
|
||||||
|
const flameBlobRef = useRef<Blob | undefined>(undefined);
|
||||||
|
|
||||||
|
const burn = (target: HTMLElement) => {
|
||||||
|
const blob = flameBlobRef.current;
|
||||||
|
if (!blob || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
|
// Pokaždé nová URL — jinak by prohlížeč GIF (bez opakování) znovu nepřehrál
|
||||||
|
setBurning({ src: URL.createObjectURL(blob), target });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!burning) return;
|
||||||
|
const timeout = setTimeout(() => setBurning(undefined), BURN_MS);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
URL.revokeObjectURL(burning.src);
|
||||||
|
};
|
||||||
|
}, [burning]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const root = document.documentElement;
|
||||||
|
loadHellGirlImages().then(images => {
|
||||||
|
if (!images || cancelled) return;
|
||||||
|
fetch(images.flame)
|
||||||
|
.then(r => r.ok ? r.blob() : undefined)
|
||||||
|
.then(blob => { flameBlobRef.current = blob; })
|
||||||
|
.catch(() => { /* bez plamenů */ });
|
||||||
|
createSymbolMask(images.symbol).then(mask => {
|
||||||
|
if (!mask || cancelled) return;
|
||||||
|
root.style.setProperty('--hg-symbol', `url(${mask})`);
|
||||||
|
root.setAttribute('data-hg-symbol', '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
root.style.removeProperty('--hg-symbol');
|
||||||
|
root.removeAttribute('data-hg-symbol');
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Dialogy (react-bootstrap) se vkládají přes portál přímo do <body>
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver(mutations => {
|
||||||
|
const modalContent = mutations
|
||||||
|
.flatMap(m => Array.from(m.addedNodes))
|
||||||
|
.map(node => node instanceof HTMLElement ? node.querySelector<HTMLElement>('.modal-content') : null)
|
||||||
|
.find(element => !!element);
|
||||||
|
if (modalContent) burn(modalContent);
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!burning) return null;
|
||||||
|
return createPortal(
|
||||||
|
<div className="hg-burn" aria-hidden="true">
|
||||||
|
<img className="hg-burn-flame" src={burning.src} alt="" />
|
||||||
|
</div>,
|
||||||
|
burning.target,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HellGirlFlames;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// Kompaktní výběr z nabídky podniku v tabulce skupinové objednávky
|
||||||
|
.menu-hint-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 200px;
|
||||||
|
|
||||||
|
.select-search-container {
|
||||||
|
// Barvy knihovny napojené na témata aplikace (jinak bílé pozadí i v tmavém režimu)
|
||||||
|
--select-search-background: var(--luncher-bg-card);
|
||||||
|
--select-search-border: var(--luncher-border);
|
||||||
|
--select-search-text: var(--luncher-text);
|
||||||
|
--select-search-subtle-text: var(--luncher-text-muted);
|
||||||
|
--select-search-highlight: var(--luncher-bg-hover);
|
||||||
|
--select-search-selected: var(--luncher-primary);
|
||||||
|
--select-search-inverted-text: #fff;
|
||||||
|
--select-search-font: inherit;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.select-search-input {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-width: 1px;
|
||||||
|
background: var(--luncher-bg);
|
||||||
|
color: var(--luncher-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seznam přes okraje tabulky/karty (jinak by ho karta ořízla)
|
||||||
|
.select-search-select {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
left: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-search-options, .select-search-group ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-search-option {
|
||||||
|
height: auto;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--luncher-text);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jídlo na limitu kusů — nejde přidat
|
||||||
|
.select-search-option:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-search-group-header {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hint-items {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hint-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1px 4px 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--luncher-bg-hover);
|
||||||
|
border: 1px solid var(--luncher-border);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: var(--luncher-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0 4px;
|
||||||
|
color: var(--luncher-text-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--luncher-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hint-price {
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hint-use {
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--luncher-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||||
|
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from "react-select-search";
|
||||||
|
import { StoreMenuDish } from "../../../types";
|
||||||
|
import { countNoteItems, matchNoteToMenu, noteItemQuantity, splitNoteItems } from "../utils/menuMatch";
|
||||||
|
import { useGroupLimits } from "../hooks/useGroupLimits";
|
||||||
|
import "./MenuHintCell.scss";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Celá nabídka podniku (stažená z Bolt Food/Wolt), undefined = načítá se / není */
|
||||||
|
menu?: StoreMenuDish[],
|
||||||
|
note?: string,
|
||||||
|
/** Zadaná částka člena v haléřích */
|
||||||
|
amount?: number,
|
||||||
|
canEdit: boolean,
|
||||||
|
onPick: (dish: StoreMenuDish) => void,
|
||||||
|
onUsePrice: (priceKc: number) => void,
|
||||||
|
/** Odebere jeden kus položky poznámky na daném indexu (a odečte její cenu) */
|
||||||
|
onRemoveItem: (index: number) => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDish = (d: StoreMenuDish) => d.price != null ? `${d.name} (${d.price} Kč)` : d.name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buňka „Z nabídky" ve skupinové objednávce: výběr položky ze stažené nabídky podniku
|
||||||
|
* (doplní poznámku i částku) a kontrola ceny rozpoznané z poznámky proti zadané částce.
|
||||||
|
*/
|
||||||
|
export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUsePrice, onRemoveItem }: Readonly<Props>) {
|
||||||
|
const { maxItemQuantity, maxItems } = useGroupLimits();
|
||||||
|
const options = useMemo<SelectSearchOption[]>(() => {
|
||||||
|
if (!menu?.length) return [];
|
||||||
|
const groups = new Map<string, SelectSearchOption[]>();
|
||||||
|
menu.forEach((d, index) => {
|
||||||
|
const category = d.category || 'Ostatní';
|
||||||
|
if (!groups.has(category)) groups.set(category, []);
|
||||||
|
// Jídlo, kterého už je v objednávce maximum kusů, nejde přidat znovu
|
||||||
|
const atLimit = noteItemQuantity(note, d.name, menu) >= maxItemQuantity;
|
||||||
|
groups.get(category)!.push({ name: atLimit ? `${formatDish(d)} — max ${maxItemQuantity} ks` : formatDish(d), value: String(index), disabled: atLimit });
|
||||||
|
});
|
||||||
|
return [...groups.entries()].map(([name, items]) => ({ name, type: 'group', items }));
|
||||||
|
}, [menu, note, maxItemQuantity]);
|
||||||
|
const itemLimitReached = useMemo(() => countNoteItems(note, menu ?? []) >= maxItems, [note, menu, maxItems]);
|
||||||
|
|
||||||
|
const match = useMemo(() => menu ? matchNoteToMenu(note, menu) : undefined, [menu, note]);
|
||||||
|
const items = useMemo(() => splitNoteItems(note, menu), [note, menu]);
|
||||||
|
|
||||||
|
if (!menu?.length) {
|
||||||
|
return <small className="text-muted">—</small>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuPrice = match?.total;
|
||||||
|
const differs = menuPrice != null && amount != null && Math.round(menuPrice * 100) !== amount;
|
||||||
|
const unmatched = match?.parts.filter(p => !p.dish).map(p => p.text) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="menu-hint-cell">
|
||||||
|
{canEdit && itemLimitReached && (
|
||||||
|
<small className="text-muted">Dosažen limit {maxItems} položek — nejdřív něco odeber (×).</small>
|
||||||
|
)}
|
||||||
|
{canEdit && !itemLimitReached && (
|
||||||
|
<SelectSearch
|
||||||
|
search
|
||||||
|
options={options}
|
||||||
|
placeholder="Přidat z nabídky…"
|
||||||
|
value=""
|
||||||
|
onBlur={() => { }}
|
||||||
|
onFocus={() => { }}
|
||||||
|
onChange={(value: SelectedOptionValue | SelectedOptionValue[]) => {
|
||||||
|
const dish = menu[Number(value)];
|
||||||
|
if (dish) onPick(dish);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canEdit && items.length > 0 && (
|
||||||
|
<div className="menu-hint-items">
|
||||||
|
{items.map((text, i) => {
|
||||||
|
// Rozpoznaná položka ze stejného rozdělení poznámky — kvůli ceně na čipu
|
||||||
|
const part = match && match.parts.length === items.length ? match.parts[i] : undefined;
|
||||||
|
const price = part?.dish?.price;
|
||||||
|
const quantity = part?.quantity ?? 1;
|
||||||
|
return (
|
||||||
|
<span key={`${i}-${text}`} className="menu-hint-item">
|
||||||
|
{text}{price != null && <small> {quantity > 1 ? `${quantity}× ${price} = ${quantity * price}` : price} Kč</small>}
|
||||||
|
<button type="button" onClick={() => onRemoveItem(i)} title={`Odebrat 1× ${text}`} aria-label={`Odebrat ${text}`}>×</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{match && (
|
||||||
|
<OverlayTrigger
|
||||||
|
placement="top"
|
||||||
|
overlay={
|
||||||
|
<Tooltip>
|
||||||
|
{match.parts.map((p, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{p.dish
|
||||||
|
? `${p.quantity > 1 ? `${p.quantity}× ` : ''}${formatDish(p.dish)}`
|
||||||
|
: `„${p.text}" — v nabídce nenalezeno`}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<small className={`menu-hint-price ${differs ? 'text-warning' : 'text-muted'}`}>
|
||||||
|
{menuPrice != null
|
||||||
|
? <>Dle nabídky: <strong>{menuPrice} Kč</strong></>
|
||||||
|
: <>Nalezeno {match.parts.length - unmatched.length}/{match.parts.length} položek</>}
|
||||||
|
{differs && canEdit && (
|
||||||
|
<button type="button" className="menu-hint-use" onClick={() => onUsePrice(menuPrice!)}
|
||||||
|
title="Nastavit částku podle ceny v nabídce">
|
||||||
|
použít
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</small>
|
||||||
|
</OverlayTrigger>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
.price-history {
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-left: 3px solid var(--luncher-border);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 2px 0 6px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-tag {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 72px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-up .price-tag { color: var(--luncher-danger); }
|
||||||
|
.price-down .price-tag { color: var(--luncher-success); }
|
||||||
|
.price-new .price-tag { color: var(--luncher-primary); }
|
||||||
|
.price-removed { color: var(--luncher-text-muted); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-history-entry + .price-history-entry {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-history-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--luncher-bg-hover);
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--luncher-text-secondary);
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
color: var(--luncher-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { getStoreMenuHistory, StoreMenuProvider, StorePriceChange, StorePriceHistoryEntry } from "../../../types";
|
||||||
|
import { getHumanDateTime } from "../Utils";
|
||||||
|
import { computeSinceStart, plural, SinceStart } from "../utils/priceHistory";
|
||||||
|
import "./PriceHistory.scss";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
store: string,
|
||||||
|
provider: StoreMenuProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Popis jedné změny: zdražení/zlevnění s rozdílem, nová nebo odebraná položka. */
|
||||||
|
function ChangeRow({ change }: Readonly<{ change: StorePriceChange }>) {
|
||||||
|
const { name, before, after } = change;
|
||||||
|
if (before == null && after != null) {
|
||||||
|
return <li className="price-new"><span className="price-tag">nové</span> {name} <strong>{after} Kč</strong></li>;
|
||||||
|
}
|
||||||
|
if (after == null) {
|
||||||
|
return <li className="price-removed"><span className="price-tag">odebráno</span> <s>{name}</s>{before != null && <> ({before} Kč)</>}</li>;
|
||||||
|
}
|
||||||
|
const diff = after - (before ?? after);
|
||||||
|
const up = diff > 0;
|
||||||
|
return (
|
||||||
|
<li className={up ? 'price-up' : 'price-down'}>
|
||||||
|
<span className="price-tag">{up ? '▲' : '▼'} {up ? '+' : ''}{diff} Kč</span> {name}: {before} → <strong>{after} Kč</strong>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryRow({ entry }: Readonly<{ entry: StorePriceHistoryEntry }>) {
|
||||||
|
const when = getHumanDateTime(new Date(entry.at));
|
||||||
|
if (entry.initial) {
|
||||||
|
return <div className="price-history-entry"><small className="text-muted">{when} — první stažení ({plural(entry.itemCount ?? 0, 'položka', 'položky', 'položek')})</small></div>;
|
||||||
|
}
|
||||||
|
const ups = entry.changes.filter(c => c.before != null && c.after != null && c.after > c.before).length;
|
||||||
|
const downs = entry.changes.filter(c => c.before != null && c.after != null && c.after < c.before).length;
|
||||||
|
return (
|
||||||
|
<div className="price-history-entry">
|
||||||
|
<small className="text-muted">
|
||||||
|
{when} — {plural(entry.changes.length, 'změna', 'změny', 'změn')}{ups > 0 && `, ${ups}× zdražení`}{downs > 0 && `, ${downs}× zlevnění`}
|
||||||
|
</small>
|
||||||
|
<ul>{entry.changes.map((c, i) => <ChangeRow key={i} change={c} />)}</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SinceStartView({ summary }: Readonly<{ summary: SinceStart }>) {
|
||||||
|
const { since, changed, added, removed } = summary;
|
||||||
|
if (changed.length + added.length + removed.length === 0) {
|
||||||
|
return <small className="text-muted">Od {getHumanDateTime(new Date(since))} beze změn.</small>;
|
||||||
|
}
|
||||||
|
const ups = changed.filter(c => c.current > c.start).length;
|
||||||
|
const avgPct = changed.length
|
||||||
|
? changed.reduce((sum, c) => sum + (c.current - c.start) / c.start * 100, 0) / changed.length
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<div className="price-history-entry">
|
||||||
|
<small className="text-muted">
|
||||||
|
Od {getHumanDateTime(new Date(since))}: {plural(changed.length, 'změna ceny', 'změny cen', 'změn cen')} ({ups}× zdražení, {changed.length - ups}× zlevnění)
|
||||||
|
{changed.length > 0 && `, průměrně ${avgPct > 0 ? '+' : ''}${avgPct.toFixed(1)} %`}
|
||||||
|
{added.length > 0 && `, ${plural(added.length, 'nová položka', 'nové položky', 'nových položek')}`}{removed.length > 0 && `, ${plural(removed.length, 'odebraná', 'odebrané', 'odebraných')}`}
|
||||||
|
</small>
|
||||||
|
<ul>
|
||||||
|
{changed.map(c => {
|
||||||
|
const diff = c.current - c.start;
|
||||||
|
const up = diff > 0;
|
||||||
|
return (
|
||||||
|
<li key={c.name} className={up ? 'price-up' : 'price-down'}>
|
||||||
|
<span className="price-tag">{up ? '▲' : '▼'} {up ? '+' : ''}{diff} Kč</span> {c.name}: {c.start} → <strong>{c.current} Kč</strong>
|
||||||
|
<small className="text-muted"> ({up ? '+' : ''}{(diff / c.start * 100).toFixed(0)} %)</small>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{added.map(a => <li key={`+${a.name}`} className="price-new"><span className="price-tag">nové</span> {a.name}{a.price != null && <> <strong>{a.price} Kč</strong></>}</li>)}
|
||||||
|
{removed.map(r => <li key={`-${r.name}`} className="price-removed"><span className="price-tag">odebráno</span> <s>{r.name}</s>{r.price != null && <> ({r.price} Kč)</>}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Historie cen nabídky obchodu u jedné služby (verze z jednotlivých stažení, nejnovější nahoře). */
|
||||||
|
export default function PriceHistory({ store, provider }: Readonly<Props>) {
|
||||||
|
const [entries, setEntries] = useState<StorePriceHistoryEntry[]>();
|
||||||
|
const [view, setView] = useState<'versions' | 'since'>('versions');
|
||||||
|
const summary = useMemo(() => entries ? computeSinceStart(entries) : undefined, [entries]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getStoreMenuHistory({ query: { name: store } })
|
||||||
|
.then(r => setEntries(r.data?.providers.find(p => p.provider === provider)?.entries ?? []))
|
||||||
|
.catch(() => setEntries([]));
|
||||||
|
}, [store, provider]);
|
||||||
|
|
||||||
|
if (!entries) return <small className="text-muted">Načítám historii…</small>;
|
||||||
|
if (entries.length === 0) return <small className="text-muted">Historie cen zatím není — vznikne s prvním stažením nabídky.</small>;
|
||||||
|
const hasChanges = entries.some(e => e.changes.length > 0);
|
||||||
|
return (
|
||||||
|
<div className="price-history">
|
||||||
|
{hasChanges && (
|
||||||
|
<div className="price-history-tabs">
|
||||||
|
<button type="button" className={view === 'versions' ? 'active' : ''} onClick={() => setView('versions')}>Po verzích</button>
|
||||||
|
<button type="button" className={view === 'since' ? 'active' : ''} onClick={() => setView('since')}>Od začátku</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{view === 'since' && summary
|
||||||
|
? <SinceStartView summary={summary} />
|
||||||
|
: entries.map(e => <EntryRow key={e.at} entry={e} />)}
|
||||||
|
{entries.length === 1 && entries[0].initial && (
|
||||||
|
<small className="text-muted d-block mt-1">Změny se objeví po dalším stažení (↻).</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
.wheel-confetti {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000; // nad modalem (bootstrap 1055)
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
position: absolute;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vrh: rychle vzhůru do vrcholu (zpomalení), pak volný pád (zrychlení)
|
||||||
|
@keyframes confetti-throw {
|
||||||
|
0% { transform: translate3d(0, 0, 0) rotate(0deg) scale(0.4); opacity: 0; animation-timing-function: cubic-bezier(0.2, 0.7, 0.4, 1); }
|
||||||
|
6% { opacity: 1; }
|
||||||
|
38% { transform: translate3d(var(--x1), var(--y1), 0) rotate(var(--r1)) scale(1); animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.6); }
|
||||||
|
88% { opacity: 1; }
|
||||||
|
100% { transform: translate3d(var(--x2), var(--y2), 0) rotate(var(--r2)) scale(1); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déšť: plynulý pád shora s mírným houpáním
|
||||||
|
@keyframes confetti-rain {
|
||||||
|
0% { transform: translate3d(0, 0, 0) rotate(0deg); opacity: 0; animation-timing-function: ease-in-out; }
|
||||||
|
8% { opacity: 1; }
|
||||||
|
40% { transform: translate3d(var(--x1), var(--y1), 0) rotate(var(--r1)); animation-timing-function: ease-in; }
|
||||||
|
88% { opacity: 1; }
|
||||||
|
100% { transform: translate3d(var(--x2), var(--y2), 0) rotate(var(--r2)); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Samolepky Umamusume — stín pro čitelnost na libovolném pozadí
|
||||||
|
.confetti-uma {
|
||||||
|
filter: drop-shadow(0 3px 3px rgb(0 0 0 / 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-uma-emoji {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-uma-badge {
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
|
||||||
|
box-shadow: 0 4px 0 rgb(0 0 0 / 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-uma-img img {
|
||||||
|
width: 92px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portréty (bez průhlednosti) jako kulaté samolepky s barevným lemem
|
||||||
|
.confetti-uma-img.round img,
|
||||||
|
.confetti-uma-voice img.round {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4px solid #fff;
|
||||||
|
box-shadow: 0 0 0 3px #ff6fa3, 0 4px 8px rgb(0 0 0 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-uma-voice img.round {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-uma-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 38%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 12px 48px;
|
||||||
|
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: clamp(2rem, 7vw, 4rem);
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(90deg, #e84d86, #ff6fa3);
|
||||||
|
clip-path: polygon(24px 0, 100% 0, calc(100% - 24px) 100%, 0 100%);
|
||||||
|
text-shadow: 0 3px 0 rgb(0 0 0 / 0.2);
|
||||||
|
border-top: 5px solid #ffc531;
|
||||||
|
border-bottom: 5px solid #ffc531;
|
||||||
|
animation: uma-banner 1.8s cubic-bezier(0.2, 1.4, 0.4, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes uma-banner {
|
||||||
|
0% { transform: translate(-150%, -50%) skewX(-12deg); opacity: 0; }
|
||||||
|
25% { transform: translate(-50%, -50%) skewX(-12deg); opacity: 1; }
|
||||||
|
75% { transform: translate(-50%, -50%) skewX(-12deg) scale(1.04); opacity: 1; }
|
||||||
|
100% { transform: translate(60%, -50%) skewX(-12deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// „Hláška" postavy při finálním výsledku
|
||||||
|
.confetti-uma-voice {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: min(460px, calc(100vw - 32px));
|
||||||
|
animation: uma-voice 4.6s ease both;
|
||||||
|
|
||||||
|
img {
|
||||||
|
flex: none;
|
||||||
|
width: 110px;
|
||||||
|
height: auto;
|
||||||
|
filter: drop-shadow(0 4px 6px rgb(0 0 0 / 0.3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-uma-bubble {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1d3322;
|
||||||
|
border: 3px solid #ff6fa3;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 4px 0 #e84d86;
|
||||||
|
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #e84d86;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ocásek bubliny směrem k postavě
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -14px;
|
||||||
|
bottom: 14px;
|
||||||
|
border: 8px solid transparent;
|
||||||
|
border-right: 10px solid #ff6fa3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes uma-voice {
|
||||||
|
0% { opacity: 0; transform: translateY(40px) scale(0.8); }
|
||||||
|
10% { opacity: 1; transform: translateY(0) scale(1.03); }
|
||||||
|
15% { transform: translateY(0) scale(1); }
|
||||||
|
85% { opacity: 1; transform: translateY(0); }
|
||||||
|
100% { opacity: 0; transform: translateY(20px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Konfety ve stylu My Little Pony
|
||||||
|
// ============================================
|
||||||
|
.confetti-mlp {
|
||||||
|
filter: drop-shadow(0 3px 3px rgb(0 0 0 / 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-mlp-badge {
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Fredoka', 'Nunito', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
|
||||||
|
box-shadow: 0 0 12px rgb(255 255 255 / 0.7), 0 4px 0 rgb(0 0 0 / 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duhový nápis „Sonic Rainboom" přes celou obrazovku
|
||||||
|
.confetti-mlp-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 38%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 12px 44px;
|
||||||
|
font-family: 'Fredoka', 'Nunito', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: clamp(1.8rem, 6.5vw, 3.6rem);
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #ee4144, #f37033, #e3b829, #62bc4d, #1e98d3, #672f89);
|
||||||
|
border: 5px solid #fff;
|
||||||
|
text-shadow: 0 3px 0 rgb(0 0 0 / 0.25);
|
||||||
|
box-shadow: 0 0 30px rgb(236 61 143 / 0.5), 0 8px 0 rgb(0 0 0 / 0.12);
|
||||||
|
animation: mlp-banner 2s cubic-bezier(0.2, 1.4, 0.4, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mlp-banner {
|
||||||
|
0% { transform: translate(-50%, -50%) scale(0.2) rotate(-8deg); opacity: 0; }
|
||||||
|
25% { transform: translate(-50%, -50%) scale(1.08) rotate(0deg); opacity: 1; }
|
||||||
|
35% { transform: translate(-50%, -50%) scale(1); }
|
||||||
|
75% { transform: translate(-50%, -50%) scale(1.03); opacity: 1; }
|
||||||
|
100% { transform: translate(-50%, -160%) scale(0.9); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postava s hláškou — místo obrázku kulatý „cutie mark" s emoji
|
||||||
|
.confetti-mlp-avatar {
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 46px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 35% 30%, #fff, #f6ecff);
|
||||||
|
border: 4px solid #fff;
|
||||||
|
box-shadow: 0 0 0 4px var(--mlp-speaker, #8e4fd1), 0 4px 10px rgb(0 0 0 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-mlp-bubble {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
color: #2e1f47;
|
||||||
|
border: 3px solid var(--mlp-speaker, #8e4fd1);
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 0 4px 0 var(--mlp-speaker, #8e4fd1);
|
||||||
|
font-family: 'Fredoka', 'Nunito', sans-serif;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--mlp-speaker, #8e4fd1);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ocásek bubliny směrem k postavě
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -14px;
|
||||||
|
bottom: 14px;
|
||||||
|
border: 8px solid transparent;
|
||||||
|
border-right: 10px solid var(--mlp-speaker, #8e4fd1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Konfety ve stylu Hell Girl
|
||||||
|
// ============================================
|
||||||
|
.confetti-hg {
|
||||||
|
filter: drop-shadow(0 0 6px rgb(200 16 46 / 0.55)) drop-shadow(0 3px 3px rgb(0 0 0 / 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-hg-badge {
|
||||||
|
padding: 4px 14px;
|
||||||
|
border: 1px solid #c8102e;
|
||||||
|
outline: 1px solid rgb(200 16 46 / 0.4);
|
||||||
|
outline-offset: 2px;
|
||||||
|
color: #fff4ee;
|
||||||
|
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 0 8px rgb(232 52 79 / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nápis „poslání do pekla" — černá deska s rudým lemem, vynoří se z mlhy
|
||||||
|
.confetti-hg-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 38%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 14px 48px;
|
||||||
|
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: clamp(2rem, 7vw, 4rem);
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #fff4ee;
|
||||||
|
background: linear-gradient(90deg, transparent, #12070a 15%, #12070a 85%, transparent);
|
||||||
|
border-top: 2px solid #c8102e;
|
||||||
|
border-bottom: 2px solid #c8102e;
|
||||||
|
text-shadow: 0 0 18px rgb(232 52 79 / 0.9), 0 0 4px #c8102e;
|
||||||
|
animation: hg-banner 2.6s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hg-banner {
|
||||||
|
0% { transform: translate(-50%, -50%) scale(1.15); opacity: 0; filter: blur(12px); }
|
||||||
|
25% { transform: translate(-50%, -50%) scale(1); opacity: 1; filter: blur(0); }
|
||||||
|
75% { transform: translate(-50%, -50%) scale(1); opacity: 1; filter: blur(0); }
|
||||||
|
100% { transform: translate(-50%, -50%) scale(0.96); opacity: 0; filter: blur(8px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postava s hláškou — tmavý odznak s rudou září a bublina jako svitek
|
||||||
|
.confetti-hellgirl-voice {
|
||||||
|
.confetti-mlp-avatar {
|
||||||
|
border-color: #12070a;
|
||||||
|
background: radial-gradient(circle at 50% 60%, #3a0d16, #12070a);
|
||||||
|
box-shadow: 0 0 0 3px var(--mlp-speaker, #c8102e), 0 0 18px rgb(200 16 46 / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-mlp-bubble {
|
||||||
|
background: #170c0f;
|
||||||
|
color: #f3e6e2;
|
||||||
|
border: 1px solid var(--mlp-speaker, #c8102e);
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 0 16px rgb(0 0 0 / 0.6), 0 0 10px rgb(200 16 46 / 0.3);
|
||||||
|
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Konfety ve stylu K-ON!
|
||||||
|
// ============================================
|
||||||
|
.confetti-kon {
|
||||||
|
filter: drop-shadow(0 3px 3px rgb(39 49 95 / 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Samolepky jako na pouzdru kytary — bílý okraj a lehce natočené
|
||||||
|
.confetti-kon-badge {
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
|
||||||
|
box-shadow: 0 3px 0 rgb(39 49 95 / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nápis „Houkago Tea Time" — vstupenka na školní koncert s notovou osnovou
|
||||||
|
.confetti-kon-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 38%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 14px 44px;
|
||||||
|
font-family: 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: clamp(1.6rem, 6vw, 3.4rem);
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #27315f;
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(180deg, transparent 0 9px, rgb(39 49 95 / 0.12) 9px 10px) center / 100% 50px no-repeat,
|
||||||
|
#fffaf0;
|
||||||
|
border: 4px solid #27315f;
|
||||||
|
box-shadow: 0 0 0 6px #fffaf0, 0 0 0 9px #d43f62, 0 12px 26px rgb(0 0 0 / 0.25);
|
||||||
|
animation: kon-banner 2.2s cubic-bezier(0.3, 1.5, 0.5, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vyskočí do rytmu (raz-dva) a odplave nahoru
|
||||||
|
@keyframes kon-banner {
|
||||||
|
0% { transform: translate(-50%, -50%) scale(0.3) rotate(6deg); opacity: 0; }
|
||||||
|
18% { transform: translate(-50%, -50%) scale(1.1) rotate(-3deg); opacity: 1; }
|
||||||
|
28% { transform: translate(-50%, -50%) scale(1) rotate(-2deg); }
|
||||||
|
40% { transform: translate(-50%, -50%) scale(1.05) rotate(-2deg); }
|
||||||
|
50% { transform: translate(-50%, -50%) scale(1) rotate(-2deg); }
|
||||||
|
80% { transform: translate(-50%, -50%) scale(1) rotate(-2deg); opacity: 1; }
|
||||||
|
100% { transform: translate(-50%, -150%) scale(0.9) rotate(-2deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postava s hláškou — krémový odznak a bublina jako stránka notového sešitu
|
||||||
|
.confetti-kon-voice {
|
||||||
|
.confetti-mlp-avatar {
|
||||||
|
background: radial-gradient(circle at 40% 35%, #fffaf0, #fbe6c8);
|
||||||
|
box-shadow: 0 0 0 4px var(--mlp-speaker, #d43f62), 0 4px 10px rgb(0 0 0 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-mlp-bubble {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(180deg, transparent 0 21px, rgb(39 49 95 / 0.08) 21px 22px),
|
||||||
|
#fffaf0;
|
||||||
|
color: #27315f;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-family: 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Konfety ve stylu Bocchi the Rock!
|
||||||
|
// ============================================
|
||||||
|
.confetti-bocchi {
|
||||||
|
filter: drop-shadow(3px 3px 0 rgb(29 26 34 / 0.85));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Samolepky z flyeru live house — černá linka a tvrdý stín
|
||||||
|
.confetti-bocchi-badge {
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 3px solid #1d1a22;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Dela Gothic One', 'M PLUS Rounded 1c', sans-serif;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nápis „Kessoku Band" — žlutá samolepka s růžovým stínem, při dopadu se zachvěje jako Bocchi
|
||||||
|
.confetti-bocchi-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 38%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 12px 40px;
|
||||||
|
font-family: 'Dela Gothic One', 'M PLUS Rounded 1c', sans-serif;
|
||||||
|
font-size: clamp(1.8rem, 7vw, 4rem);
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #1d1a22;
|
||||||
|
background: #ffcf33;
|
||||||
|
border: 5px solid #1d1a22;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 10px 10px 0 #e0457b, 18px 18px 0 #1d1a22;
|
||||||
|
animation: bocchi-banner 2.4s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bocchi-banner {
|
||||||
|
0% { transform: translate(-50%, -50%) scale(2.2) rotate(-8deg); opacity: 0; }
|
||||||
|
14% { transform: translate(-50%, -50%) scale(1) rotate(-3deg); opacity: 1; }
|
||||||
|
17% { transform: translate(calc(-50% - 6px), -50%) rotate(-3deg); }
|
||||||
|
20% { transform: translate(calc(-50% + 6px), -50%) rotate(-3deg); }
|
||||||
|
23% { transform: translate(calc(-50% - 3px), -50%) rotate(-3deg); }
|
||||||
|
26%, 80% { transform: translate(-50%, -50%) rotate(-3deg); opacity: 1; }
|
||||||
|
100% { transform: translate(-50%, 60%) rotate(4deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postava s hláškou — hranatý odznak a bublina jako z mangy s tvrdým stínem
|
||||||
|
.confetti-bocchi-voice {
|
||||||
|
.confetti-mlp-avatar {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 3px solid #1d1a22;
|
||||||
|
background: #ffcf33;
|
||||||
|
box-shadow: 5px 5px 0 var(--mlp-speaker, #e0457b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-mlp-bubble {
|
||||||
|
background: #fff;
|
||||||
|
color: #1d1a22;
|
||||||
|
border: 3px solid #1d1a22;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 5px 5px 0 var(--mlp-speaker, #e0457b);
|
||||||
|
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
border-right-color: #1d1a22;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { StyleTheme } from "../context/settings";
|
||||||
|
import { UmaCharacter, loadUmaCharacters } from "../utils/umaCharacters";
|
||||||
|
import "./WheelConfetti.scss";
|
||||||
|
|
||||||
|
type WheelTheme = StyleTheme;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
theme: WheelTheme,
|
||||||
|
/** Mění se s každým výsledkem — nová hodnota spustí novou salvu. */
|
||||||
|
burstKey: number,
|
||||||
|
/** Velká salva (finální výsledek) vs. menší (mezivýsledek). */
|
||||||
|
big?: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
type UmaImage = { src: string, round: boolean };
|
||||||
|
|
||||||
|
const SIMPLE_COLORS = ['#16a34a', '#0ea5e9', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#ef4444'];
|
||||||
|
const UMA_EMOJI = ['🐴', '🏇', '🥕', '🏆', '🌸', '✨', '⭐', '🍙', '🎀', '🥇'];
|
||||||
|
// Hlášky z fandomu Umamusume (Umapyoi Densetsu, Teio step, hachimī, Golshi, Oguri „okawari"...)
|
||||||
|
const UMA_BADGES = ['1着!', 'Umazing!', 'Umapyoi!', 'Teio step!', 'Hachimī~', 'Golshi approves', 'Okawari!', 'Pakapaka~', 'ウマい!'];
|
||||||
|
const UMA_BADGE_COLORS = ['#ff6fa3', '#2e9a3a', '#43b8f0', '#f2a91a'];
|
||||||
|
const MLP_EMOJI = ['🦄', '🌈', '✨', '⭐', '🧁', '🍎', '🦋', '💎', '🎈', '🎉', '🍰', '💜'];
|
||||||
|
// Hlášky a narážky z My Little Pony: Friendship is Magic
|
||||||
|
const MLP_BADGES = ['Friendship is Magic!', '20% cooler!', 'Sonic Rainboom!', 'Yay!', 'Party!', 'Yeehaw!', 'Everypony!', 'Awesome!', 'Darling!', 'Pinkie Promise'];
|
||||||
|
const MLP_BADGE_COLORS = ['#8e4fd1', '#f06aa6', '#1e98d3', '#f37033', '#e3b829', '#5b4fc9'];
|
||||||
|
/** Postavy Mane 6 (+ Spike) s „hláškou" po finálním výsledku — místo obrázků cutie mark emoji. */
|
||||||
|
const MLP_SPEAKERS = [
|
||||||
|
{ name: 'Twilight Sparkle', emoji: '⭐', color: '#8e4fd1', quote: 'Podle mého seznamu je to ta nejlogičtější volba!' },
|
||||||
|
{ name: 'Rainbow Dash', emoji: '🌈', color: '#1e98d3', quote: 'Tohle je o 20 % úžasnější než ostatní!' },
|
||||||
|
{ name: 'Pinkie Pie', emoji: '🎈', color: '#f06aa6', quote: 'Tohle volá po PÁRTY! S dortem! A s cupcaky!' },
|
||||||
|
{ name: 'Applejack', emoji: '🍎', color: '#f37033', quote: 'Poctivé jídlo pro poctivou práci. Jíhá!' },
|
||||||
|
{ name: 'Fluttershy', emoji: '🦋', color: '#e3b829', quote: 'Jé… teda, jestli to nevadí… yay.' },
|
||||||
|
{ name: 'Rarity', emoji: '💎', color: '#5b4fc9', quote: 'Naprosto božská volba, drahoušku!' },
|
||||||
|
{ name: 'Spike', emoji: '🐉', color: '#62bc4d', quote: 'Zapsáno do svitku a odesláno princezně!' },
|
||||||
|
];
|
||||||
|
const HG_EMOJI = ['🥀', '🏮', '⛩️', '🔥', '🕯️', '🌑', '🦋', '👁️', '🕸️', '🌸'];
|
||||||
|
// Hlášky z Jigoku Shōjo (Pekelná korespondence, „Ippen, shinde miru?", poslání do pekla...)
|
||||||
|
const HG_BADGES = ['いっぺん、死んでみる?', '地獄通信', '地獄流し', 'Ippen… shinde miru?', 'Pomsta vykonána', '0:00', 'Uvaž to dobře…', 'Smlouva uzavřena'];
|
||||||
|
const HG_BADGE_COLORS = ['#12070a', '#7a0c1f', '#2e0d24'];
|
||||||
|
/** Ai Enma a její služebníci s „hláškou" po finálním výsledku — místo obrázků emoji. */
|
||||||
|
const HG_SPEAKERS = [
|
||||||
|
{ name: 'Ai Enma', emoji: '🥀', color: '#c8102e', quote: 'Smlouva je uzavřena. Tenhle oběd tě bude provázet až na věčnost.' },
|
||||||
|
{ name: 'Ichimoku Ren', emoji: '👁️', color: '#6b5a8a', quote: 'Prohlédl jsem svým okem všechny podniky. Tenhle jediný obstál.' },
|
||||||
|
{ name: 'Hone Onna', emoji: '💀', color: '#b8862b', quote: 'Dobré až na kost, zlato. Mně z toho stejně nic nezůstane.' },
|
||||||
|
{ name: 'Wanyūdō', emoji: '🔥', color: '#d9582b', quote: 'Já jsem kolo. Kolo rozhodlo. Víc k tomu nemám.' },
|
||||||
|
{ name: 'Kikuri', emoji: '🎀', color: '#d4407a', quote: 'Hihi! Tohle bych do pekla poslala jako první!' },
|
||||||
|
{ name: 'Babička', emoji: '🕸️', color: '#8c7672', quote: 'Ai, oběd je na stole… jako každý den už čtyři sta let.' },
|
||||||
|
];
|
||||||
|
const KON_EMOJI = ['🎸', '🥁', '🎹', '🎵', '🎶', '🍰', '☕', '🍓', '🐢', '🎀', '🍪', '🫖'];
|
||||||
|
// Hlášky z K-ON! (Houkago Tea Time, Fuwa Fuwa Time, Gitah, Ton-chan, Mugi a její čaj...)
|
||||||
|
const KON_BADGES = ['放課後ティータイム', 'Fuwa Fuwa Time ♪', 'Tea time!', 'Gitah!', 'Ton-chan ♡', 'けいおん!', 'Nejdřív čaj, pak zkouška', "Don't say lazy", 'Azunyan~', 'Ui!'];
|
||||||
|
const KON_BADGE_COLORS = ['#d43f62', '#3d6fc4', '#e8a10f', '#8a6bc7', '#e46f9a', '#27315f'];
|
||||||
|
/** Členky Houkago Tea Time (+ Ui, Sawa-chan a Ton-chan) s „hláškou" po finálním výsledku. */
|
||||||
|
const KON_SPEAKERS = [
|
||||||
|
{ name: 'Yui Hirasawa', emoji: '🎸', color: '#d43f62', quote: 'Gitah říká, že tohle bude mňamka! Fuwa fuwa~' },
|
||||||
|
{ name: 'Mio Akiyama', emoji: '🎼', color: '#3d6fc4', quote: 'Dobře… ale hned po obědě se jde zkoušet! Opravdu!' },
|
||||||
|
{ name: 'Ritsu Tainaka', emoji: '🥁', color: '#e8a10f', quote: 'Prezidentka klubu rozhodla! Raz, dva, raz dva tři čtyři!' },
|
||||||
|
{ name: 'Tsumugi Kotobuki', emoji: '🫖', color: '#c9a227', quote: 'Čaj a dortík k tomu přinesu já. Těším se!' },
|
||||||
|
{ name: 'Azusa Nakano', emoji: '🐱', color: '#8a6bc7', quote: 'Zase jídlo místo zkoušky? …No dobře, ale jen chvilku!' },
|
||||||
|
{ name: 'Ui Hirasawa', emoji: '🍳', color: '#e46f9a', quote: 'Sestřičko, nezapomeň si vzít ubrousek!' },
|
||||||
|
{ name: 'Sawako Yamanaka', emoji: '👗', color: '#27315f', quote: 'Oběd? Výborně. Mezitím vám ušiju nové kostýmy!' },
|
||||||
|
{ name: 'Ton-chan', emoji: '🐢', color: '#3c9a6b', quote: '(spokojeně mává ploutvičkou)' },
|
||||||
|
];
|
||||||
|
const BOCCHI_EMOJI = ['🎸', '🥁', '🎤', '📦', '🥭', '🟨', '🟦', '✨', '🌿', '🫠', '⭐', '🔊'];
|
||||||
|
// Hlášky z Bocchi the Rock! (Kessoku Band, Kita-aura, krabice od manga, guitarhero, live house STARRY...)
|
||||||
|
const BOCCHI_BADGES = ['結束バンド', 'Kessoku Band!', 'Kitaaan ✨', 'guitarhero', 'A-ano…', 'Live at STARRY', 'Bocchi-chan!', 'ぼっち・ざ・ろっく!', 'Mango box', 'Rock!'];
|
||||||
|
const BOCCHI_BADGE_COLORS = ['#e0457b', '#e8b400', '#3a63c9', '#d93a30', '#1d1a22'];
|
||||||
|
/** Kessoku Band a jejich okolí s „hláškou" po finálním výsledku. */
|
||||||
|
const BOCCHI_SPEAKERS = [
|
||||||
|
{ name: 'Hitori Gotoh', emoji: '🎸', color: '#e0457b', quote: 'A-ano… t-tohle by šlo… teda, pokud to nikomu nevadí…' },
|
||||||
|
{ name: 'Nijika Ijichi', emoji: '🥁', color: '#e8b400', quote: 'Tak jo, kapelo, jdeme jíst! Bocchi, vylez z té krabice!' },
|
||||||
|
{ name: 'Ryo Yamada', emoji: '🌿', color: '#3a63c9', quote: 'Dobrá volba. Mimochodem… nepůjčíš mi na oběd?' },
|
||||||
|
{ name: 'Ikuyo Kita', emoji: '✨', color: '#d93a30', quote: 'Kitaaan! Tohle se mi děsně líbí, vyfotím to na Instagram!' },
|
||||||
|
{ name: 'Seika Ijichi', emoji: '🎛️', color: '#7a4fc0', quote: 'Najezte se pořádně. Večer v STARRY neodfláknete zkoušku.' },
|
||||||
|
{ name: 'Futari Gotoh', emoji: '🐕', color: '#f08fb4', quote: 'Ségra zase obědvá ve skříni! Jimihen, pojď se podívat!' },
|
||||||
|
];
|
||||||
|
/** Styly, jejichž konfety tvoří emoji a samolepky s hláškami (a po finále promluví postava s emoji). */
|
||||||
|
const EMOJI_CONFETTI: Partial<Record<WheelTheme, {
|
||||||
|
prefix: string, badges: string[], colors: string[], emoji: string[],
|
||||||
|
speakers: { name: string, emoji: string, color: string, quote: string }[],
|
||||||
|
banner: string,
|
||||||
|
}>> = {
|
||||||
|
mlp: { prefix: 'confetti-mlp', badges: MLP_BADGES, colors: MLP_BADGE_COLORS, emoji: MLP_EMOJI, speakers: MLP_SPEAKERS, banner: '🌈 Sonic Rainboom! 🌈' },
|
||||||
|
hellgirl: { prefix: 'confetti-hg', badges: HG_BADGES, colors: HG_BADGE_COLORS, emoji: HG_EMOJI, speakers: HG_SPEAKERS, banner: '🏮 地獄流し 🏮' },
|
||||||
|
kon: { prefix: 'confetti-kon', badges: KON_BADGES, colors: KON_BADGE_COLORS, emoji: KON_EMOJI, speakers: KON_SPEAKERS, banner: '🎸 放課後ティータイム 🍰' },
|
||||||
|
bocchi: { prefix: 'confetti-bocchi', badges: BOCCHI_BADGES, colors: BOCCHI_BADGE_COLORS, emoji: BOCCHI_EMOJI, speakers: BOCCHI_SPEAKERS, banner: '🎸 結束バンド 🎸' },
|
||||||
|
};
|
||||||
|
const DURATION_MS = 6400;
|
||||||
|
|
||||||
|
const rand = (min: number, max: number) => min + Math.random() * (max - min);
|
||||||
|
const pick = <T,>(list: T[]) => list[Math.floor(Math.random() * list.length)];
|
||||||
|
|
||||||
|
type Piece = { id: number, style: React.CSSProperties, content: React.ReactNode, className: string };
|
||||||
|
|
||||||
|
type Launch = 'cannon-left' | 'cannon-right' | 'burst' | 'rain';
|
||||||
|
|
||||||
|
const LAUNCHES: Launch[] = ['cannon-left', 'cannon-left', 'cannon-right', 'cannon-right', 'burst', 'rain'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Výchozí pozice a dráha jednoho kousku: děla z dolních rohů střílí šikmo vzhůru,
|
||||||
|
* „výbuch" se rozletí ze středu a déšť padá opožděně shora. Kousek nejprve vyletí
|
||||||
|
* do vrcholu (--x1/--y1) a pak dopadne mimo obrazovku (--x2/--y2).
|
||||||
|
*/
|
||||||
|
function launchStyle(launch: Launch): React.CSSProperties {
|
||||||
|
let left: number, top: number, x1: number, y1: number, x2: number, y2: number, delay: number;
|
||||||
|
switch (launch) {
|
||||||
|
case 'cannon-left':
|
||||||
|
case 'cannon-right': {
|
||||||
|
const dir = launch === 'cannon-left' ? 1 : -1;
|
||||||
|
left = launch === 'cannon-left' ? rand(-3, 3) : rand(97, 103);
|
||||||
|
top = rand(88, 100);
|
||||||
|
x1 = dir * rand(12, 62);
|
||||||
|
y1 = -rand(45, 88);
|
||||||
|
x2 = x1 + dir * rand(6, 22);
|
||||||
|
y2 = rand(12, 25);
|
||||||
|
delay = rand(0, 450);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'burst': {
|
||||||
|
const angle = rand(0, Math.PI * 2);
|
||||||
|
const radius = rand(18, 48);
|
||||||
|
left = 50;
|
||||||
|
top = 42;
|
||||||
|
x1 = Math.cos(angle) * radius;
|
||||||
|
y1 = Math.sin(angle) * radius * 0.8 - rand(5, 15);
|
||||||
|
x2 = x1 * 1.35;
|
||||||
|
y2 = rand(70, 85);
|
||||||
|
delay = rand(0, 250);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
left = rand(0, 100);
|
||||||
|
top = -12;
|
||||||
|
x1 = rand(-8, 8);
|
||||||
|
y1 = rand(35, 55);
|
||||||
|
x2 = x1 + rand(-10, 10);
|
||||||
|
y2 = rand(115, 125);
|
||||||
|
delay = rand(400, 1800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const spin = rand(180, 720) * (Math.random() < 0.5 ? -1 : 1);
|
||||||
|
return {
|
||||||
|
left: `${left}vw`,
|
||||||
|
top: `${top}vh`,
|
||||||
|
animationName: launch === 'rain' ? 'confetti-rain' : 'confetti-throw',
|
||||||
|
animationDelay: `${delay}ms`,
|
||||||
|
animationDuration: `${rand(2600, 3900)}ms`,
|
||||||
|
['--x1' as any]: `${x1}vw`,
|
||||||
|
['--y1' as any]: `${y1}vh`,
|
||||||
|
['--x2' as any]: `${x2}vw`,
|
||||||
|
['--y2' as any]: `${y2}vh`,
|
||||||
|
['--r1' as any]: `${spin * 0.4}deg`,
|
||||||
|
['--r2' as any]: `${spin}deg`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPieces(theme: WheelTheme, big: boolean, images: UmaImage[]): Piece[] {
|
||||||
|
if (theme === 'simple') {
|
||||||
|
const count = big ? 170 : 80;
|
||||||
|
return Array.from({ length: count }, (_, id) => ({
|
||||||
|
id,
|
||||||
|
className: 'confetti-paper',
|
||||||
|
content: null,
|
||||||
|
style: {
|
||||||
|
...launchStyle(pick(LAUNCHES)),
|
||||||
|
width: `${rand(6, 11)}px`,
|
||||||
|
height: `${rand(10, 18)}px`,
|
||||||
|
background: pick(SIMPLE_COLORS),
|
||||||
|
borderRadius: Math.random() < 0.3 ? '50%' : '2px',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const count = big ? 50 : 24;
|
||||||
|
const emojiConfetti = EMOJI_CONFETTI[theme];
|
||||||
|
if (emojiConfetti) {
|
||||||
|
const { prefix, badges, colors, emoji } = emojiConfetti;
|
||||||
|
return Array.from({ length: count }, (_, id) => {
|
||||||
|
const base: React.CSSProperties = {
|
||||||
|
...launchStyle(pick(LAUNCHES)),
|
||||||
|
['--r1' as any]: `${rand(-25, 25)}deg`,
|
||||||
|
['--r2' as any]: `${rand(-40, 40)}deg`,
|
||||||
|
};
|
||||||
|
if (Math.random() < 0.35) {
|
||||||
|
return { id, className: `${prefix} ${prefix}-badge`, content: pick(badges),
|
||||||
|
style: { ...base, background: pick(colors) } };
|
||||||
|
}
|
||||||
|
return { id, className: `${prefix} confetti-uma-emoji`, style: { ...base, fontSize: `${rand(28, 52)}px` }, content: pick(emoji) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Array.from({ length: count }, (_, id) => {
|
||||||
|
const roll = Math.random();
|
||||||
|
// Samolepky se tolik netočí, ať jsou čitelné
|
||||||
|
const base: React.CSSProperties = {
|
||||||
|
...launchStyle(pick(LAUNCHES)),
|
||||||
|
['--r1' as any]: `${rand(-25, 25)}deg`,
|
||||||
|
['--r2' as any]: `${rand(-40, 40)}deg`,
|
||||||
|
};
|
||||||
|
if (images.length && roll < 0.45) {
|
||||||
|
const image = pick(images);
|
||||||
|
return { id, className: `confetti-uma confetti-uma-img${image.round ? ' round' : ''}`, style: base,
|
||||||
|
content: <img src={image.src} alt="" loading="eager" /> };
|
||||||
|
}
|
||||||
|
if (roll < 0.7) {
|
||||||
|
return { id, className: 'confetti-uma confetti-uma-badge', content: pick(UMA_BADGES),
|
||||||
|
style: { ...base, background: pick(UMA_BADGE_COLORS) } };
|
||||||
|
}
|
||||||
|
return { id, className: 'confetti-uma confetti-uma-emoji', style: { ...base, fontSize: `${rand(28, 52)}px` }, content: pick(UMA_EMOJI) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Salva konfet po výsledku kola štěstí, ve vzhledu podle zvoleného stylu. */
|
||||||
|
export default function WheelConfetti({ theme, burstKey, big = false }: Readonly<Props>) {
|
||||||
|
const [characters, setCharacters] = useState<UmaCharacter[]>([]);
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme === 'umamusume') loadUmaCharacters().then(setCharacters);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!burstKey) return;
|
||||||
|
setActive(true);
|
||||||
|
const t = setTimeout(() => setActive(false), DURATION_MS + 900);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [burstKey]);
|
||||||
|
|
||||||
|
const pieces = useMemo(
|
||||||
|
() => buildPieces(theme, big, characters.flatMap(c => c.images.map(src => ({ src, round: !!c.round })))),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[burstKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Při finálním výsledku „promluví" náhodná postava svou hláškou
|
||||||
|
const speaker = useMemo(() => {
|
||||||
|
const withQuote = characters.filter(c => c.quote);
|
||||||
|
return theme === 'umamusume' && big && withQuote.length ? pick(withQuote) : undefined;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [burstKey]);
|
||||||
|
|
||||||
|
// Ostatní tematické styly: hlášku pronese náhodná postava (Mane 6, Ai Enma, Houkago Tea Time, Kessoku Band...)
|
||||||
|
const emojiConfetti = EMOJI_CONFETTI[theme];
|
||||||
|
const emojiSpeaker = useMemo(
|
||||||
|
() => big && emojiConfetti ? pick(emojiConfetti.speakers) : undefined,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[burstKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!active || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className={`wheel-confetti wheel-confetti-${theme}`} key={burstKey} aria-hidden>
|
||||||
|
{theme === 'umamusume' && big && <div className="confetti-uma-banner">🏆 1着 🏆</div>}
|
||||||
|
{emojiConfetti && big && <div className={`${emojiConfetti.prefix}-banner`}>{emojiConfetti.banner}</div>}
|
||||||
|
{pieces.map(p => <span key={p.id} className={p.className} style={p.style}>{p.content}</span>)}
|
||||||
|
{speaker && (
|
||||||
|
<div className="confetti-uma-voice">
|
||||||
|
<img src={pick(speaker.images)} alt="" className={speaker.round ? 'round' : undefined} />
|
||||||
|
<div className="confetti-uma-bubble">
|
||||||
|
<p>„{speaker.quote}"</p>
|
||||||
|
<small>— {speaker.name}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{emojiSpeaker && (
|
||||||
|
<div className={`confetti-uma-voice confetti-${theme}-voice`} style={{ ['--mlp-speaker' as any]: emojiSpeaker.color }}>
|
||||||
|
<span className="confetti-mlp-avatar">{emojiSpeaker.emoji}</span>
|
||||||
|
<div className="confetti-mlp-bubble">
|
||||||
|
<p>„{emojiSpeaker.quote}"</p>
|
||||||
|
<small>— {emojiSpeaker.name}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,9 +49,10 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
|
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
|
||||||
const selectedFoods = choice.selectedFoods ?? [];
|
const selectedFoods = choice.selectedFoods ?? [];
|
||||||
let baseAmount = 0;
|
// Cena, kterou si strávník zadal sám, má přednost před cenami z menu
|
||||||
|
let baseAmount = choice.price ?? 0;
|
||||||
let baseAmountParseFailed = false;
|
let baseAmountParseFailed = false;
|
||||||
if (menu) {
|
if (menu && choice.price == null) {
|
||||||
for (const idx of selectedFoods) {
|
for (const idx of selectedFoods) {
|
||||||
const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
|
const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
|
||||||
if (priceKc === null) {
|
if (priceKc === null) {
|
||||||
@@ -168,7 +169,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
<>
|
<>
|
||||||
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p>
|
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p>
|
||||||
|
|
||||||
{!hasMenu && (
|
{!hasMenu && !diners.some(d => d.baseAmount > 0) && (
|
||||||
<Alert variant="info">
|
<Alert variant="info">
|
||||||
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
|
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -219,7 +220,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
<td>
|
<td>
|
||||||
<small>
|
<small>
|
||||||
{foodNames || <span className="text-muted">—</span>}
|
{foodNames || <span className="text-muted">—</span>}
|
||||||
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>}
|
{d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>}
|
||||||
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
|
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
|
||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Modal, Button, Form } from "react-bootstrap";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean,
|
||||||
|
onClose: () => void,
|
||||||
|
/** Uložená cena v haléřích */
|
||||||
|
price?: number,
|
||||||
|
/** Cena v haléřích spočtená z vybraných jídel v menu, nabídne se, pokud cena ještě není uložená */
|
||||||
|
suggestedPrice?: number,
|
||||||
|
/** Cena v haléřích, undefined cenu odstraní */
|
||||||
|
onSave: (price?: number) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCrownsText = (amount?: number) => amount ? String(amount / 100).replace('.', ',') : '';
|
||||||
|
|
||||||
|
/** Modální dialog pro zadání ceny oběda, která se promítne do přehledu stravování. */
|
||||||
|
const PriceModal = ({ isOpen, onClose, price, suggestedPrice, onSave }: Readonly<Props>) => {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setValue(toCrownsText(price ?? suggestedPrice));
|
||||||
|
}
|
||||||
|
}, [isOpen, price, suggestedPrice]);
|
||||||
|
|
||||||
|
const parsed = value.trim().length ? Number.parseFloat(value.replace(',', '.')) : undefined;
|
||||||
|
const invalid = parsed != null && (Number.isNaN(parsed) || parsed < 0);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (invalid) return;
|
||||||
|
onSave(parsed == null ? undefined : Math.round(parsed * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Modal show={isOpen} onHide={onClose}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>Cena oběda</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<p className="text-muted small">Cena se promítne do vašeho měsíčního přehledu stravování (export).</p>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<Form.Control
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="např. 145"
|
||||||
|
value={value}
|
||||||
|
isInvalid={invalid}
|
||||||
|
onChange={e => setValue(e.target.value.replace(/[^0-9.,]/g, ''))}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>Kč</span>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
{price != null && <Button variant="outline-danger" className="me-auto" onClick={() => onSave(undefined)}>
|
||||||
|
Odstranit cenu
|
||||||
|
</Button>}
|
||||||
|
<Button variant="secondary" onClick={() => onClose()}>
|
||||||
|
Storno
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => save()} disabled={invalid}>
|
||||||
|
Uložit
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PriceModal;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Modal, Button, Form } from "react-bootstrap"
|
import { Modal, Button, Form, Row, Col } from "react-bootstrap"
|
||||||
import { useSettings, ThemePreference } from "../../context/settings";
|
import { useSettings, ThemePreference, StyleTheme, STYLE_THEMES } from "../../context/settings";
|
||||||
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
|
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
|
||||||
import { useAuth } from "../../context/auth";
|
import { useAuth } from "../../context/auth";
|
||||||
import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder";
|
import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder";
|
||||||
@@ -19,6 +19,9 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
|||||||
const nameRef = useRef<HTMLInputElement>(null);
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
const hideSoupsRef = useRef<HTMLInputElement>(null);
|
const hideSoupsRef = useRef<HTMLInputElement>(null);
|
||||||
const themeRef = useRef<HTMLSelectElement>(null);
|
const themeRef = useRef<HTMLSelectElement>(null);
|
||||||
|
const styleThemeRef = useRef<HTMLSelectElement>(null);
|
||||||
|
const showButterfliesRef = useRef<HTMLInputElement>(null);
|
||||||
|
const autoOrderChoiceRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const reminderTimeRef = useRef<HTMLInputElement>(null);
|
const reminderTimeRef = useRef<HTMLInputElement>(null);
|
||||||
const boltDeliveredRef = useRef<HTMLInputElement>(null);
|
const boltDeliveredRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -75,6 +78,15 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Uložení ostatních nastavení (localStorage)
|
// Uložení ostatních nastavení (localStorage)
|
||||||
|
if (styleThemeRef.current?.value) {
|
||||||
|
settings?.setStyleTheme(styleThemeRef.current.value as StyleTheme);
|
||||||
|
}
|
||||||
|
if (showButterfliesRef.current) {
|
||||||
|
settings?.setShowButterflies(showButterfliesRef.current.checked);
|
||||||
|
}
|
||||||
|
if (autoOrderChoiceRef.current) {
|
||||||
|
settings?.setAutoOrderChoice(autoOrderChoiceRef.current.checked);
|
||||||
|
}
|
||||||
onSave(
|
onSave(
|
||||||
bankAccountRef.current?.value,
|
bankAccountRef.current?.value,
|
||||||
nameRef.current?.value,
|
nameRef.current?.value,
|
||||||
@@ -84,169 +96,205 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal show={isOpen} onHide={onClose} size="lg">
|
<Modal show={isOpen} onHide={onClose} size="xl">
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title><h2>Nastavení</h2></Modal.Title>
|
<Modal.Title><h2>Nastavení</h2></Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<h4>Vzhled</h4>
|
{/* Na širších obrazovkách rozdělení do dvou sloupců, na užších pod sebou */}
|
||||||
<Form.Group className="mb-3">
|
<Row className="gx-5">
|
||||||
<Form.Label>Barevný motiv</Form.Label>
|
<Col lg={6}>
|
||||||
<Form.Select ref={themeRef} defaultValue={settings?.themePreference}>
|
<h4>Vzhled</h4>
|
||||||
<option value="system">Podle systému</option>
|
<Form.Group className="mb-3">
|
||||||
<option value="light">Světlý</option>
|
<Form.Label>Styl</Form.Label>
|
||||||
<option value="dark">Tmavý</option>
|
<Form.Select ref={styleThemeRef} defaultValue={settings?.styleTheme}>
|
||||||
</Form.Select>
|
{STYLE_THEMES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||||
</Form.Group>
|
</Form.Select>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Barevný motiv</Form.Label>
|
||||||
|
<Form.Select ref={themeRef} defaultValue={settings?.themePreference}>
|
||||||
|
<option value="system">Podle systému</option>
|
||||||
|
<option value="light">Světlý</option>
|
||||||
|
<option value="dark">Tmavý</option>
|
||||||
|
</Form.Select>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Check
|
||||||
|
id="showButterfliesCheckbox"
|
||||||
|
ref={showButterfliesRef}
|
||||||
|
type="checkbox"
|
||||||
|
label="Zobrazit motýlky"
|
||||||
|
defaultChecked={settings?.showButterflies ?? true}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Po vypnutí se poletující motýlci ani minihra nebudou vůbec zobrazovat.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<h4>Obecné</h4>
|
<h4>Obecné</h4>
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Check
|
<Form.Check
|
||||||
id="hideSoupsCheckbox"
|
id="hideSoupsCheckbox"
|
||||||
ref={hideSoupsRef}
|
ref={hideSoupsRef}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
label="Skrýt polévky"
|
label="Skrýt polévky"
|
||||||
defaultChecked={settings?.hideSoups}
|
defaultChecked={settings?.hideSoups}
|
||||||
title="V nabídkách nebudou zobrazovány polévky. Tato funkce je experimentální."
|
title="V nabídkách nebudou zobrazovány polévky. Tato funkce je experimentální."
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
<Form.Text className="text-muted">
|
||||||
Experimentální funkce - zejména u TechTower bývá problém polévky spolehlivě rozeznat.
|
Experimentální funkce - zejména u TechTower bývá problém polévky spolehlivě rozeznat.
|
||||||
</Form.Text>
|
</Form.Text>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Check
|
||||||
|
id="autoOrderChoiceCheckbox"
|
||||||
|
ref={autoOrderChoiceRef}
|
||||||
|
type="checkbox"
|
||||||
|
label='Při objednávání automaticky zvolit "Budu objednávat"'
|
||||||
|
defaultChecked={settings?.autoOrderChoice ?? false}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Po založení skupinové objednávky nebo připojení k existující se vaše dnešní volba stravování přepne na "Budu objednávat". Volba Pizza day zůstane beze změny.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<h4>Notifikace</h4>
|
<h4>Bankovní účet</h4>
|
||||||
<p>
|
<p>
|
||||||
Nastavením notifikací budete dostávat upozornění o událostech (např. "Jdeme na oběd") přímo do vámi zvoleného komunikačního kanálu.
|
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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>Připomínka výběru oběda</Form.Label>
|
<Form.Label>Číslo účtu</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
ref={reminderTimeRef}
|
ref={bankAccountRef}
|
||||||
type="time"
|
type="text"
|
||||||
defaultValue={notifSettings.reminderTime ?? ''}
|
placeholder="123456-1234567890/1234"
|
||||||
key={notifSettings.reminderTime ?? 'reminder-empty'}
|
defaultValue={settings?.bankAccount}
|
||||||
/>
|
onKeyDown={e => e.stopPropagation()}
|
||||||
<Form.Text className="text-muted">
|
/>
|
||||||
V zadaný čas vám přijde push notifikace, pokud nemáte zvolenou možnost stravování. Nechte prázdné pro vypnutí.
|
<Form.Text className="text-muted">
|
||||||
</Form.Text>
|
Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.
|
||||||
</Form.Group>
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Check
|
<Form.Label>Název příjemce</Form.Label>
|
||||||
id="boltDeliveredCheckbox"
|
<Form.Control
|
||||||
ref={boltDeliveredRef}
|
ref={nameRef}
|
||||||
type="checkbox"
|
type="text"
|
||||||
label="Upozornění na doručení objednávky (Bolt Food / Wolt)"
|
placeholder="Jan Novák"
|
||||||
defaultChecked={notifSettings.boltDeliveredPush ?? false}
|
defaultValue={settings?.holderName}
|
||||||
key={`bolt-delivered-${notifSettings.boltDeliveredPush ?? false}`}
|
onKeyDown={e => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
<Form.Text className="text-muted">
|
||||||
Až bude sledovaná skupinová objednávka doručena, přijde vám push notifikace.
|
Jméno majitele účtu pro QR platbu.
|
||||||
Zakladateli skupiny se neposílá — ten dostane upozornění přímo z aplikace rozvozové služby.
|
</Form.Text>
|
||||||
</Form.Text>
|
</Form.Group>
|
||||||
</Form.Group>
|
</Col>
|
||||||
|
<Col lg={6}>
|
||||||
|
<hr className="d-lg-none" />
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
<h4>Notifikace</h4>
|
||||||
<Form.Label>ntfy téma (topic)</Form.Label>
|
<p>
|
||||||
<Form.Control
|
Nastavením notifikací budete dostávat upozornění o událostech (např. "Jdeme na oběd") přímo do vámi zvoleného komunikačního kanálu.
|
||||||
ref={ntfyTopicRef}
|
</p>
|
||||||
type="text"
|
|
||||||
placeholder="moje-tema"
|
|
||||||
defaultValue={notifSettings.ntfyTopic}
|
|
||||||
key={notifSettings.ntfyTopic ?? 'ntfy-empty'}
|
|
||||||
onKeyDown={e => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
<Form.Text className="text-muted">
|
|
||||||
Téma pro ntfy push notifikace. Nechte prázdné pro vypnutí.
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>Discord webhook URL</Form.Label>
|
<Form.Label>Připomínka výběru oběda</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
ref={discordWebhookRef}
|
ref={reminderTimeRef}
|
||||||
type="text"
|
type="time"
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
defaultValue={notifSettings.reminderTime ?? ''}
|
||||||
defaultValue={notifSettings.discordWebhookUrl}
|
key={notifSettings.reminderTime ?? 'reminder-empty'}
|
||||||
key={notifSettings.discordWebhookUrl ?? 'discord-empty'}
|
/>
|
||||||
onKeyDown={e => e.stopPropagation()}
|
<Form.Text className="text-muted">
|
||||||
/>
|
V zadaný čas vám přijde push notifikace, pokud nemáte zvolenou možnost stravování. Nechte prázdné pro vypnutí.
|
||||||
<Form.Text className="text-muted">
|
</Form.Text>
|
||||||
URL webhooku Discord kanálu. Nechte prázdné pro vypnutí.
|
</Form.Group>
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>MS Teams webhook URL</Form.Label>
|
<Form.Check
|
||||||
<Form.Control
|
id="boltDeliveredCheckbox"
|
||||||
ref={teamsWebhookRef}
|
ref={boltDeliveredRef}
|
||||||
type="text"
|
type="checkbox"
|
||||||
placeholder="https://outlook.office.com/webhook/..."
|
label="Upozornění na doručení objednávky (Bolt Food / Wolt)"
|
||||||
defaultValue={notifSettings.teamsWebhookUrl}
|
defaultChecked={notifSettings.boltDeliveredPush ?? false}
|
||||||
key={notifSettings.teamsWebhookUrl ?? 'teams-empty'}
|
key={`bolt-delivered-${notifSettings.boltDeliveredPush ?? false}`}
|
||||||
onKeyDown={e => e.stopPropagation()}
|
/>
|
||||||
/>
|
<Form.Text className="text-muted">
|
||||||
<Form.Text className="text-muted">
|
Až bude sledovaná skupinová objednávka doručena, přijde vám push notifikace.
|
||||||
URL webhooku MS Teams kanálu. Nechte prázdné pro vypnutí.
|
Zakladateli skupiny se neposílá — ten dostane upozornění přímo z aplikace rozvozové služby.
|
||||||
</Form.Text>
|
</Form.Text>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>Události k odběru</Form.Label>
|
<Form.Label>ntfy téma (topic)</Form.Label>
|
||||||
{Object.values(UdalostEnum).map(event => (
|
<Form.Control
|
||||||
<Form.Check
|
ref={ntfyTopicRef}
|
||||||
key={event}
|
type="text"
|
||||||
id={`notif-event-${event}`}
|
placeholder="moje-tema"
|
||||||
type="checkbox"
|
defaultValue={notifSettings.ntfyTopic}
|
||||||
label={event}
|
key={notifSettings.ntfyTopic ?? 'ntfy-empty'}
|
||||||
checked={enabledEvents.includes(event)}
|
onKeyDown={e => e.stopPropagation()}
|
||||||
onChange={() => toggleEvent(event)}
|
/>
|
||||||
/>
|
<Form.Text className="text-muted">
|
||||||
))}
|
Téma pro ntfy push notifikace. Nechte prázdné pro vypnutí.
|
||||||
<Form.Text className="text-muted">
|
</Form.Text>
|
||||||
Zvolte události, o kterých chcete být notifikováni. Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou.
|
</Form.Group>
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<hr />
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Discord webhook URL</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
ref={discordWebhookRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
defaultValue={notifSettings.discordWebhookUrl}
|
||||||
|
key={notifSettings.discordWebhookUrl ?? 'discord-empty'}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
URL webhooku Discord kanálu. Nechte prázdné pro vypnutí.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
<h4>Bankovní účet</h4>
|
<Form.Group className="mb-3">
|
||||||
<p>
|
<Form.Label>MS Teams webhook URL</Form.Label>
|
||||||
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.
|
<Form.Control
|
||||||
</p>
|
ref={teamsWebhookRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="https://outlook.office.com/webhook/..."
|
||||||
|
defaultValue={notifSettings.teamsWebhookUrl}
|
||||||
|
key={notifSettings.teamsWebhookUrl ?? 'teams-empty'}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
URL webhooku MS Teams kanálu. Nechte prázdné pro vypnutí.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>Číslo účtu</Form.Label>
|
<Form.Label>Události k odběru</Form.Label>
|
||||||
<Form.Control
|
{Object.values(UdalostEnum).map(event => (
|
||||||
ref={bankAccountRef}
|
<Form.Check
|
||||||
type="text"
|
key={event}
|
||||||
placeholder="123456-1234567890/1234"
|
id={`notif-event-${event}`}
|
||||||
defaultValue={settings?.bankAccount}
|
type="checkbox"
|
||||||
onKeyDown={e => e.stopPropagation()}
|
label={event}
|
||||||
/>
|
checked={enabledEvents.includes(event)}
|
||||||
<Form.Text className="text-muted">
|
onChange={() => toggleEvent(event)}
|
||||||
Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.
|
/>
|
||||||
</Form.Text>
|
))}
|
||||||
</Form.Group>
|
<Form.Text className="text-muted">
|
||||||
|
Zvolte události, o kterých chcete být notifikováni. Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou.
|
||||||
<Form.Group className="mb-3">
|
</Form.Text>
|
||||||
<Form.Label>Název příjemce</Form.Label>
|
</Form.Group>
|
||||||
<Form.Control
|
</Col>
|
||||||
ref={nameRef}
|
</Row>
|
||||||
type="text"
|
|
||||||
placeholder="Jan Novák"
|
|
||||||
defaultValue={settings?.holderName}
|
|
||||||
onKeyDown={e => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
<Form.Text className="text-muted">
|
|
||||||
Jméno majitele účtu pro QR platbu.
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button variant="secondary" onClick={onClose}>
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,407 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Modal, Button, ButtonGroup } from "react-bootstrap";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faXmark, faRotateLeft } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { Restaurant, RestaurantDayMenuMap, Store, StoreMenu, StoreMenuSource, getStoreMenu, listStores } from "../../../../types";
|
||||||
|
import { getLunchChoiceName } from "../../enums";
|
||||||
|
import WheelConfetti from "../WheelConfetti";
|
||||||
|
import { useSettings } from "../../context/settings";
|
||||||
|
import "./SpinWheelModal.scss";
|
||||||
|
|
||||||
|
/** Výsledek kola štěstí — buď jídlo z dnešního menu podniku, nebo jídlo z rozvozu. */
|
||||||
|
export type WheelPick =
|
||||||
|
| { kind: 'menu', restaurant: Restaurant, foodIndex?: number, foodName?: string }
|
||||||
|
| { kind: 'store', store: string, dish?: string };
|
||||||
|
|
||||||
|
type Source = 'all' | 'menu' | 'store';
|
||||||
|
|
||||||
|
type Place = {
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
kind: 'menu' | 'store',
|
||||||
|
restaurant?: Restaurant,
|
||||||
|
/** Jídla dnešního menu; u rozvozu se nabídka dotahuje až při druhém kole. */
|
||||||
|
dishes: Dish[],
|
||||||
|
};
|
||||||
|
|
||||||
|
type Dish = { name: string, index?: number, price?: string };
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<StoreMenuSource, string> = {
|
||||||
|
bolt: 'Nabídka z Bolt Food',
|
||||||
|
wolt: 'Nabídka z Wolt',
|
||||||
|
history: 'Nabídku se nepodařilo stáhnout — jídla z historie objednávek',
|
||||||
|
none: 'Nabídku se nepodařilo stáhnout',
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean,
|
||||||
|
onClose: () => void,
|
||||||
|
menus?: RestaurantDayMenuMap,
|
||||||
|
onPick: (pick: WheelPick) => Promise<void>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPIN_MS = 4800;
|
||||||
|
const WHEEL_SIZE = 360;
|
||||||
|
const RADIUS = WHEEL_SIZE / 2 - 6;
|
||||||
|
const SEGMENT_COLORS = 6;
|
||||||
|
const MAX_LABELED_SEGMENTS = 40;
|
||||||
|
/** Točit má smysl až od dvou možností — jediná zbylá se vybere rovnou. */
|
||||||
|
const MIN_SPIN_ITEMS = 2;
|
||||||
|
|
||||||
|
/** Kryptograficky náhodný index v rozsahu 0..n-1 (bez modulo zkreslení to u malých n nevadí). */
|
||||||
|
function randomIndex(n: number): number {
|
||||||
|
const buf = new Uint32Array(1);
|
||||||
|
crypto.getRandomValues(buf);
|
||||||
|
return buf[0] % n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, max: number): string {
|
||||||
|
return text.length > max ? text.slice(0, max - 1) + '…' : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bod na kružnici; úhel ve stupních měřený od „12 hodin" po směru hodinových ručiček. */
|
||||||
|
function polar(angle: number, r: number) {
|
||||||
|
const rad = (angle - 90) * Math.PI / 180;
|
||||||
|
return { x: WHEEL_SIZE / 2 + r * Math.cos(rad), y: WHEEL_SIZE / 2 + r * Math.sin(rad) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentPath(start: number, end: number): string {
|
||||||
|
const c = WHEEL_SIZE / 2;
|
||||||
|
if (end - start >= 360) {
|
||||||
|
return `M ${c} ${c - RADIUS} A ${RADIUS} ${RADIUS} 0 1 1 ${c - 0.01} ${c - RADIUS} Z`;
|
||||||
|
}
|
||||||
|
const a = polar(start, RADIUS);
|
||||||
|
const b = polar(end, RADIUS);
|
||||||
|
const large = end - start > 180 ? 1 : 0;
|
||||||
|
return `M ${c} ${c} L ${a.x} ${a.y} A ${RADIUS} ${RADIUS} 0 ${large} 1 ${b.x} ${b.y} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WheelProps = {
|
||||||
|
labels: string[],
|
||||||
|
rotation: number,
|
||||||
|
spinning: boolean,
|
||||||
|
onSpinEnd: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Wheel({ labels, rotation, spinning, onSpinEnd }: Readonly<WheelProps>) {
|
||||||
|
const n = labels.length;
|
||||||
|
const angle = 360 / Math.max(n, 1);
|
||||||
|
const fontSize = n > 24 ? 8.5 : n > 14 ? 10 : n > 8 ? 12 : 14;
|
||||||
|
const maxChars = n > 24 ? 26 : n > 14 ? 24 : 20;
|
||||||
|
// U velkých nabídek jsou popisky nečitelné — názvy jsou v seznamu vedle kola
|
||||||
|
const showLabels = n <= MAX_LABELED_SEGMENTS;
|
||||||
|
return (
|
||||||
|
<div className="spin-wheel">
|
||||||
|
<div className="spin-wheel-pointer" aria-hidden />
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${WHEEL_SIZE} ${WHEEL_SIZE}`}
|
||||||
|
className="spin-wheel-svg"
|
||||||
|
style={{
|
||||||
|
transform: `rotate(${rotation}deg)`,
|
||||||
|
transition: spinning ? `transform ${SPIN_MS}ms cubic-bezier(0.12, 0.72, 0.16, 1)` : 'none',
|
||||||
|
}}
|
||||||
|
onTransitionEnd={e => { if (e.propertyName === 'transform') onSpinEnd(); }}
|
||||||
|
role="img"
|
||||||
|
aria-label={`Kolo s ${n} možnostmi`}
|
||||||
|
>
|
||||||
|
<circle cx={WHEEL_SIZE / 2} cy={WHEEL_SIZE / 2} r={RADIUS + 4} className="spin-wheel-rim" />
|
||||||
|
{labels.map((label, i) => {
|
||||||
|
const start = i * angle;
|
||||||
|
const mid = start + angle / 2;
|
||||||
|
return (
|
||||||
|
<g key={`${i}-${label}`}>
|
||||||
|
<path d={segmentPath(start, start + angle)} className={`spin-seg spin-seg-${i % SEGMENT_COLORS}`} />
|
||||||
|
{showLabels && <text
|
||||||
|
x={WHEEL_SIZE / 2 + 34}
|
||||||
|
y={WHEEL_SIZE / 2}
|
||||||
|
transform={`rotate(${mid - 90} ${WHEEL_SIZE / 2} ${WHEEL_SIZE / 2})`}
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={fontSize}
|
||||||
|
className="spin-seg-label"
|
||||||
|
>
|
||||||
|
{truncate(label, maxChars)}
|
||||||
|
</text>}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<circle cx={WHEEL_SIZE / 2} cy={WHEEL_SIZE / 2} r={26} className="spin-wheel-hub" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kolo štěstí pro nerozhodnuté: první kolo vybere podnik (dnešní menu nebo rozvoz),
|
||||||
|
* druhé kolo jídlo z něj (bez polévek). Možnosti lze z kola průběžně vyřazovat.
|
||||||
|
*/
|
||||||
|
export default function SpinWheelModal({ isOpen, onClose, menus, onPick }: Readonly<Props>) {
|
||||||
|
const [stores, setStores] = useState<Store[]>();
|
||||||
|
const [storeMenus, setStoreMenus] = useState<Record<string, StoreMenu | 'loading' | 'error'>>({});
|
||||||
|
const [source, setSource] = useState<Source>('all');
|
||||||
|
const [excludedPlaces, setExcludedPlaces] = useState<Set<string>>(new Set());
|
||||||
|
const [excludedDishes, setExcludedDishes] = useState<Set<string>>(new Set());
|
||||||
|
const [place, setPlace] = useState<Place>();
|
||||||
|
const [dish, setDish] = useState<Dish>();
|
||||||
|
const [phase, setPhase] = useState<'place' | 'dish'>('place');
|
||||||
|
const [rotation, setRotation] = useState(0);
|
||||||
|
const [spinning, setSpinning] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [respinRequested, setRespinRequested] = useState(false);
|
||||||
|
const theme = useSettings()?.styleTheme ?? 'simple';
|
||||||
|
const [confetti, setConfetti] = useState<{ key: number, big: boolean }>({ key: 0, big: false });
|
||||||
|
const pendingResult = useRef<number>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && !stores) {
|
||||||
|
listStores().then(r => setStores(r.data ?? [])).catch(() => setStores([]));
|
||||||
|
}
|
||||||
|
}, [isOpen, stores]);
|
||||||
|
|
||||||
|
const allPlaces = useMemo<Place[]>(() => {
|
||||||
|
const result: Place[] = [];
|
||||||
|
for (const key of Object.keys(Restaurant) as Restaurant[]) {
|
||||||
|
const menu = menus?.[key];
|
||||||
|
if (!menu || menu.closed || !menu.food?.length) continue;
|
||||||
|
const dishes = menu.food
|
||||||
|
.map((f, index) => ({ name: f.name, index, price: f.price, isSoup: f.isSoup }))
|
||||||
|
.filter(f => !f.isSoup)
|
||||||
|
.map(({ name, index, price }) => ({ name, index, price }));
|
||||||
|
result.push({ id: `menu:${key}`, label: getLunchChoiceName(key), kind: 'menu', restaurant: key, dishes });
|
||||||
|
}
|
||||||
|
for (const s of stores ?? []) {
|
||||||
|
result.push({ id: `store:${s.name}`, label: s.name, kind: 'store', dishes: [] });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [menus, stores]);
|
||||||
|
|
||||||
|
const storeMenu = place?.kind === 'store' ? storeMenus[place.label] : undefined;
|
||||||
|
const allDishes = useMemo<Dish[]>(() => {
|
||||||
|
if (place?.kind !== 'store') return place?.dishes ?? [];
|
||||||
|
if (!storeMenu || typeof storeMenu === 'string') return [];
|
||||||
|
return storeMenu.dishes
|
||||||
|
.filter(d => d.isMain !== false)
|
||||||
|
.map(d => ({ name: d.name, price: d.price != null ? `${d.price} Kč` : undefined }));
|
||||||
|
}, [place, storeMenu]);
|
||||||
|
|
||||||
|
const places = useMemo(
|
||||||
|
() => allPlaces.filter(p => (source === 'all' || p.kind === source) && !excludedPlaces.has(p.id)),
|
||||||
|
[allPlaces, source, excludedPlaces],
|
||||||
|
);
|
||||||
|
const dishes = useMemo(
|
||||||
|
() => allDishes.filter(d => !excludedDishes.has(`${place?.id}|${d.name}`)),
|
||||||
|
[place, allDishes, excludedDishes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const wheelItems = phase === 'place' ? places.map(p => p.label) : dishes.map(d => d.name);
|
||||||
|
const result = phase === 'place' ? place : dish;
|
||||||
|
|
||||||
|
const resetWheel = () => {
|
||||||
|
setSpinning(false);
|
||||||
|
setRotation(r => r % 360);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zbyla jediná možnost — není co losovat, vybere se rovnou (lze pokračovat dál / převzít)
|
||||||
|
const onlyOne = wheelItems.length === 1;
|
||||||
|
useEffect(() => {
|
||||||
|
if (spinning || !onlyOne) return;
|
||||||
|
if (phase === 'place' && place?.id !== places[0].id) setPlace(places[0]);
|
||||||
|
else if (phase === 'dish' && dish?.name !== dishes[0].name) setDish(dishes[0]);
|
||||||
|
}, [onlyOne, phase, places, dishes, spinning, place?.id, dish?.name]);
|
||||||
|
|
||||||
|
const spin = () => {
|
||||||
|
const n = wheelItems.length;
|
||||||
|
if (n < MIN_SPIN_ITEMS || spinning) return;
|
||||||
|
const index = randomIndex(n);
|
||||||
|
const angle = 360 / n;
|
||||||
|
const jitter = (Math.random() - 0.5) * angle * 0.6;
|
||||||
|
const target = 360 - (index * angle + angle / 2) + jitter;
|
||||||
|
const turns = 5 + randomIndex(3);
|
||||||
|
pendingResult.current = index;
|
||||||
|
if (phase === 'place') setPlace(undefined); else setDish(undefined);
|
||||||
|
setSpinning(true);
|
||||||
|
setRotation(r => r - (r % 360) + turns * 360 + ((target % 360) + 360) % 360);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSpinEnd = () => {
|
||||||
|
const index = pendingResult.current;
|
||||||
|
if (index == null) return;
|
||||||
|
pendingResult.current = undefined;
|
||||||
|
setSpinning(false);
|
||||||
|
if (phase === 'place') setPlace(places[index]);
|
||||||
|
else setDish(dishes[index]);
|
||||||
|
setConfetti(c => ({ key: c.key + 1, big: phase === 'dish' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToDishes = () => {
|
||||||
|
if (place?.kind === 'store' && (!storeMenus[place.label] || storeMenus[place.label] === 'error')) {
|
||||||
|
const name = place.label;
|
||||||
|
setStoreMenus(prev => ({ ...prev, [name]: 'loading' }));
|
||||||
|
getStoreMenu({ query: { name } })
|
||||||
|
.then(r => setStoreMenus(prev => ({ ...prev, [name]: r.data ?? 'error' })))
|
||||||
|
.catch(() => setStoreMenus(prev => ({ ...prev, [name]: 'error' })));
|
||||||
|
}
|
||||||
|
setDish(undefined);
|
||||||
|
setPhase('dish');
|
||||||
|
resetWheel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const backToPlaces = () => {
|
||||||
|
setDish(undefined);
|
||||||
|
setPlace(undefined);
|
||||||
|
setPhase('place');
|
||||||
|
resetWheel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeOption = (i: number) => {
|
||||||
|
if (spinning) return;
|
||||||
|
if (phase === 'place') {
|
||||||
|
const p = places[i];
|
||||||
|
setExcludedPlaces(prev => new Set(prev).add(p.id));
|
||||||
|
if (place?.id === p.id) setPlace(undefined);
|
||||||
|
} else if (place) {
|
||||||
|
const d = dishes[i];
|
||||||
|
setExcludedDishes(prev => new Set(prev).add(`${place.id}|${d.name}`));
|
||||||
|
if (dish?.name === d.name) setDish(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeResultAndRespin = () => {
|
||||||
|
const i = phase === 'place' ? places.findIndex(p => p.id === place?.id) : dishes.findIndex(d => d.name === dish?.name);
|
||||||
|
if (i < 0) return;
|
||||||
|
removeOption(i);
|
||||||
|
setRespinRequested(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Po vyřazení se kolo překreslí s menším počtem výsečí — roztočíme ho až nad novými daty
|
||||||
|
useEffect(() => {
|
||||||
|
if (respinRequested) {
|
||||||
|
setRespinRequested(false);
|
||||||
|
spin();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [respinRequested]);
|
||||||
|
|
||||||
|
const restoreAll = () => {
|
||||||
|
if (phase === 'place') setExcludedPlaces(new Set());
|
||||||
|
else if (place) setExcludedDishes(prev => new Set([...prev].filter(k => !k.startsWith(`${place.id}|`))));
|
||||||
|
};
|
||||||
|
|
||||||
|
const accept = async () => {
|
||||||
|
if (!place) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (place.kind === 'menu') {
|
||||||
|
await onPick({ kind: 'menu', restaurant: place.restaurant!, foodIndex: dish?.index, foodName: dish?.name });
|
||||||
|
} else {
|
||||||
|
await onPick({ kind: 'store', store: place.label, dish: dish?.name });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const excludedCount = phase === 'place'
|
||||||
|
? allPlaces.filter(p => (source === 'all' || p.kind === source) && excludedPlaces.has(p.id)).length
|
||||||
|
: allDishes.length - dishes.length;
|
||||||
|
const menuLoading = phase === 'dish' && storeMenu === 'loading';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose} size="lg" className={`spin-wheel-modal spin-theme-${theme}`}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>
|
||||||
|
<h2>{phase === 'place' ? 'Kolo štěstí: kam?' : `Kolo štěstí: co v ${place?.label}?`}</h2>
|
||||||
|
</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<WheelConfetti theme={theme} burstKey={confetti.key} big={confetti.big} />
|
||||||
|
<Modal.Body>
|
||||||
|
<div className="spin-steps">
|
||||||
|
<span className={phase === 'place' ? 'active' : 'done'}>1. Podnik</span>
|
||||||
|
<span className={phase === 'dish' ? 'active' : ''}>2. Jídlo</span>
|
||||||
|
{phase === 'dish' && storeMenu && typeof storeMenu !== 'string' && (
|
||||||
|
<small className="spin-source-info">{SOURCE_LABELS[storeMenu.source]}</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{phase === 'place' && (
|
||||||
|
<ButtonGroup className="spin-source mb-3" aria-label="Zdroj podniků">
|
||||||
|
{([['all', 'Vše'], ['menu', 'Dnešní menu'], ['store', 'Rozvoz']] as [Source, string][]).map(([value, label]) => (
|
||||||
|
<Button key={value} variant={source === value ? 'primary' : 'outline-primary'} disabled={spinning}
|
||||||
|
onClick={() => { setSource(value); setPlace(undefined); }}>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</ButtonGroup>
|
||||||
|
)}
|
||||||
|
<div className="spin-layout">
|
||||||
|
<div className="spin-wheel-column">
|
||||||
|
{(phase === 'place' && !stores) || menuLoading ? (
|
||||||
|
<div className="spin-empty">{menuLoading ? 'Stahuji nabídku podniku…' : 'Načítám podniky…'}</div>
|
||||||
|
) : wheelItems.length === 0 ? (
|
||||||
|
<div className="spin-empty">
|
||||||
|
{phase === 'dish'
|
||||||
|
? 'Tady nemáme žádná jídla k točení — vyber si na místě.'
|
||||||
|
: 'Na kole nezbyla žádná možnost.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Wheel labels={wheelItems} rotation={rotation} spinning={spinning} onSpinEnd={handleSpinEnd} />
|
||||||
|
)}
|
||||||
|
<div className={`spin-result${result ? ' visible' : ''}`} aria-live="polite">
|
||||||
|
{result && (phase === 'place'
|
||||||
|
? <>{onlyOne ? 'Zbývá jediná možnost' : 'Padlo'}: <strong>{place!.label}</strong> <small>({place!.kind === 'menu' ? 'dnešní menu' : 'rozvoz'})</small></>
|
||||||
|
: <>{onlyOne ? 'Zbývá jediné jídlo' : 'Padlo'}: <strong>{dish!.name}</strong>{dish!.price && <small> ({dish!.price})</small>}</>)}
|
||||||
|
</div>
|
||||||
|
<div className="spin-actions">
|
||||||
|
<Button onClick={spin} disabled={spinning || wheelItems.length < MIN_SPIN_ITEMS} className="spin-button"
|
||||||
|
title={wheelItems.length < MIN_SPIN_ITEMS ? 'Na kole musí být aspoň 2 možnosti' : undefined}>
|
||||||
|
{spinning ? 'Točí se…' : result && !onlyOne ? 'Točit znovu' : 'Roztočit!'}
|
||||||
|
</Button>
|
||||||
|
{result && !spinning && wheelItems.length > 1 && (
|
||||||
|
<Button variant="outline-primary" onClick={removeResultAndRespin}>Vyřadit a točit znovu</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onlyOne && (
|
||||||
|
<small className="text-muted">Na kole musí být aspoň 2 možnosti — vrať nějaké vyřazené, nebo pokračuj s touto.</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="spin-options">
|
||||||
|
<div className="spin-options-header">
|
||||||
|
<span>Na kole ({wheelItems.length})</span>
|
||||||
|
{excludedCount > 0 && (
|
||||||
|
<button className="spin-restore" onClick={restoreAll} disabled={spinning} title="Vrátit vyřazené možnosti">
|
||||||
|
<FontAwesomeIcon icon={faRotateLeft} /> Vrátit {excludedCount}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{wheelItems.map((label, i) => (
|
||||||
|
<li key={`${i}-${label}`}>
|
||||||
|
<span className={`spin-dot spin-seg-${i % SEGMENT_COLORS}`} />
|
||||||
|
<span className="spin-option-label" title={label}>{label}</span>
|
||||||
|
{phase === 'place'
|
||||||
|
? <small className="spin-option-kind">{places[i].kind === 'menu' ? 'menu' : 'rozvoz'}</small>
|
||||||
|
: dishes[i].price && <small className="spin-option-kind">{dishes[i].price}</small>}
|
||||||
|
<button className="spin-remove" onClick={() => removeOption(i)} disabled={spinning}
|
||||||
|
title={`Vyřadit z kola: ${label}`} aria-label={`Vyřadit ${label}`}>
|
||||||
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
{phase === 'dish' && <Button variant="secondary" onClick={backToPlaces} disabled={spinning}>Zpět na podniky</Button>}
|
||||||
|
{phase === 'place' && place && !spinning && (
|
||||||
|
<Button variant="outline-primary" onClick={accept} disabled={saving}>Beru jen podnik</Button>
|
||||||
|
)}
|
||||||
|
{phase === 'place' && place && !spinning && (
|
||||||
|
<Button onClick={goToDishes}>Dál: točit jídlo</Button>
|
||||||
|
)}
|
||||||
|
{phase === 'dish' && !spinning && (dish || dishes.length === 0) && (
|
||||||
|
<Button onClick={accept} disabled={saving}>{dish ? 'Beru to!' : 'Beru podnik'}</Button>
|
||||||
|
)}
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap";
|
import { Modal, Button, Form, ListGroup, Alert, OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
|
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
|
||||||
import { faPen, faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons";
|
import { faArrowsRotate, faPen, faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { addStore, deleteStore, updateStore, Store } from "../../../../types";
|
import { addStore, deleteStore, updateStore, getStoreMenuStatus, refreshStoreMenu, Store, StoreMenuProvider, StoreScrapeInfo, StoreScrapeStatus } from "../../../../types";
|
||||||
|
import { getHumanDateTime } from "../../Utils";
|
||||||
|
import PriceHistory from "../PriceHistory";
|
||||||
|
import { useAppConfig } from "../../hooks/useGroupLimits";
|
||||||
import { getStoreUrls, storeUrlLabel } from "../../utils/storeUrls";
|
import { getStoreUrls, storeUrlLabel } from "../../utils/storeUrls";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -20,6 +23,23 @@ type EditState = {
|
|||||||
urls: string[];
|
urls: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PROVIDER_LABELS: Record<StoreMenuProvider, string> = { bolt: 'Bolt Food', wolt: 'Wolt' };
|
||||||
|
|
||||||
|
/** Krátký popis stavu stahování nabídky od jedné služby. */
|
||||||
|
function describeScrape(p: StoreScrapeStatus): { text: string, className: string, title?: string } {
|
||||||
|
const when = (iso?: string) => iso ? getHumanDateTime(new Date(iso)) : '';
|
||||||
|
switch (p.state) {
|
||||||
|
case 'ok':
|
||||||
|
return { text: `${p.dishCount} hlavních jídel (${when(p.lastSuccessAt)})${p.lastError ? ` · poslední obnovení selhalo: ${p.lastError}` : ''}`, className: 'text-success' };
|
||||||
|
case 'failed':
|
||||||
|
return { text: `selhalo ${p.failures}× (${when(p.lastAttemptAt)}): ${p.lastError ?? ''} · zkusí se znovu`, className: 'text-warning' };
|
||||||
|
case 'blocked':
|
||||||
|
return { text: `zablokováno po ${p.failures} neúspěších (${when(p.lastAttemptAt)}): ${p.lastError ?? ''}`, className: 'text-danger' };
|
||||||
|
default:
|
||||||
|
return { text: 'nestaženo', className: 'text-muted', title: 'Stáhne se automaticky, až bude nabídka potřeba v kole štěstí (a žádná jiná služba ji nemá)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Vstupní pole pro URL nabídek — vždy alespoň jedno prázdné, aby bylo kam psát. */
|
/** Vstupní pole pro URL nabídek — vždy alespoň jedno prázdné, aby bylo kam psát. */
|
||||||
function toUrlInputs(urls: string[]): string[] {
|
function toUrlInputs(urls: string[]): string[] {
|
||||||
return urls.length > 0 ? urls : [''];
|
return urls.length > 0 ? urls : [''];
|
||||||
@@ -33,6 +53,41 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
|||||||
const [heslo, setHeslo] = useState('');
|
const [heslo, setHeslo] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Server bez admin hesla — správa (přidání/úprava/odebrání/obnovení) nejde, zbývá jen přehled.
|
||||||
|
// undefined = konfigurace nenačtena, chováme se jako dřív (správa zapnutá).
|
||||||
|
const adminEnabled = useAppConfig().storeAdmin !== false;
|
||||||
|
const [scrapeInfo, setScrapeInfo] = useState<Record<string, StoreScrapeInfo>>({});
|
||||||
|
const [refreshing, setRefreshing] = useState<string | null>(null);
|
||||||
|
// Rozbalená historie cen („obchod|služba")
|
||||||
|
const [historyOpen, setHistoryOpen] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Stav stahování nabídek (Bolt Food/Wolt) — při otevření a po změně seznamu obchodů
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
getStoreMenuStatus().then(res => {
|
||||||
|
setScrapeInfo(Object.fromEntries((res.data ?? []).map(i => [i.store, i])));
|
||||||
|
}).catch(() => { /* stav je jen informativní */ });
|
||||||
|
}, [isOpen, stores]);
|
||||||
|
|
||||||
|
const handleRefreshMenu = async (name: string, provider: StoreMenuProvider) => {
|
||||||
|
setError(null);
|
||||||
|
setRefreshing(`${name}|${provider}`);
|
||||||
|
try {
|
||||||
|
const res = await refreshStoreMenu({ body: { name, provider, heslo } });
|
||||||
|
if (res.data) {
|
||||||
|
setScrapeInfo(prev => ({ ...prev, [name]: res.data! }));
|
||||||
|
}
|
||||||
|
// Znovu načíst případně otevřenou historii (mohla přibýt nová verze)
|
||||||
|
if (historyOpen === `${name}|${provider}`) {
|
||||||
|
setHistoryOpen(null);
|
||||||
|
setTimeout(() => setHistoryOpen(`${name}|${provider}`));
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Nastala chyba');
|
||||||
|
} finally {
|
||||||
|
setRefreshing(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const addUrlRow = (urls: string[]) => [...urls, ''];
|
const addUrlRow = (urls: string[]) => [...urls, ''];
|
||||||
|
|
||||||
@@ -147,7 +202,7 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal show={isOpen} onHide={onClose}>
|
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title><h2>Správa obchodů</h2></Modal.Title>
|
<Modal.Title><h2>Správa obchodů</h2></Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
@@ -158,6 +213,14 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!adminEnabled && (
|
||||||
|
<Alert variant="info">
|
||||||
|
Správa obchodů není na tomto serveru zapnutá (chybí <code>ADMIN_PASSWORD</code>).
|
||||||
|
Seznam obchodů, stav nabídek a historii cen lze jen prohlížet.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adminEnabled && <>
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>Admin heslo</Form.Label>
|
<Form.Label>Admin heslo</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
@@ -167,6 +230,9 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
|||||||
onChange={e => setHeslo(e.target.value)}
|
onChange={e => setHeslo(e.target.value)}
|
||||||
onKeyDown={e => e.stopPropagation()}
|
onKeyDown={e => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Potřeba pro přidání, úpravu a odebrání obchodu i pro obnovení nabídky (<FontAwesomeIcon icon={faArrowsRotate} />).
|
||||||
|
</Form.Text>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
@@ -185,6 +251,7 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
|||||||
Přidat
|
Přidat
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</>}
|
||||||
|
|
||||||
<h6>Aktuální seznam</h6>
|
<h6>Aktuální seznam</h6>
|
||||||
{stores.length === 0 ? (
|
{stores.length === 0 ? (
|
||||||
@@ -244,8 +311,52 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
|||||||
))}
|
))}
|
||||||
</small>
|
</small>
|
||||||
)}
|
)}
|
||||||
|
{scrapeInfo[s.name]?.providers.map(p => {
|
||||||
|
const { text, className, title } = describeScrape(p);
|
||||||
|
const key = `${s.name}|${p.provider}`;
|
||||||
|
return (
|
||||||
|
<div key={p.provider}>
|
||||||
|
<small className="d-flex align-items-baseline gap-2 mt-1">
|
||||||
|
<span className="text-nowrap text-muted">Menu {PROVIDER_LABELS[p.provider]}:</span>
|
||||||
|
<span className={className} title={title}>{text}</span>
|
||||||
|
{/* Zakázané tlačítko nezobrazí tooltip — obalení do span ho zobrazí i bez hesla */}
|
||||||
|
{adminEnabled && <OverlayTrigger
|
||||||
|
placement="top"
|
||||||
|
overlay={
|
||||||
|
<Tooltip>
|
||||||
|
{heslo
|
||||||
|
? `Znovu stáhnout nabídku z ${PROVIDER_LABELS[p.provider]}`
|
||||||
|
: 'Pro obnovení nabídky zadejte nahoře admin heslo'}
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="d-inline-block" style={{ cursor: heslo ? undefined : 'not-allowed' }}>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className="p-0"
|
||||||
|
disabled={!heslo || refreshing != null}
|
||||||
|
style={heslo ? undefined : { pointerEvents: 'none' }}
|
||||||
|
aria-label={`Znovu stáhnout nabídku z ${PROVIDER_LABELS[p.provider]}`}
|
||||||
|
onClick={() => handleRefreshMenu(s.name, p.provider)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faArrowsRotate} spin={refreshing === key} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</OverlayTrigger>}
|
||||||
|
{p.dishCount > 0 && (
|
||||||
|
<Button variant="link" size="sm" className="p-0 text-nowrap"
|
||||||
|
onClick={() => setHistoryOpen(historyOpen === key ? null : key)}>
|
||||||
|
{historyOpen === key ? 'skrýt historii' : 'historie cen'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</small>
|
||||||
|
{historyOpen === key && <PriceHistory store={s.name} provider={p.provider} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex gap-3">
|
{adminEnabled && <div className="d-flex gap-3">
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={faPen}
|
icon={faPen}
|
||||||
className="action-icon"
|
className="action-icon"
|
||||||
@@ -260,7 +371,7 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
|||||||
onClick={() => handleRemove(s.name)}
|
onClick={() => handleRemove(s.name)}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>}
|
||||||
</ListGroup.Item>
|
</ListGroup.Item>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -6,9 +6,24 @@ const HIDE_SOUPS_KEY = 'hide_soups';
|
|||||||
const THEME_KEY = 'theme_preference';
|
const THEME_KEY = 'theme_preference';
|
||||||
const ACCENT_HUE_KEY = 'accent_hue';
|
const ACCENT_HUE_KEY = 'accent_hue';
|
||||||
const LEGACY_COLOR_THEME_KEY = 'color_theme';
|
const LEGACY_COLOR_THEME_KEY = 'color_theme';
|
||||||
|
const STYLE_THEME_KEY = 'style_theme';
|
||||||
|
const SHOW_BUTTERFLIES_KEY = 'show_butterflies';
|
||||||
|
const AUTO_ORDER_CHOICE_KEY = 'auto_order_choice';
|
||||||
|
|
||||||
export type ThemePreference = 'system' | 'light' | 'dark';
|
export type ThemePreference = 'system' | 'light' | 'dark';
|
||||||
|
|
||||||
|
/** Vizuální styl aplikace (nezávislý na světlém/tmavém režimu). */
|
||||||
|
export type StyleTheme = 'simple' | 'umamusume' | 'mlp' | 'hellgirl' | 'kon' | 'bocchi';
|
||||||
|
|
||||||
|
export const STYLE_THEMES: { value: StyleTheme, label: string }[] = [
|
||||||
|
{ value: 'simple', label: 'Jednoduchý' },
|
||||||
|
{ value: 'umamusume', label: 'Umamusume' },
|
||||||
|
{ value: 'mlp', label: 'My Little Pony' },
|
||||||
|
{ value: 'hellgirl', label: 'Hell Girl' },
|
||||||
|
{ value: 'kon', label: 'K-ON!' },
|
||||||
|
{ value: 'bocchi', label: 'Bocchi the Rock!' },
|
||||||
|
];
|
||||||
|
|
||||||
export type SettingsContextProps = {
|
export type SettingsContextProps = {
|
||||||
bankAccount?: string,
|
bankAccount?: string,
|
||||||
holderName?: string,
|
holderName?: string,
|
||||||
@@ -16,11 +31,17 @@ export type SettingsContextProps = {
|
|||||||
themePreference: ThemePreference,
|
themePreference: ThemePreference,
|
||||||
accentHue: number,
|
accentHue: number,
|
||||||
effectiveDark: boolean,
|
effectiveDark: boolean,
|
||||||
|
styleTheme: StyleTheme,
|
||||||
|
showButterflies: boolean,
|
||||||
|
autoOrderChoice: boolean,
|
||||||
setBankAccountNumber: (accountNumber?: string) => void,
|
setBankAccountNumber: (accountNumber?: string) => void,
|
||||||
setBankAccountHolderName: (holderName?: string) => void,
|
setBankAccountHolderName: (holderName?: string) => void,
|
||||||
setHideSoupsOption: (hideSoups?: boolean) => void,
|
setHideSoupsOption: (hideSoups?: boolean) => void,
|
||||||
setThemePreference: (theme: ThemePreference) => void,
|
setThemePreference: (theme: ThemePreference) => void,
|
||||||
setAccentHue: (hue: number) => void,
|
setAccentHue: (hue: number) => void,
|
||||||
|
setStyleTheme: (theme: StyleTheme) => void,
|
||||||
|
setShowButterflies: (show: boolean) => void,
|
||||||
|
setAutoOrderChoice: (enabled: boolean) => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContextProps = {
|
type ContextProps = {
|
||||||
@@ -50,6 +71,37 @@ function getInitialTheme(): ThemePreference {
|
|||||||
return 'system';
|
return 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInitialStyleTheme(): StyleTheme {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STYLE_THEME_KEY);
|
||||||
|
const known = STYLE_THEMES.find(t => t.value === saved);
|
||||||
|
if (known) {
|
||||||
|
return known.value;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage nedostupný
|
||||||
|
}
|
||||||
|
return 'simple';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialShowButterflies(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(SHOW_BUTTERFLIES_KEY) !== 'false';
|
||||||
|
} catch {
|
||||||
|
// localStorage nedostupný
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialAutoOrderChoice(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(AUTO_ORDER_CHOICE_KEY) === 'true';
|
||||||
|
} catch {
|
||||||
|
// localStorage nedostupný
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function getInitialAccentHue(): number {
|
function getInitialAccentHue(): number {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(ACCENT_HUE_KEY);
|
const saved = localStorage.getItem(ACCENT_HUE_KEY);
|
||||||
@@ -90,6 +142,12 @@ function adjustedL(hue: number, sat: number, targetL: number): number {
|
|||||||
return l;
|
return l;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACCENT_PROPERTIES = ['--luncher-primary', '--luncher-primary-hover', '--luncher-primary-light', '--luncher-action-icon', '--luncher-success'];
|
||||||
|
|
||||||
|
function clearAccentColors(): void {
|
||||||
|
ACCENT_PROPERTIES.forEach(p => document.documentElement.style.removeProperty(p));
|
||||||
|
}
|
||||||
|
|
||||||
function applyAccentColors(hue: number, isDark: boolean): void {
|
function applyAccentColors(hue: number, isDark: boolean): void {
|
||||||
const sat = 70;
|
const sat = 70;
|
||||||
const baseL = adjustedL(hue, sat, isDark ? 55 : 38);
|
const baseL = adjustedL(hue, sat, isDark ? 55 : 38);
|
||||||
@@ -110,6 +168,9 @@ function useProvideSettings(): SettingsContextProps {
|
|||||||
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
|
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
|
||||||
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
|
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
|
||||||
const [accentHue, setHue] = useState<number>(getInitialAccentHue);
|
const [accentHue, setHue] = useState<number>(getInitialAccentHue);
|
||||||
|
const [styleTheme, setStyle] = useState<StyleTheme>(getInitialStyleTheme);
|
||||||
|
const [showButterflies, setShowButterfliesState] = useState<boolean>(getInitialShowButterflies);
|
||||||
|
const [autoOrderChoice, setAutoOrderChoiceState] = useState<boolean>(getInitialAutoOrderChoice);
|
||||||
const [effectiveDark, setEffectiveDark] = useState<boolean>(() => {
|
const [effectiveDark, setEffectiveDark] = useState<boolean>(() => {
|
||||||
try {
|
try {
|
||||||
const pref = localStorage.getItem(THEME_KEY) as ThemePreference | null;
|
const pref = localStorage.getItem(THEME_KEY) as ThemePreference | null;
|
||||||
@@ -178,11 +239,29 @@ function useProvideSettings(): SettingsContextProps {
|
|||||||
}
|
}
|
||||||
}, [themePreference]);
|
}, [themePreference]);
|
||||||
|
|
||||||
// Aplikuje accent barvy při změně hue nebo přepnutí světlý/tmavý
|
useEffect(() => {
|
||||||
|
localStorage.setItem(STYLE_THEME_KEY, styleTheme);
|
||||||
|
document.documentElement.setAttribute('data-luncher-theme', styleTheme);
|
||||||
|
}, [styleTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(SHOW_BUTTERFLIES_KEY, showButterflies ? 'true' : 'false');
|
||||||
|
}, [showButterflies]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(AUTO_ORDER_CHOICE_KEY, autoOrderChoice ? 'true' : 'false');
|
||||||
|
}, [autoOrderChoice]);
|
||||||
|
|
||||||
|
// Aplikuje accent barvy při změně hue nebo přepnutí světlý/tmavý.
|
||||||
|
// Tematické styly (Umamusume, My Little Pony, Hell Girl, K-ON!, Bocchi the Rock!) mají vlastní pevnou paletu, accent barvu proto uplatňuje jen jednoduchý styl.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(ACCENT_HUE_KEY, String(accentHue));
|
localStorage.setItem(ACCENT_HUE_KEY, String(accentHue));
|
||||||
applyAccentColors(accentHue, effectiveDark);
|
if (styleTheme === 'simple') {
|
||||||
}, [accentHue, effectiveDark]);
|
applyAccentColors(accentHue, effectiveDark);
|
||||||
|
} else {
|
||||||
|
clearAccentColors();
|
||||||
|
}
|
||||||
|
}, [accentHue, effectiveDark, styleTheme]);
|
||||||
|
|
||||||
function setBankAccountNumber(bankAccount?: string) {
|
function setBankAccountNumber(bankAccount?: string) {
|
||||||
setBankAccount(bankAccount);
|
setBankAccount(bankAccount);
|
||||||
@@ -204,6 +283,18 @@ function useProvideSettings(): SettingsContextProps {
|
|||||||
setHue(hue);
|
setHue(hue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setStyleTheme(theme: StyleTheme) {
|
||||||
|
setStyle(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShowButterflies(show: boolean) {
|
||||||
|
setShowButterfliesState(show);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAutoOrderChoice(enabled: boolean) {
|
||||||
|
setAutoOrderChoiceState(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bankAccount,
|
bankAccount,
|
||||||
holderName,
|
holderName,
|
||||||
@@ -216,5 +307,11 @@ function useProvideSettings(): SettingsContextProps {
|
|||||||
setHideSoupsOption,
|
setHideSoupsOption,
|
||||||
setThemePreference,
|
setThemePreference,
|
||||||
setAccentHue,
|
setAccentHue,
|
||||||
|
styleTheme,
|
||||||
|
setStyleTheme,
|
||||||
|
showButterflies,
|
||||||
|
setShowButterflies,
|
||||||
|
autoOrderChoice,
|
||||||
|
setAutoOrderChoice,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,8 @@ import socketio from "socket.io-client";
|
|||||||
let socketUrl;
|
let socketUrl;
|
||||||
let socketPath;
|
let socketPath;
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
socketUrl = `http://localhost:3001`;
|
// Stejný stroj jako stránka, aby šlo vývojovou verzi zkoušet i z telefonu či tabletu v síti
|
||||||
|
socketUrl = `http://${globalThis.location.hostname}:3001`;
|
||||||
socketPath = undefined;
|
socketPath = undefined;
|
||||||
} else {
|
} else {
|
||||||
socketUrl = `${globalThis.location.host}`;
|
socketUrl = `${globalThis.location.host}`;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { LunchChoice, Restaurant } from "../../types";
|
import { LunchChoice, Restaurant } from "../../types";
|
||||||
|
|
||||||
|
/** Volby, u kterých se nezadává cena oběda — cena je dána objednávkou, Pizza day, nebo se neobědvá. */
|
||||||
|
export const CHOICES_WITHOUT_PRICE: LunchChoice[] = [LunchChoice.OBJEDNAVAM, LunchChoice.PIZZA, LunchChoice.NEOBEDVAM, LunchChoice.ROZHODUJI];
|
||||||
|
|
||||||
export function getRestaurantName(restaurant: Restaurant) {
|
export function getRestaurantName(restaurant: Restaurant) {
|
||||||
switch (restaurant) {
|
switch (restaurant) {
|
||||||
case Restaurant.SLADOVNICKA:
|
case Restaurant.SLADOVNICKA:
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { StyleTheme, useSettings } from "../context/settings";
|
||||||
|
|
||||||
|
export type GameId = 'drawing';
|
||||||
|
|
||||||
|
/** Ikony miniher podle vizuálního stylu aplikace (ostatní minihry jsou v samostatné aplikaci Lobby). */
|
||||||
|
const ICONS: Record<StyleTheme, Record<GameId, string>> = {
|
||||||
|
simple: { drawing: '🎨' },
|
||||||
|
// Dostihy: trofej
|
||||||
|
umamusume: { drawing: '🏆' },
|
||||||
|
// Paleta Rarity
|
||||||
|
mlp: { drawing: '🖌️' },
|
||||||
|
// Pekelná korespondence: svitek
|
||||||
|
hellgirl: { drawing: '📜' },
|
||||||
|
// Klub lehké hudby: notový zápis
|
||||||
|
kon: { drawing: '🎼' },
|
||||||
|
// Kessoku Band: sešit s texty Bocchi
|
||||||
|
bocchi: { drawing: '📝' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Styl z <html data-luncher-theme> — pro místa mimo React kontext nastavení. */
|
||||||
|
function currentTheme(): StyleTheme {
|
||||||
|
const theme = document.documentElement.getAttribute('data-luncher-theme') as StyleTheme | null;
|
||||||
|
return theme && theme in ICONS ? theme : 'simple';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ikona hry pro aktuální styl (mimo React, např. výpis do konzole). */
|
||||||
|
export const gameIcon = (game: GameId, theme: StyleTheme = currentTheme()) => ICONS[theme]?.[game] ?? ICONS.simple[game];
|
||||||
|
|
||||||
|
/** Ikona hry pro aktuální styl; stránky bez nastavení (statistiky) použijí atribut na <html>. */
|
||||||
|
export function useGameIcon(game: GameId) {
|
||||||
|
const settings = useSettings();
|
||||||
|
return gameIcon(game, settings?.styleTheme ?? currentTheme());
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { getAppConfig, getGroupLimits, subscribeAppConfig } from "../utils/appConfig";
|
||||||
|
|
||||||
|
/** Aktuální limity skupinových objednávek (překreslí se po načtení konfigurace ze serveru). */
|
||||||
|
export function useGroupLimits() {
|
||||||
|
return useSyncExternalStore(subscribeAppConfig, getGroupLimits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Celá runtime konfigurace ze serveru (způsob přihlášení, zapnuté funkce, limity). */
|
||||||
|
export function useAppConfig() {
|
||||||
|
return useSyncExternalStore(subscribeAppConfig, getAppConfig);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { openDrawingGame } from "../components/DrawingGame";
|
||||||
|
import { GameId, gameIcon } from "../games/themeIcons";
|
||||||
|
import { useAppConfig } from "./useGroupLimits";
|
||||||
|
|
||||||
|
type Minigame = {
|
||||||
|
id: GameId,
|
||||||
|
/** Kód, který se napíše po sérii kliknutí na logo. */
|
||||||
|
code: string,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
open: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skryté minihry, které zůstaly v Luncheru (ostatní jsou v samostatné aplikaci Lobby).
|
||||||
|
* Novou hru stačí přidat sem.
|
||||||
|
*/
|
||||||
|
const MINIGAMES: Minigame[] = [
|
||||||
|
{
|
||||||
|
id: 'drawing',
|
||||||
|
code: 'kresleni',
|
||||||
|
name: 'Kreslení',
|
||||||
|
description: 'Jeden kreslí jídlo z dnešních menu, ostatní hádají.',
|
||||||
|
open: openDrawingGame,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const HELP_CODE = 'help';
|
||||||
|
const SECRET_CLICKS = 3;
|
||||||
|
/** Max. pauza mezi kliknutími na logo, aby se počítala do série (ms). */
|
||||||
|
const SECRET_CLICK_GAP_MS = 400;
|
||||||
|
/** Jak dlouho po sérii kliknutí (případně po návratu z Lobby do této záložky) lze napsat kód (ms). */
|
||||||
|
const SECRET_ARM_MS = 8000;
|
||||||
|
|
||||||
|
function printHelp(lobbyUrl?: string) {
|
||||||
|
console.log('%c🎮 Luncher minihry', 'font-size: 16px; font-weight: bold');
|
||||||
|
if (lobbyUrl) {
|
||||||
|
console.log(`Klikněte 3× na logo — otevře se Lobby s dalšími minihrami (${lobbyUrl}).`);
|
||||||
|
console.log('Po návratu do Luncheru lze napsat kód hry:');
|
||||||
|
} else {
|
||||||
|
console.log('Klikněte 3× na logo a napište kód hry:');
|
||||||
|
}
|
||||||
|
console.table(MINIGAMES.map(g => ({ kód: g.code, hra: `${gameIcon(g.id)} ${g.name}`, popis: g.description })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Malá písmena bez diakritiky — česká klávesnice na mobilu může "kresleni" opravit na "Kreslení". */
|
||||||
|
const normalizeCode = (text: string) => text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skrytý vstup do miniher: 3× rychle kliknout na logo. Je-li nastavena adresa Lobby, otevře se v nové záložce.
|
||||||
|
* Poté lze (po návratu do Luncheru) napsat kód hry, která zůstala v Luncheru, případně "help" pro výpis do konzole prohlížeče.
|
||||||
|
* Jedno či dvě kliknutí se chovají jako běžný odkaz (jen s malým zpožděním).
|
||||||
|
* Bez Lobby se na dotykových zařízeních kód píše do skrytého pole (`inputRef`), které otevře softwarovou klávesnici.
|
||||||
|
* Po otevření Lobby klávesnici po návratu do záložky otevřít nelze (chce gesto uživatele), na mobilu tak kód zadat nepůjde.
|
||||||
|
*/
|
||||||
|
export function useMinigameSecret(href: string) {
|
||||||
|
const { lobbyUrl } = useAppConfig();
|
||||||
|
const [armed, setArmed] = useState(false);
|
||||||
|
const clicks = useRef(0);
|
||||||
|
const timer = useRef<number | undefined>(undefined);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const onLogoClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||||
|
// Otevření v novém tabu apod. necháme prohlížeči
|
||||||
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
window.clearTimeout(timer.current);
|
||||||
|
clicks.current++;
|
||||||
|
if (clicks.current >= SECRET_CLICKS) {
|
||||||
|
clicks.current = 0;
|
||||||
|
if (lobbyUrl) {
|
||||||
|
// Otevření musí proběhnout přímo v obsluze kliknutí, jinak ho prohlížeč zablokuje jako vyskakovací okno
|
||||||
|
window.open(lobbyUrl, '_blank', 'noopener');
|
||||||
|
} else {
|
||||||
|
// Fokus musí proběhnout přímo v obsluze kliknutí, jinak iOS klávesnici neotevře
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
input.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setArmed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer.current = window.setTimeout(() => {
|
||||||
|
clicks.current = 0;
|
||||||
|
window.location.assign(href);
|
||||||
|
}, SECRET_CLICK_GAP_MS);
|
||||||
|
}, [href, lobbyUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!armed) return;
|
||||||
|
const input = inputRef.current;
|
||||||
|
const maxLength = Math.max(HELP_CODE.length, ...MINIGAMES.map(g => g.code.length));
|
||||||
|
let typed = '';
|
||||||
|
const check = (text: string) => {
|
||||||
|
if (text.endsWith(HELP_CODE)) {
|
||||||
|
setArmed(false);
|
||||||
|
printHelp(lobbyUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const game = MINIGAMES.find(g => text.endsWith(g.code));
|
||||||
|
if (game) {
|
||||||
|
setArmed(false);
|
||||||
|
game.open();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('input, textarea, select, [contenteditable="true"]') || e.key.length !== 1) return;
|
||||||
|
typed = (typed + normalizeCode(e.key)).slice(-maxLength);
|
||||||
|
check(typed);
|
||||||
|
};
|
||||||
|
// Softwarová klávesnice (Android) posílá v keydown jen "Unidentified", proto se čte celý text pole
|
||||||
|
const onInput = () => check(normalizeCode(input?.value ?? ''));
|
||||||
|
// Limit běží, jen když je záložka vidět — po odchodu do Lobby se zastaví a po návratu začne znovu
|
||||||
|
let disarm: number | undefined;
|
||||||
|
const startDisarm = () => {
|
||||||
|
window.clearTimeout(disarm);
|
||||||
|
disarm = window.setTimeout(() => setArmed(false), SECRET_ARM_MS);
|
||||||
|
};
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
startDisarm();
|
||||||
|
} else {
|
||||||
|
window.clearTimeout(disarm);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
startDisarm();
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
input?.addEventListener('input', onInput);
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(disarm);
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
input?.removeEventListener('input', onInput);
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
input.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [armed, lobbyUrl]);
|
||||||
|
|
||||||
|
useEffect(() => () => window.clearTimeout(timer.current), []);
|
||||||
|
|
||||||
|
return { armed, onLogoClick, inputRef };
|
||||||
|
}
|
||||||
+13
-2
@@ -10,6 +10,7 @@ import { getConfig } from '../../types/gen/sdk.gen';
|
|||||||
import { getToken } from './Utils';
|
import { getToken } from './Utils';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import * as Sentry from '@sentry/react';
|
import * as Sentry from '@sentry/react';
|
||||||
|
import { setAppConfig } from './utils/appConfig';
|
||||||
|
|
||||||
client.setConfig({
|
client.setConfig({
|
||||||
auth: () => getToken(),
|
auth: () => getToken(),
|
||||||
@@ -17,15 +18,25 @@ client.setConfig({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Sentry se inicializuje až podle runtime konfigurace ze serveru (bez DSN zůstává vypnuté).
|
// Sentry se inicializuje až podle runtime konfigurace ze serveru (bez DSN zůstává vypnuté).
|
||||||
// Klient je statický build, takže DSN nejde zapéct při buildu — server ho zná z env.
|
// Klient je statický build, takže DSN ani limity nejde zapéct při buildu — server je zná z env.
|
||||||
getConfig().then(({ data }) => {
|
getConfig().then(({ data }) => {
|
||||||
|
setAppConfig({
|
||||||
|
loaded: true,
|
||||||
|
trustedHeaders: data?.auth?.trustedHeaders,
|
||||||
|
storeAdmin: data?.features?.storeAdmin,
|
||||||
|
lobbyUrl: data?.links?.lobbyUrl ?? undefined,
|
||||||
|
groupLimits: data?.limits?.group,
|
||||||
|
});
|
||||||
if (data?.sentry.dsn) {
|
if (data?.sentry.dsn) {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: data.sentry.dsn,
|
dsn: data.sentry.dsn,
|
||||||
environment: data.sentry.environment,
|
environment: data.sentry.environment,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}).catch(() => { /* config endpoint nedostupný — běžíme bez Sentry */ });
|
}).catch(() => {
|
||||||
|
// Config endpoint nedostupný — běžíme bez Sentry, s výchozími limity a původním chováním přihlášení
|
||||||
|
setAppConfig({ loaded: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Interceptor na vyhození toasteru při chybě
|
// Interceptor na vyhození toasteru při chybě
|
||||||
client.interceptors.response.use(async response => {
|
client.interceptors.response.use(async response => {
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import DatePicker, { registerLocale } from 'react-datepicker';
|
|||||||
import { cs } from 'date-fns/locale';
|
import { cs } from 'date-fns/locale';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import {
|
import {
|
||||||
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr, TrackingProvider,
|
ClientData, GroupState, LunchChoice, MealSlot, OrderGroup, OrderGroupMember, PendingQr, TrackingProvider,
|
||||||
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setTracking, getOrderDates,
|
StoreMenuDish, getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setTracking, getOrderDates, getStoreMenu, addChoice,
|
||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
|
import MenuHintCell from '../components/MenuHintCell';
|
||||||
|
import EaterBadge from '../components/EaterBadge';
|
||||||
|
import { addItemLimitError, addNoteItem, countNoteItems, removeNoteItem } from '../utils/menuMatch';
|
||||||
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
||||||
import { getStoreUrls, isHttpUrl, storeUrlLabel } from '../utils/storeUrls';
|
import { getStoreUrls, isHttpUrl, storeUrlLabel } from '../utils/storeUrls';
|
||||||
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
||||||
@@ -27,6 +30,7 @@ import BoltSimulationModal from '../components/modals/BoltSimulationModal';
|
|||||||
import GenerateMockOrdersModal from '../components/modals/GenerateMockOrdersModal';
|
import GenerateMockOrdersModal from '../components/modals/GenerateMockOrdersModal';
|
||||||
import PendingPayments from '../components/PendingPayments';
|
import PendingPayments from '../components/PendingPayments';
|
||||||
import OrderProgress, { PROVIDER_LABEL } from '../components/OrderProgress';
|
import OrderProgress, { PROVIDER_LABEL } from '../components/OrderProgress';
|
||||||
|
import { isArrowNavigationBlocked } from '../utils/arrowNavigation';
|
||||||
|
|
||||||
const SLOT = MealSlot.EXTRA;
|
const SLOT = MealSlot.EXTRA;
|
||||||
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
@@ -147,6 +151,8 @@ export default function OrderGroupsPage() {
|
|||||||
const [mockOrdersModalOpen, setMockOrdersModalOpen] = useState(false);
|
const [mockOrdersModalOpen, setMockOrdersModalOpen] = useState(false);
|
||||||
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
|
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
|
||||||
const [pageError, setPageError] = useState<string | null>(null);
|
const [pageError, setPageError] = useState<string | null>(null);
|
||||||
|
// Stažené nabídky podniků (dle názvu obchodu) pro sloupec „Z nabídky"
|
||||||
|
const [storeMenus, setStoreMenus] = useState<Record<string, StoreMenuDish[] | null>>({});
|
||||||
|
|
||||||
const fetchData = async (date?: string) => {
|
const fetchData = async (date?: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -219,9 +225,8 @@ export default function OrderGroupsPage() {
|
|||||||
|
|
||||||
// Navigace mezi dny pomocí klávesových šipek (←/→), obdobně jako na hlavní stránce
|
// Navigace mezi dny pomocí klávesových šipek (←/→), obdobně jako na hlavní stránce
|
||||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
// Ignorujeme, pokud uživatel právě píše do formulářového pole
|
// Ignorujeme, pokud uživatel právě píše do formulářového pole nebo je otevřený dialog (např. minihra)
|
||||||
const tag = (e.target as HTMLElement)?.tagName;
|
if (isArrowNavigationBlocked(e)) return;
|
||||||
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
|
|
||||||
const currentIso = data?.isoDate;
|
const currentIso = data?.isoDate;
|
||||||
if (!currentIso) return;
|
if (!currentIso) return;
|
||||||
if (e.keyCode === 37) {
|
if (e.keyCode === 37) {
|
||||||
@@ -256,6 +261,22 @@ export default function OrderGroupsPage() {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pokud to má uživatel zapnuté v nastavení, přepne jeho dnešní volbu stravování na "Budu objednávat".
|
||||||
|
* Volbu nemění, pokud již objednává, nebo je přihlášen na Pizza day (změnou by přišel o objednané pizzy).
|
||||||
|
*/
|
||||||
|
const switchChoiceToOrdering = async () => {
|
||||||
|
if (!settings?.autoOrderChoice || !auth?.login) return;
|
||||||
|
const lunch = await getData({ query: { slot: MealSlot.OBED } });
|
||||||
|
const choices = lunch.data?.choices;
|
||||||
|
if (!choices) return;
|
||||||
|
if (auth.login in (choices[LunchChoice.OBJEDNAVAM] ?? {}) || auth.login in (choices[LunchChoice.PIZZA] ?? {})) return;
|
||||||
|
const result = await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: MealSlot.OBED } });
|
||||||
|
if (result.error) {
|
||||||
|
setPageError(`Nepodařilo se přepnout volbu stravování: ${(result.error as any).error || 'Nastala chyba'}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!newGroupName || !auth?.login) return;
|
if (!newGroupName || !auth?.login) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
@@ -266,13 +287,17 @@ export default function OrderGroupsPage() {
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
setNewGroupName('');
|
setNewGroupName('');
|
||||||
setNewGroupUrl('');
|
setNewGroupUrl('');
|
||||||
|
await switchChoiceToOrdering();
|
||||||
}
|
}
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleJoin = (groupId: string) =>
|
const handleJoin = async (groupId: string) => {
|
||||||
refresh(() => addGroupMember({ body: { id: groupId } }));
|
if (await refresh(() => addGroupMember({ body: { id: groupId } }))) {
|
||||||
|
await switchChoiceToOrdering();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleLock = (group: OrderGroup) => {
|
const handleToggleLock = (group: OrderGroup) => {
|
||||||
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
|
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
|
||||||
@@ -308,6 +333,49 @@ export default function OrderGroupsPage() {
|
|||||||
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
|
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Nabídky podniků zobrazených skupin — jen pro obchody ze seznamu, každou jednou
|
||||||
|
const groupStoreNames = (data?.groups ?? [])
|
||||||
|
.map(g => (data?.stores ?? []).find(s => s.name.toLowerCase() === g.name.toLowerCase())?.name)
|
||||||
|
.filter((n): n is string => !!n);
|
||||||
|
const groupStoreKey = [...new Set(groupStoreNames)].sort().join('|');
|
||||||
|
useEffect(() => {
|
||||||
|
for (const name of groupStoreKey ? groupStoreKey.split('|') : []) {
|
||||||
|
if (name in storeMenus) continue;
|
||||||
|
setStoreMenus(prev => ({ ...prev, [name]: null }));
|
||||||
|
getStoreMenu({ query: { name } })
|
||||||
|
.then(r => setStoreMenus(prev => ({ ...prev, [name]: r.data?.dishes ?? [] })))
|
||||||
|
.catch(() => setStoreMenus(prev => ({ ...prev, [name]: [] })));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [groupStoreKey]);
|
||||||
|
|
||||||
|
const getGroupMenu = (group: OrderGroup): StoreMenuDish[] | undefined =>
|
||||||
|
Object.entries(storeMenus).find(([name]) => name.toLowerCase() === group.name.toLowerCase())?.[1] ?? undefined;
|
||||||
|
|
||||||
|
/** Výběr položky z nabídky — připojí ji k poznámce (opakovaný výběr zvýší počet „2x") a cenu přičte k částce. */
|
||||||
|
const handlePickDish = async (group: OrderGroup, login: string, member: OrderGroupMember, dish: StoreMenuDish) => {
|
||||||
|
const limitError = addItemLimitError(member.note, dish.name, getGroupMenu(group) ?? []);
|
||||||
|
if (limitError) {
|
||||||
|
setPageError(limitError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const note = addNoteItem(member.note, dish.name, getGroupMenu(group) ?? []);
|
||||||
|
const amount = dish.price != null ? (member.amount ?? 0) + Math.round(dish.price * 100) : member.amount;
|
||||||
|
return refresh(() => updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Odebere jeden kus položky z poznámky a odečte její cenu z částky (nikdy pod nulu). */
|
||||||
|
const handleRemoveDish = (group: OrderGroup, login: string, member: OrderGroupMember, index: number) => {
|
||||||
|
const { note, removedPrice } = removeNoteItem(member.note, index, getGroupMenu(group) ?? []);
|
||||||
|
const amount = removedPrice != null && member.amount != null
|
||||||
|
? Math.max(0, member.amount - Math.round(removedPrice * 100))
|
||||||
|
: member.amount;
|
||||||
|
return refresh(() => updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseMenuPrice = (group: OrderGroup, login: string, priceKc: number) =>
|
||||||
|
refresh(() => updateGroupMember({ body: { id: group.id, login, amount: Math.round(priceKc * 100) } }));
|
||||||
|
|
||||||
const handleSaveNote = async (groupId: string, login: string) => {
|
const handleSaveNote = async (groupId: string, login: string) => {
|
||||||
const key = `${groupId}:${login}`;
|
const key = `${groupId}:${login}`;
|
||||||
const note = editNotes[key] ?? '';
|
const note = editNotes[key] ?? '';
|
||||||
@@ -630,6 +698,7 @@ export default function OrderGroupsPage() {
|
|||||||
<th style={{ width: 180 }}>Částka (bez slev)</th>
|
<th style={{ width: 180 }}>Částka (bez slev)</th>
|
||||||
<th style={{ width: 220 }}>Příplatek</th>
|
<th style={{ width: 220 }}>Příplatek</th>
|
||||||
<th>Poznámka</th>
|
<th>Poznámka</th>
|
||||||
|
{getGroupMenu(group)?.length ? <th style={{ width: 240 }}>Z nabídky</th> : null}
|
||||||
<th style={{ width: 160 }}>Celkem (s poplatky)</th>
|
<th style={{ width: 160 }}>Celkem (s poplatky)</th>
|
||||||
<th style={{ width: 40 }}></th>
|
<th style={{ width: 40 }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -646,6 +715,7 @@ export default function OrderGroupsPage() {
|
|||||||
<tr key={memberLogin}>
|
<tr key={memberLogin}>
|
||||||
<td>
|
<td>
|
||||||
<span className="user-info">
|
<span className="user-info">
|
||||||
|
<EaterBadge items={countNoteItems(member.note, getGroupMenu(group) ?? [])} />
|
||||||
<strong>{memberLogin}</strong>
|
<strong>{memberLogin}</strong>
|
||||||
{memberLogin === group.creatorLogin && (
|
{memberLogin === group.creatorLogin && (
|
||||||
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
|
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
|
||||||
@@ -744,6 +814,19 @@ export default function OrderGroupsPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
{getGroupMenu(group)?.length ? (
|
||||||
|
<td>
|
||||||
|
<MenuHintCell
|
||||||
|
menu={getGroupMenu(group)}
|
||||||
|
note={member.note}
|
||||||
|
amount={member.amount}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onPick={dish => handlePickDish(group, memberLogin, member, dish)}
|
||||||
|
onUsePrice={price => handleUseMenuPrice(group, memberLogin, price)}
|
||||||
|
onRemoveItem={index => handleRemoveDish(group, memberLogin, member, index)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
|
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
|
||||||
{memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'}
|
{memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'}
|
||||||
@@ -775,7 +858,7 @@ export default function OrderGroupsPage() {
|
|||||||
return groupTotal > 0 ? (
|
return groupTotal > 0 ? (
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr style={{ fontWeight: 700, borderTop: '2px solid var(--luncher-border)' }}>
|
<tr style={{ fontWeight: 700, borderTop: '2px solid var(--luncher-border)' }}>
|
||||||
<td colSpan={4} className="text-end" style={{ fontSize: '0.9em' }}>Celkem za skupinu:</td>
|
<td colSpan={getGroupMenu(group)?.length ? 5 : 4} className="text-end" style={{ fontSize: '0.9em' }}>Celkem za skupinu:</td>
|
||||||
<td className="text-end">{groupTotal / 100} Kč</td>
|
<td className="text-end">{groupTotal / 100} Kč</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { getLunchChoiceName } from "../enums";
|
import { getLunchChoiceName } from "../enums";
|
||||||
import './StatsPage.scss';
|
import './StatsPage.scss';
|
||||||
|
import { isArrowNavigationBlocked } from '../utils/arrowNavigation';
|
||||||
|
|
||||||
const CHART_WIDTH = 1400;
|
const CHART_WIDTH = 1400;
|
||||||
const CHART_HEIGHT = 700;
|
const CHART_HEIGHT = 700;
|
||||||
@@ -131,6 +132,7 @@ export default function StatsPage() {
|
|||||||
}, [dateRange]);
|
}, [dateRange]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: any) => {
|
const handleKeyDown = useCallback((e: any) => {
|
||||||
|
if (isArrowNavigationBlocked(e)) return;
|
||||||
if (e.keyCode === 37) {
|
if (e.keyCode === 37) {
|
||||||
handlePreviousWeek();
|
handlePreviousWeek();
|
||||||
} else if (e.keyCode === 39 && !isCurrentOrFutureWeek) {
|
} else if (e.keyCode === 39 && !isCurrentOrFutureWeek) {
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
// ============================================
|
||||||
|
// STYL "BOCCHI THE ROCK!" (volitelný, výchozí je jednoduchý styl)
|
||||||
|
// Inspirováno anime Bocchi the Rock! (ぼっち・ざ・ろっく!) a kapelou Kessoku Band (結束バンド).
|
||||||
|
// Růžová tepláková bunda Bocchi, žlutá a modrá kostička z jejích vlasů,
|
||||||
|
// černý Les Paul, flyery z live house STARRY — tlusté černé linky, tvrdé stíny,
|
||||||
|
// rastr jako z kopírky a samolepky. Občas se nápis „zasekne" Bocchiinou úzkostí.
|
||||||
|
// Tmavý režim = koncert v live house STARRY pod barevnými světly.
|
||||||
|
// Aktivní, pokud má <html> atribut data-luncher-theme="bocchi".
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
$bocchi: 'html[data-luncher-theme="bocchi"]';
|
||||||
|
|
||||||
|
// Kessoku Band: Bocchi (růžová), Nijika (žlutá), Ryo (modrá), Kita (červená)
|
||||||
|
$bocchi-band: linear-gradient(90deg, #e0457b 0 25%, #ffcf33 25% 50%, #3a63c9 50% 75%, #d93a30 75% 100%);
|
||||||
|
|
||||||
|
#{$bocchi} {
|
||||||
|
--bocchi-font: 'M PLUS Rounded 1c', 'Nunito', -apple-system, sans-serif;
|
||||||
|
--bocchi-display: 'Dela Gothic One', 'M PLUS Rounded 1c', sans-serif;
|
||||||
|
--bocchi-pink: #cc3570; // tepláková bunda Bocchi
|
||||||
|
--bocchi-pink-deep: #a8245a;
|
||||||
|
--bocchi-pink-light: #ffd6e5;
|
||||||
|
--bocchi-nijika: #ffcf33; // kostička ve vlasech / Nijika
|
||||||
|
--bocchi-ryo: #3a63c9; // kostička ve vlasech / Ryo
|
||||||
|
--bocchi-kita: #d93a30;
|
||||||
|
--bocchi-ink: #1d1a22; // černý Les Paul, linky flyeru
|
||||||
|
--bocchi-line: var(--bocchi-ink);
|
||||||
|
--bocchi-hard-shadow: rgb(29 26 34 / 0.9);
|
||||||
|
--bocchi-halftone: rgb(224 69 123 / 0.13);
|
||||||
|
|
||||||
|
--luncher-primary: var(--bocchi-pink);
|
||||||
|
--luncher-primary-hover: var(--bocchi-pink-deep);
|
||||||
|
--luncher-primary-light: #ffe4ee;
|
||||||
|
--luncher-bg: #fff6f9;
|
||||||
|
--luncher-bg-card: #ffffff;
|
||||||
|
--luncher-bg-hover: #fff0f5;
|
||||||
|
--luncher-text: var(--bocchi-ink);
|
||||||
|
--luncher-text-secondary: #4a4252;
|
||||||
|
--luncher-text-muted: #8f8597;
|
||||||
|
--luncher-border: #e9d9e1;
|
||||||
|
--luncher-border-light: #f5e9ef;
|
||||||
|
--luncher-navbar-bg: var(--bocchi-ink);
|
||||||
|
--luncher-navbar-text: #ffffff;
|
||||||
|
--luncher-action-icon: var(--bocchi-pink);
|
||||||
|
--luncher-buyer-icon: var(--bocchi-ryo);
|
||||||
|
--luncher-success: #2e8a4f;
|
||||||
|
--luncher-warning: #d08f00;
|
||||||
|
--luncher-danger: var(--bocchi-kita);
|
||||||
|
--luncher-radius-sm: 6px;
|
||||||
|
--luncher-radius: 8px;
|
||||||
|
--luncher-radius-lg: 10px;
|
||||||
|
--luncher-radius-xl: 12px;
|
||||||
|
--luncher-shadow: 4px 4px 0 var(--bocchi-hard-shadow);
|
||||||
|
--luncher-shadow-lg: 6px 6px 0 var(--bocchi-pink);
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] {
|
||||||
|
// Live house STARRY — tma, kouř a barevná světla nad pódiem
|
||||||
|
--bocchi-pink: #ff6fa0;
|
||||||
|
--bocchi-pink-deep: #e0457b;
|
||||||
|
--bocchi-pink-light: #4a1f33;
|
||||||
|
--bocchi-ryo: #7d9cff;
|
||||||
|
--bocchi-kita: #ff6a5c;
|
||||||
|
--bocchi-line: #5a4d66;
|
||||||
|
--bocchi-hard-shadow: #8c2350;
|
||||||
|
--bocchi-halftone: rgb(255 111 160 / 0.10);
|
||||||
|
|
||||||
|
--luncher-primary: #ff7aa8;
|
||||||
|
--luncher-primary-hover: #ff9cbf;
|
||||||
|
--luncher-primary-light: #3a1a2b;
|
||||||
|
--luncher-bg: #121016;
|
||||||
|
--luncher-bg-card: #1d1a24;
|
||||||
|
--luncher-bg-hover: #282231;
|
||||||
|
--luncher-text: #f5eff8;
|
||||||
|
--luncher-text-secondary: #c9bfd2;
|
||||||
|
--luncher-text-muted: #8f8499;
|
||||||
|
--luncher-border: #3a3246;
|
||||||
|
--luncher-border-light: #2a2433;
|
||||||
|
--luncher-navbar-bg: #0a090c;
|
||||||
|
--luncher-action-icon: #ff7aa8;
|
||||||
|
--luncher-buyer-icon: #ffcf33;
|
||||||
|
--luncher-success: #6fd59a;
|
||||||
|
--luncher-warning: #ffcf33;
|
||||||
|
--luncher-danger: #ff6a5c;
|
||||||
|
--luncher-shadow: 4px 4px 0 var(--bocchi-hard-shadow);
|
||||||
|
--luncher-shadow-lg: 6px 6px 0 var(--bocchi-ryo);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--bocchi-font);
|
||||||
|
background-color: var(--luncher-bg);
|
||||||
|
// Rastr jako z kopírovaného flyeru, nahoře růžový nádech
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle, var(--bocchi-halftone) 0 1.4px, transparent 1.9px),
|
||||||
|
linear-gradient(180deg, #ffe0ec 0, var(--luncher-bg) 360px);
|
||||||
|
background-size: 12px 12px, 100% 100%;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] body {
|
||||||
|
// Růžové a modré světlo z rampy nad pódiem
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle, var(--bocchi-halftone) 0 1.4px, transparent 1.9px),
|
||||||
|
radial-gradient(ellipse 40% 55% at 15% -10%, rgb(255 111 160 / 0.22), transparent 70%),
|
||||||
|
radial-gradient(ellipse 40% 55% at 85% -10%, rgb(125 156 255 / 0.20), transparent 70%),
|
||||||
|
linear-gradient(180deg, #1c1622 0, var(--luncher-bg) 420px);
|
||||||
|
background-size: 12px 12px, 100% 100%, 100% 100%, 100% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, .navbar-brand {
|
||||||
|
font-family: var(--bocchi-display);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-family: var(--bocchi-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Navbar: černá jako Les Paul, pod ním pruh Kessoku Band ----------
|
||||||
|
.navbar {
|
||||||
|
background:
|
||||||
|
#{$bocchi-band} bottom / 100% 5px no-repeat,
|
||||||
|
var(--luncher-navbar-bg) !important;
|
||||||
|
padding-bottom: calc(var(--bs-navbar-padding-y, 0.5rem) + 5px);
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
animation: bocchi-glitch 7s steps(1, end) infinite;
|
||||||
|
|
||||||
|
// Žlutá a modrá kostička z vlasů Bocchi
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.42em;
|
||||||
|
height: 0.42em;
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-right: 0.6em;
|
||||||
|
vertical-align: super;
|
||||||
|
background: var(--bocchi-nijika);
|
||||||
|
box-shadow: 0.55em 0.18em 0 var(--bocchi-ryo);
|
||||||
|
transform: rotate(-12deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
border: 2px solid var(--bocchi-line);
|
||||||
|
border-radius: var(--luncher-radius);
|
||||||
|
box-shadow: var(--luncher-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Titulek dne: žlutá samolepka z flyeru ----------
|
||||||
|
.day-navigator .title {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 24px 8px;
|
||||||
|
font-family: var(--bocchi-display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--bocchi-ink) !important;
|
||||||
|
background: var(--bocchi-nijika);
|
||||||
|
border: 3px solid var(--bocchi-ink);
|
||||||
|
border-radius: var(--luncher-radius-sm);
|
||||||
|
box-shadow: 5px 5px 0 var(--bocchi-pink);
|
||||||
|
transform: rotate(1.2deg);
|
||||||
|
|
||||||
|
&::before, &::after {
|
||||||
|
content: '★';
|
||||||
|
font-size: 0.7em;
|
||||||
|
margin: 0 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: var(--bocchi-pink-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.text-muted {
|
||||||
|
background: #e6e0e8;
|
||||||
|
color: #6b6273 !important;
|
||||||
|
box-shadow: 5px 5px 0 #b9afbf;
|
||||||
|
|
||||||
|
&::before, &::after {
|
||||||
|
color: #8f8597;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Karty podniků: flyery s tvrdým stínem ----------
|
||||||
|
.restaurant-card {
|
||||||
|
border: 2px solid var(--bocchi-line);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
box-shadow: var(--luncher-shadow);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translate(-2px, -2px);
|
||||||
|
border-color: var(--bocchi-line);
|
||||||
|
box-shadow: var(--luncher-shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restaurant-header {
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
#{$bocchi-band} bottom / 100% 4px no-repeat,
|
||||||
|
var(--bocchi-ink);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background:
|
||||||
|
#{$bocchi-band} bottom / 100% 4px no-repeat,
|
||||||
|
linear-gradient(90deg, var(--bocchi-pink-deep), var(--bocchi-pink));
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: rgb(255 255 255 / 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .restaurant-card .restaurant-header:not(:hover) {
|
||||||
|
background:
|
||||||
|
#{$bocchi-band} bottom / 100% 4px no-repeat,
|
||||||
|
#0a090c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-table tbody tr:hover > td {
|
||||||
|
background: #fff0f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
|
||||||
|
background: #2e2236;
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-price {
|
||||||
|
color: var(--bocchi-pink);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Panely a tabulka voleb ----------
|
||||||
|
.choice-section,
|
||||||
|
.choices-table,
|
||||||
|
.pizza-section,
|
||||||
|
.no-votes {
|
||||||
|
border: 2px solid var(--bocchi-line);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
box-shadow: var(--luncher-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// <table> s border-collapse: collapse ignoruje border-radius — kulaté rohy jen se „separate"
|
||||||
|
.choices-table {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sloupec s podnikem: světle žlutý jako Nijika, oddělený černou linkou
|
||||||
|
.choices-table > tbody > tr > td:first-child {
|
||||||
|
background: #fff4c7;
|
||||||
|
color: var(--bocchi-ink);
|
||||||
|
font-weight: 800;
|
||||||
|
border-right: 2px solid var(--bocchi-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
|
||||||
|
background: #2a2416;
|
||||||
|
color: var(--bocchi-nijika);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap karty (skupinové objednávky apod.) a seznamy — stejné linky a stíny jako ostatní panely
|
||||||
|
.card {
|
||||||
|
border: 2px solid var(--bocchi-line);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
box-shadow: var(--luncher-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
|
||||||
|
// Rozbalovací výběr z nabídky nesmí karta oříznout
|
||||||
|
&:has(.select-search-container) {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .card-header {
|
||||||
|
background: var(--luncher-primary-light);
|
||||||
|
border-bottom: 2px solid var(--bocchi-line);
|
||||||
|
font-weight: 800;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-radius: calc(var(--luncher-radius-lg) - 2px) calc(var(--luncher-radius-lg) - 2px) 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Průhledná tabulka — rohy pak zaobluje karta sama (i když nesmí ořezávat obsah)
|
||||||
|
.table {
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
// Spodní linka posledního řádku by přečnívala přes zaoblené rohy
|
||||||
|
> :last-child > tr:last-child > * {
|
||||||
|
border-bottom-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
border-radius: var(--luncher-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid var(--bocchi-line);
|
||||||
|
|
||||||
|
> .list-group-item {
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select, .form-control,
|
||||||
|
input[type="text"], input[type="email"], input[type="password"] {
|
||||||
|
border-radius: var(--luncher-radius-sm);
|
||||||
|
border: 2px solid var(--bocchi-line);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--bocchi-pink);
|
||||||
|
box-shadow: 3px 3px 0 var(--bocchi-pink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Tlačítka: samolepky s černou linkou, po stisku „zaklapnou" do stínu ----------
|
||||||
|
.btn {
|
||||||
|
border-radius: var(--luncher-radius-sm);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translate(3px, 3px) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--bocchi-pink);
|
||||||
|
border: 2px solid var(--bocchi-ink);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 3px 3px 0 var(--bocchi-ink);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bocchi-pink-deep);
|
||||||
|
border-color: var(--bocchi-ink);
|
||||||
|
box-shadow: 3px 3px 0 var(--bocchi-ink), 0 0 0 3px rgb(255 207 51 / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #cfc6d3;
|
||||||
|
border-color: #8f8597;
|
||||||
|
box-shadow: 3px 3px 0 #8f8597;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .btn-primary {
|
||||||
|
color: #1d1a22;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-primary {
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
border: 2px solid var(--bocchi-line);
|
||||||
|
color: var(--luncher-primary);
|
||||||
|
box-shadow: 3px 3px 0 var(--bocchi-hard-shadow);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--luncher-primary-light);
|
||||||
|
border-color: var(--bocchi-line);
|
||||||
|
color: var(--luncher-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
border: 2px solid var(--bocchi-line);
|
||||||
|
color: var(--luncher-text);
|
||||||
|
box-shadow: 3px 3px 0 var(--bocchi-hard-shadow);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--luncher-bg-hover);
|
||||||
|
border-color: var(--bocchi-line);
|
||||||
|
color: var(--luncher-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--bocchi-kita);
|
||||||
|
border: 2px solid var(--bocchi-ink);
|
||||||
|
box-shadow: 3px 3px 0 var(--bocchi-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon:hover {
|
||||||
|
background: var(--luncher-primary-light);
|
||||||
|
color: var(--bocchi-pink);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Modaly ----------
|
||||||
|
.modal-content {
|
||||||
|
border: 3px solid var(--bocchi-line);
|
||||||
|
border-radius: var(--luncher-radius-xl);
|
||||||
|
box-shadow: 8px 8px 0 var(--bocchi-pink);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
background:
|
||||||
|
#{$bocchi-band} bottom / 100% 5px no-repeat,
|
||||||
|
#0f0d12;
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: calc(var(--bs-modal-header-padding-y, 1rem) + 5px);
|
||||||
|
|
||||||
|
.modal-title, .modal-title h2 {
|
||||||
|
color: #fff;
|
||||||
|
font-family: var(--bocchi-display);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
filter: invert(1) grayscale(1) brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
border-top: none;
|
||||||
|
background-image: #{$bocchi-band};
|
||||||
|
background-size: 100% 4px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bocchiina úzkost: nápis se na okamžik „rozpadne" do růžového a modrého posunu
|
||||||
|
@keyframes bocchi-glitch {
|
||||||
|
0%, 92%, 100% { text-shadow: none; transform: none; }
|
||||||
|
93% { text-shadow: -3px 0 #ff6fa0, 3px 0 #7d9cff; transform: translateX(1px) skewX(-6deg); }
|
||||||
|
94% { text-shadow: 2px 0 #ff6fa0, -2px 0 #7d9cff; transform: translateX(-2px); }
|
||||||
|
95% { text-shadow: -1px 0 #ff6fa0, 1px 0 #7d9cff; transform: translateX(1px) skewX(4deg); }
|
||||||
|
96% { text-shadow: none; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
#{$bocchi} .day-navigator .title {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#{$bocchi} .navbar .navbar-brand {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
// ============================================
|
||||||
|
// STYL "HELL GIRL" (volitelný, výchozí je jednoduchý styl)
|
||||||
|
// Inspirováno anime Jigoku Shōjo (地獄少女).
|
||||||
|
// Věčný soumrak Ai Enmy (purpurovo-rudé nebe), papír washi, karmínová rudá
|
||||||
|
// nit ze slaměné panenky, květy lilie pavoukovité (higanbana) a ostré,
|
||||||
|
// tradičně japonské tvary s písmem mincho.
|
||||||
|
// Tmavý režim = půlnoc, kdy se otevírá Pekelná korespondence (地獄通信).
|
||||||
|
// Aktivní, pokud má <html> atribut data-luncher-theme="hellgirl".
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
$hg: 'html[data-luncher-theme="hellgirl"]';
|
||||||
|
|
||||||
|
// Nebe věčného soumraku (fialová → karmínová → oranžová)
|
||||||
|
$hg-twilight: linear-gradient(90deg, #1e0a1c, #4a0f2a 35%, #8e0f24 70%, #c8452a);
|
||||||
|
// Rudá nit — tenká linka s jemnou září
|
||||||
|
$hg-thread: linear-gradient(90deg, transparent, #c8102e 12%, #e8344f 50%, #c8102e 88%, transparent);
|
||||||
|
|
||||||
|
#{$hg} {
|
||||||
|
--hg-font: 'Shippori Mincho', 'Noto Serif JP', 'Georgia', serif;
|
||||||
|
--hg-crimson: #a3122a; // kimono Ai / rudá nit
|
||||||
|
--hg-crimson-deep: #7a0c1f;
|
||||||
|
--hg-crimson-shadow: #4a0612;
|
||||||
|
--hg-higanbana: #d4203c; // lilie pavoukovitá
|
||||||
|
--hg-dusk: #c8452a; // oranžové světlo soumraku
|
||||||
|
--hg-ink: #1c1012; // černé vlasy / tuš
|
||||||
|
--hg-washi: #f4ece0; // papír washi
|
||||||
|
--hg-card-border: #dccbbb;
|
||||||
|
--hg-petal: rgb(163 18 42 / 0.10);
|
||||||
|
--hg-petal-2: rgb(200 69 42 / 0.08);
|
||||||
|
|
||||||
|
--luncher-primary: var(--hg-crimson);
|
||||||
|
--luncher-primary-hover: var(--hg-crimson-deep);
|
||||||
|
--luncher-primary-light: #f7e3e2;
|
||||||
|
--luncher-bg: var(--hg-washi);
|
||||||
|
--luncher-bg-card: #fffbf5;
|
||||||
|
--luncher-bg-hover: #f8ebe6;
|
||||||
|
--luncher-text: var(--hg-ink);
|
||||||
|
--luncher-text-secondary: #4f3a3c;
|
||||||
|
--luncher-text-muted: #8c7672;
|
||||||
|
--luncher-border: var(--hg-card-border);
|
||||||
|
--luncher-border-light: #eadfd2;
|
||||||
|
--luncher-navbar-bg: var(--hg-crimson-deep);
|
||||||
|
--luncher-navbar-text: #fff4ee;
|
||||||
|
--luncher-action-icon: var(--hg-crimson);
|
||||||
|
--luncher-buyer-icon: var(--hg-dusk);
|
||||||
|
--luncher-success: #3d7a4a;
|
||||||
|
--luncher-warning: #c07a1a;
|
||||||
|
--luncher-danger: #c8102e;
|
||||||
|
// Ostré, tradiční tvary — téměř bez zaoblení
|
||||||
|
--luncher-radius-sm: 2px;
|
||||||
|
--luncher-radius: 3px;
|
||||||
|
--luncher-radius-lg: 4px;
|
||||||
|
--luncher-radius-xl: 6px;
|
||||||
|
--luncher-shadow: 0 3px 12px -4px rgb(74 6 18 / 0.25);
|
||||||
|
--luncher-shadow-lg: 0 10px 26px -8px rgb(74 6 18 / 0.38);
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] {
|
||||||
|
// Půlnoc — Pekelná korespondence je otevřena
|
||||||
|
--hg-crimson: #e0334f;
|
||||||
|
--hg-crimson-deep: #b81c38;
|
||||||
|
--hg-crimson-shadow: #3a0510;
|
||||||
|
--hg-higanbana: #ff4d63;
|
||||||
|
--hg-dusk: #ff7a4a;
|
||||||
|
--hg-card-border: #3d1c22;
|
||||||
|
--hg-petal: rgb(224 51 79 / 0.22);
|
||||||
|
--hg-petal-2: rgb(255 122 74 / 0.12);
|
||||||
|
|
||||||
|
--luncher-primary: #ec4a63;
|
||||||
|
--luncher-primary-hover: #ff6b80;
|
||||||
|
--luncher-primary-light: #2e1015;
|
||||||
|
--luncher-bg: #0c0608;
|
||||||
|
--luncher-bg-card: #170c0f;
|
||||||
|
--luncher-bg-hover: #221216;
|
||||||
|
--luncher-text: #f3e6e2;
|
||||||
|
--luncher-text-secondary: #c9aeaa;
|
||||||
|
--luncher-text-muted: #8a6c6a;
|
||||||
|
--luncher-border: #3d1c22;
|
||||||
|
--luncher-border-light: #2a1418;
|
||||||
|
--luncher-navbar-bg: #12070a;
|
||||||
|
--luncher-action-icon: #ec4a63;
|
||||||
|
--luncher-buyer-icon: #ff9a6b;
|
||||||
|
--luncher-success: #6fbf7e;
|
||||||
|
--luncher-warning: #f0b45a;
|
||||||
|
--luncher-danger: #ff4d63;
|
||||||
|
--luncher-shadow: 0 4px 16px -4px rgb(0 0 0 / 0.7);
|
||||||
|
--luncher-shadow-lg: 0 12px 30px -8px rgb(0 0 0 / 0.8), 0 0 18px -6px rgb(224 51 79 / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--hg-font);
|
||||||
|
background-color: var(--luncher-bg);
|
||||||
|
// Rozfoukané okvětní lístky higanbany nad papírem, nahoře záře soumraku
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse 3px 1.5px at 20% 30%, var(--hg-petal) 0 100%, transparent 101%),
|
||||||
|
radial-gradient(ellipse 2px 4px at 70% 75%, var(--hg-petal-2) 0 100%, transparent 101%),
|
||||||
|
radial-gradient(ellipse 2.5px 1.2px at 85% 15%, var(--hg-petal) 0 100%, transparent 101%),
|
||||||
|
linear-gradient(180deg, #f3d9cc 0, var(--luncher-bg) 380px);
|
||||||
|
background-size: 150px 150px, 210px 210px, 260px 260px, 100% 100%;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] body {
|
||||||
|
// Půlnoční tma s rudou září pekelného ohně odspodu
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse 3px 1.5px at 20% 30%, var(--hg-petal) 0 100%, transparent 101%),
|
||||||
|
radial-gradient(ellipse 2px 4px at 70% 75%, var(--hg-petal-2) 0 100%, transparent 101%),
|
||||||
|
radial-gradient(ellipse 2.5px 1.2px at 85% 15%, var(--hg-petal) 0 100%, transparent 101%),
|
||||||
|
radial-gradient(ellipse 120% 45% at 50% 115%, rgb(140 12 36 / 0.35), transparent 70%),
|
||||||
|
linear-gradient(180deg, #1c0a12 0, var(--luncher-bg) 420px);
|
||||||
|
background-size: 150px 150px, 210px 210px, 260px 260px, 100% 100%, 100% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, .navbar-brand, .btn {
|
||||||
|
font-family: var(--hg-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Navbar: nebe soumraku s rudou nití dole ----------
|
||||||
|
.navbar {
|
||||||
|
background:
|
||||||
|
#{$hg-thread} bottom / 100% 2px no-repeat,
|
||||||
|
#{$hg-twilight} !important;
|
||||||
|
padding-bottom: calc(var(--bs-navbar-padding-y, 0.5rem) + 2px);
|
||||||
|
box-shadow: 0 3px 14px -2px rgb(74 6 18 / 0.5);
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-shadow: 0 0 12px rgb(232 52 79 / 0.6);
|
||||||
|
|
||||||
|
// Pekelná korespondence — otevírá se jen o půlnoci
|
||||||
|
&::after {
|
||||||
|
content: '地獄通信';
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 0.55em;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: #ffb3a8;
|
||||||
|
opacity: 0.85;
|
||||||
|
animation: hg-flicker 5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
border: 1px solid var(--hg-crimson);
|
||||||
|
border-radius: var(--luncher-radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .navbar {
|
||||||
|
background:
|
||||||
|
#{$hg-thread} bottom / 100% 2px no-repeat,
|
||||||
|
linear-gradient(90deg, #0a0406, #1f0810 55%, #3d0a18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Titulek dne: černá lakovaná deska s rudým lemem ----------
|
||||||
|
.day-navigator .title {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 28px 8px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: #fff4ee !important;
|
||||||
|
background: linear-gradient(180deg, #2a1216, #12070a);
|
||||||
|
border: 1px solid var(--hg-crimson);
|
||||||
|
outline: 1px solid rgb(163 18 42 / 0.35);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: var(--luncher-radius-sm);
|
||||||
|
text-shadow: 0 0 10px rgb(232 52 79 / 0.55);
|
||||||
|
box-shadow: var(--luncher-shadow);
|
||||||
|
|
||||||
|
// Květy higanbany po stranách
|
||||||
|
&::before, &::after {
|
||||||
|
content: '✿';
|
||||||
|
font-size: 0.6em;
|
||||||
|
margin: 0 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: var(--hg-higanbana);
|
||||||
|
text-shadow: 0 0 6px rgb(212 32 60 / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.text-muted {
|
||||||
|
background: linear-gradient(180deg, #6b5a5a, #4a3d3e);
|
||||||
|
border-color: #8c7672;
|
||||||
|
outline-color: rgb(140 118 114 / 0.35);
|
||||||
|
text-shadow: none;
|
||||||
|
|
||||||
|
&::before, &::after {
|
||||||
|
color: #b3a19d;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symbol ohně ze znělky místo květů (masku dodá HellGirlFlames, až se obrázek načte)
|
||||||
|
&[data-hg-symbol] .day-navigator .title {
|
||||||
|
&::before, &::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 1em;
|
||||||
|
width: 0.85em;
|
||||||
|
height: 0.85em;
|
||||||
|
margin: 0 14px;
|
||||||
|
vertical-align: -0.08em;
|
||||||
|
background: linear-gradient(180deg, var(--hg-dusk), var(--hg-higanbana) 45%, var(--hg-crimson-deep));
|
||||||
|
-webkit-mask: var(--hg-symbol) center / contain no-repeat;
|
||||||
|
mask: var(--hg-symbol) center / contain no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.text-muted::before, &.text-muted::after {
|
||||||
|
background: #b3a19d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Karty podniků ----------
|
||||||
|
.restaurant-card {
|
||||||
|
border: 1px solid var(--hg-card-border);
|
||||||
|
border-top: 3px solid var(--hg-crimson);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--hg-crimson);
|
||||||
|
box-shadow: var(--luncher-shadow-lg), 0 0 0 1px rgb(200 16 46 / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restaurant-header {
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
#{$hg-thread} bottom / 100% 2px no-repeat,
|
||||||
|
linear-gradient(90deg, #1c0a10, var(--hg-crimson-deep));
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background:
|
||||||
|
#{$hg-thread} bottom / 100% 2px no-repeat,
|
||||||
|
#{$hg-twilight};
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-shadow: 0 1px 0 rgb(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: rgb(255 244 238 / 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-table tbody tr:hover > td {
|
||||||
|
background: #f8e6e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
|
||||||
|
background: #2a1218;
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-price {
|
||||||
|
color: var(--hg-crimson);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Panely a tabulka voleb ----------
|
||||||
|
.choice-section,
|
||||||
|
.choices-table,
|
||||||
|
.pizza-section,
|
||||||
|
.no-votes {
|
||||||
|
border: 1px solid var(--hg-card-border);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
box-shadow: var(--luncher-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sloupec s podnikem: černá tuš s rudou nití na okraji
|
||||||
|
.choices-table > tbody > tr > td:first-child {
|
||||||
|
background: linear-gradient(90deg, #1c1012, #2e1418);
|
||||||
|
color: #fff4ee;
|
||||||
|
font-weight: 700;
|
||||||
|
border-right: 2px solid var(--hg-crimson);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
|
||||||
|
background: linear-gradient(90deg, #22090f, #170c0f);
|
||||||
|
color: var(--hg-higanbana);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap karty (skupinové objednávky apod.) a seznamy
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--hg-card-border);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
box-shadow: var(--luncher-shadow);
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
|
||||||
|
> .card-header {
|
||||||
|
background: var(--luncher-primary-light);
|
||||||
|
border-bottom: 2px solid var(--hg-crimson);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
border-radius: var(--luncher-radius);
|
||||||
|
border: 1px solid var(--hg-card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select, .form-control,
|
||||||
|
input[type="text"], input[type="email"], input[type="password"] {
|
||||||
|
border-radius: var(--luncher-radius-sm);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--hg-crimson);
|
||||||
|
box-shadow: 0 0 0 3px rgb(200 16 46 / 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Tlačítka: ostré, karmínová s dvojitou linkou jako pás obi ----------
|
||||||
|
.btn {
|
||||||
|
border-radius: var(--luncher-radius-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(1px) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(180deg, #c01733, var(--hg-crimson-deep));
|
||||||
|
border: 1px solid var(--hg-crimson-shadow);
|
||||||
|
color: #fff4ee;
|
||||||
|
box-shadow: inset 0 0 0 2px rgb(255 255 255 / 0.12);
|
||||||
|
|
||||||
|
// Rudá záře pekelného ohně při najetí
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(180deg, #d41d3b, #8e0f24);
|
||||||
|
box-shadow: inset 0 0 0 2px rgb(255 255 255 / 0.18), 0 0 14px rgb(212 32 60 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #b9a3a0;
|
||||||
|
border-color: #9c8582;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-primary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--hg-crimson);
|
||||||
|
color: var(--luncher-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hg-crimson);
|
||||||
|
color: #fff4ee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
border: 1px solid var(--luncher-border);
|
||||||
|
color: var(--luncher-text);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--hg-crimson);
|
||||||
|
background: var(--luncher-bg-hover);
|
||||||
|
color: var(--luncher-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(180deg, #e0243f, #8e0f24);
|
||||||
|
border: 1px solid var(--hg-crimson-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon:hover {
|
||||||
|
background: var(--luncher-primary-light);
|
||||||
|
color: var(--hg-higanbana);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Modaly ----------
|
||||||
|
.modal-content {
|
||||||
|
border: 1px solid var(--hg-crimson);
|
||||||
|
border-radius: var(--luncher-radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
background:
|
||||||
|
#{$hg-thread} bottom / 100% 2px no-repeat,
|
||||||
|
#{$hg-twilight};
|
||||||
|
border-bottom: none;
|
||||||
|
|
||||||
|
.modal-title, .modal-title h2 {
|
||||||
|
color: #fff4ee;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-shadow: 0 0 10px rgb(232 52 79 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
filter: invert(1) grayscale(1) brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
border-top: none;
|
||||||
|
background-image: #{$hg-thread};
|
||||||
|
background-size: 100% 1px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mihotání nápisu jako plamen svíčky
|
||||||
|
@keyframes hg-flicker {
|
||||||
|
0%, 100% { opacity: 0.85; }
|
||||||
|
45% { opacity: 0.85; }
|
||||||
|
48% { opacity: 0.35; }
|
||||||
|
50% { opacity: 0.9; }
|
||||||
|
53% { opacity: 0.5; }
|
||||||
|
56% { opacity: 0.85; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
#{$hg} .navbar .navbar-brand::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#{$hg} .navbar .navbar-brand::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
// ============================================
|
||||||
|
// STYL "K-ON!" (volitelný, výchozí je jednoduchý styl)
|
||||||
|
// Inspirováno anime K-ON! (けいおん!) — klub lehké hudby na dívčí škole Sakuragaoka.
|
||||||
|
// Tmavě modré školní sako, kytara Gitah v barvě cherry sunburst, mléčný čaj
|
||||||
|
// a dortíky z odpoledních dýchánků (Houkago Tea Time), notová osnova
|
||||||
|
// a ručně psané nápisy jako ze školního sešitu.
|
||||||
|
// Tmavý režim = večerní koncert v klubu (live house) pod reflektory.
|
||||||
|
// Aktivní, pokud má <html> atribut data-luncher-theme="kon".
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
$kon: 'html[data-luncher-theme="kon"]';
|
||||||
|
|
||||||
|
// Pruh v barvách členek Houkago Tea Time: Yui (červená Gitah), Mio (modrá),
|
||||||
|
// Ritsu (žlutá), Mugi (zlatavá) a Azusa (fialová)
|
||||||
|
$kon-members: linear-gradient(90deg, #d43f62 0 20%, #3d6fc4 20% 40%, #f2b81d 40% 60%, #e8cf7a 60% 80%, #8a6bc7 80% 100%);
|
||||||
|
// Notová osnova (pět linek) — opakuje se na pozadí po 220 px
|
||||||
|
$kon-staff-stops: transparent 0 100px,
|
||||||
|
var(--kon-staff) 100px 101px, transparent 101px 109px,
|
||||||
|
var(--kon-staff) 109px 110px, transparent 110px 118px,
|
||||||
|
var(--kon-staff) 118px 119px, transparent 119px 127px,
|
||||||
|
var(--kon-staff) 127px 128px, transparent 128px 136px,
|
||||||
|
var(--kon-staff) 136px 137px, transparent 137px;
|
||||||
|
// Rozeseté noty ♪ ♫ (ve dne červená a modrá, v noci světlejší)
|
||||||
|
$kon-notes: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Ctext x='34' y='95' font-size='26' fill='%23d43f62' fill-opacity='0.16'%3E%E2%99%AA%3C/text%3E%3Ctext x='150' y='128' font-size='30' fill='%233d6fc4' fill-opacity='0.14'%3E%E2%99%AB%3C/text%3E%3Ctext x='96' y='210' font-size='18' fill='%23e8a10f' fill-opacity='0.18'%3E%E2%99%AA%3C/text%3E%3C/svg%3E");
|
||||||
|
$kon-notes-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Ctext x='34' y='95' font-size='26' fill='%23ff8fa8' fill-opacity='0.22'%3E%E2%99%AA%3C/text%3E%3Ctext x='150' y='128' font-size='30' fill='%238fb2ff' fill-opacity='0.2'%3E%E2%99%AB%3C/text%3E%3Ctext x='96' y='210' font-size='18' fill='%23ffd166' fill-opacity='0.22'%3E%E2%99%AA%3C/text%3E%3C/svg%3E");
|
||||||
|
|
||||||
|
#{$kon} {
|
||||||
|
--kon-font: 'Zen Maru Gothic', 'M PLUS Rounded 1c', 'Nunito', -apple-system, sans-serif;
|
||||||
|
--kon-hand: 'Yusei Magic', 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
|
||||||
|
--kon-blazer: #27315f; // tmavě modré sako Sakuragaoka
|
||||||
|
--kon-blazer-light: #3a4782;
|
||||||
|
--kon-blazer-shadow: #171d3d;
|
||||||
|
--kon-cherry: #c43358; // Gitah — cherry sunburst
|
||||||
|
--kon-cherry-deep: #a3234a;
|
||||||
|
--kon-sunburst: #f2a33a; // jantarový střed sunburstu
|
||||||
|
--kon-ritsu: #f2b81d;
|
||||||
|
--kon-tea: #fbf3e4; // mléčný čaj
|
||||||
|
--kon-paper: #fffaf0; // notový sešit
|
||||||
|
--kon-card-border: #e6d8c0;
|
||||||
|
--kon-staff: rgb(39 49 95 / 0.07);
|
||||||
|
|
||||||
|
--luncher-primary: var(--kon-cherry);
|
||||||
|
--luncher-primary-hover: var(--kon-cherry-deep);
|
||||||
|
--luncher-primary-light: #fbe4ea;
|
||||||
|
--luncher-bg: var(--kon-tea);
|
||||||
|
--luncher-bg-card: var(--kon-paper);
|
||||||
|
--luncher-bg-hover: #f8ecd8;
|
||||||
|
--luncher-text: #222849;
|
||||||
|
--luncher-text-secondary: #4a5179;
|
||||||
|
--luncher-text-muted: #8a8aa3;
|
||||||
|
--luncher-border: var(--kon-card-border);
|
||||||
|
--luncher-border-light: #f0e6d3;
|
||||||
|
--luncher-navbar-bg: var(--kon-blazer);
|
||||||
|
--luncher-navbar-text: #ffffff;
|
||||||
|
--luncher-action-icon: var(--kon-cherry);
|
||||||
|
--luncher-buyer-icon: #e8a10f;
|
||||||
|
--luncher-success: #2f8a57;
|
||||||
|
--luncher-warning: #d98a0b;
|
||||||
|
--luncher-danger: #d0304a;
|
||||||
|
--luncher-radius-sm: 10px;
|
||||||
|
--luncher-radius: 14px;
|
||||||
|
--luncher-radius-lg: 18px;
|
||||||
|
--luncher-radius-xl: 22px;
|
||||||
|
--luncher-shadow: 0 4px 14px -6px rgb(39 49 95 / 0.25);
|
||||||
|
--luncher-shadow-lg: 0 10px 26px -8px rgb(39 49 95 / 0.35);
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] {
|
||||||
|
// Večerní koncert v klubu — reflektory nad pódiem
|
||||||
|
--kon-blazer: #1a2150;
|
||||||
|
--kon-blazer-light: #2b367a;
|
||||||
|
--kon-blazer-shadow: #0a0e26;
|
||||||
|
--kon-cherry: #ff7a9a;
|
||||||
|
--kon-cherry-deep: #e85a7e;
|
||||||
|
--kon-ritsu: #ffd166;
|
||||||
|
--kon-card-border: #2c3566;
|
||||||
|
--kon-staff: rgb(180 196 255 / 0.06);
|
||||||
|
|
||||||
|
--luncher-primary: #ff86a3;
|
||||||
|
--luncher-primary-hover: #ffa8bd;
|
||||||
|
--luncher-primary-light: #3a1d35;
|
||||||
|
--luncher-bg: #0e1230;
|
||||||
|
--luncher-bg-card: #171c42;
|
||||||
|
--luncher-bg-hover: #212858;
|
||||||
|
--luncher-text: #f1f0ff;
|
||||||
|
--luncher-text-secondary: #bfc2e6;
|
||||||
|
--luncher-text-muted: #8085b3;
|
||||||
|
--luncher-border: #2c3566;
|
||||||
|
--luncher-border-light: #232a58;
|
||||||
|
--luncher-navbar-bg: #141a44;
|
||||||
|
--luncher-action-icon: #ff86a3;
|
||||||
|
--luncher-buyer-icon: #ffd166;
|
||||||
|
--luncher-success: #6fd59a;
|
||||||
|
--luncher-warning: #ffd166;
|
||||||
|
--luncher-danger: #ff6f86;
|
||||||
|
--luncher-shadow: 0 4px 16px -4px rgb(0 0 0 / 0.55);
|
||||||
|
--luncher-shadow-lg: 0 12px 30px -8px rgb(0 0 0 / 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--kon-font);
|
||||||
|
background-color: var(--luncher-bg);
|
||||||
|
// Notový sešit: osnova s notami, nahoře teplé odpolední světlo klubovny
|
||||||
|
background-image:
|
||||||
|
#{$kon-notes},
|
||||||
|
linear-gradient(180deg, #{$kon-staff-stops}),
|
||||||
|
linear-gradient(180deg, #fde4d6 0, var(--luncher-bg) 360px);
|
||||||
|
background-size: 220px 220px, 100% 220px, 100% 100%;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] body {
|
||||||
|
// Dva reflektory (růžový a jantarový) svítí z horních rohů na pódium
|
||||||
|
background-image:
|
||||||
|
#{$kon-notes-dark},
|
||||||
|
linear-gradient(180deg, #{$kon-staff-stops}),
|
||||||
|
radial-gradient(ellipse 45% 60% at 10% -10%, rgb(255 122 154 / 0.18), transparent 70%),
|
||||||
|
radial-gradient(ellipse 45% 60% at 90% -10%, rgb(255 209 102 / 0.14), transparent 70%),
|
||||||
|
linear-gradient(180deg, #1a1f4d 0, var(--luncher-bg) 420px);
|
||||||
|
background-size: 220px 220px, 100% 220px, 100% 100%, 100% 100%, 100% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, .navbar-brand, .btn {
|
||||||
|
font-family: var(--kon-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Navbar: školní sako s pruhem v barvách kapely ----------
|
||||||
|
.navbar {
|
||||||
|
background:
|
||||||
|
#{$kon-members} bottom / 100% 4px no-repeat,
|
||||||
|
linear-gradient(90deg, var(--kon-blazer-shadow), var(--kon-blazer) 40%, var(--kon-blazer-light)) !important;
|
||||||
|
padding-bottom: calc(var(--bs-navbar-padding-y, 0.5rem) + 4px);
|
||||||
|
box-shadow: 0 3px 10px -2px rgb(23 29 61 / 0.4);
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-shadow: 0 2px 0 var(--kon-blazer-shadow);
|
||||||
|
|
||||||
|
// Nota, která poskakuje do rytmu
|
||||||
|
&::after {
|
||||||
|
content: '♪';
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
color: var(--kon-ritsu);
|
||||||
|
font-size: 0.85em;
|
||||||
|
vertical-align: super;
|
||||||
|
animation: kon-bounce 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
border: 2px solid var(--kon-card-border);
|
||||||
|
border-radius: var(--luncher-radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Titulek dne: ručně psaná cedulka z dveří klubovny ----------
|
||||||
|
.day-navigator .title {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 26px 6px;
|
||||||
|
font-family: var(--kon-hand);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: var(--kon-blazer) !important;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(180deg, transparent 0 11px, rgb(61 111 196 / 0.12) 11px 12px),
|
||||||
|
var(--kon-paper);
|
||||||
|
border: 3px solid var(--kon-blazer);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 4px 4px 0 var(--kon-cherry);
|
||||||
|
transform: rotate(-1.5deg);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '♪';
|
||||||
|
margin-right: 10px;
|
||||||
|
color: var(--kon-cherry);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '♫';
|
||||||
|
margin-left: 10px;
|
||||||
|
color: #3d6fc4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.text-muted {
|
||||||
|
color: #8a8aa3 !important;
|
||||||
|
border-color: #a9a9be;
|
||||||
|
box-shadow: 4px 4px 0 #cfcfdc;
|
||||||
|
|
||||||
|
&::before, &::after {
|
||||||
|
color: #a9a9be;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .day-navigator .title {
|
||||||
|
color: #f1f0ff !important;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(180deg, transparent 0 11px, rgb(143 178 255 / 0.12) 11px 12px),
|
||||||
|
var(--luncher-bg-card);
|
||||||
|
border-color: #8fb2ff;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
color: #8fb2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.text-muted {
|
||||||
|
color: #8085b3 !important;
|
||||||
|
border-color: #4a5288;
|
||||||
|
box-shadow: 4px 4px 0 #2c3566;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Karty podniků ----------
|
||||||
|
.restaurant-card {
|
||||||
|
border: 2px solid var(--kon-card-border);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-3px) rotate(-0.3deg);
|
||||||
|
border-color: var(--kon-cherry);
|
||||||
|
box-shadow: var(--luncher-shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restaurant-header {
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
#{$kon-members} bottom / 100% 4px no-repeat,
|
||||||
|
linear-gradient(90deg, var(--kon-blazer), var(--kon-blazer-light));
|
||||||
|
|
||||||
|
// Gitah: jantarový střed přechází do třešňové
|
||||||
|
&:hover {
|
||||||
|
background:
|
||||||
|
#{$kon-members} bottom / 100% 4px no-repeat,
|
||||||
|
radial-gradient(ellipse 80% 160% at 30% 50%, var(--kon-sunburst), var(--kon-cherry) 55%, #6e1530);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-weight: 900;
|
||||||
|
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: rgb(255 255 255 / 0.88);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-table tbody tr:hover > td {
|
||||||
|
background: #fdeee0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
|
||||||
|
background: #232a5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-price {
|
||||||
|
color: var(--kon-cherry);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Panely a tabulka voleb ----------
|
||||||
|
.choice-section,
|
||||||
|
.choices-table,
|
||||||
|
.pizza-section,
|
||||||
|
.no-votes {
|
||||||
|
border: 2px solid var(--kon-card-border);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
box-shadow: var(--luncher-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// <table> s border-collapse: collapse ignoruje border-radius — kulaté rohy jen se „separate"
|
||||||
|
.choices-table {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sloupec s podnikem: linkovaný papír se svislou okrajovou linkou jako v sešitě
|
||||||
|
.choices-table > tbody > tr > td:first-child {
|
||||||
|
background: #fdf1dc;
|
||||||
|
color: var(--kon-blazer);
|
||||||
|
font-weight: 700;
|
||||||
|
border-right: 2px solid rgb(196 51 88 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
|
||||||
|
background: #1f2552;
|
||||||
|
color: #c9d6ff;
|
||||||
|
border-right-color: rgb(255 134 163 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap karty (skupinové objednávky apod.) a seznamy — stejné zaoblení a lem jako ostatní panely
|
||||||
|
.card {
|
||||||
|
border: 2px solid var(--kon-card-border);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
box-shadow: var(--luncher-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
|
||||||
|
// Rozbalovací výběr z nabídky nesmí karta oříznout
|
||||||
|
&:has(.select-search-container) {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .card-header {
|
||||||
|
background: var(--luncher-primary-light);
|
||||||
|
border-bottom: 2px solid var(--kon-card-border);
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-radius: calc(var(--luncher-radius-lg) - 2px) calc(var(--luncher-radius-lg) - 2px) 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Průhledná tabulka — rohy pak zaobluje karta sama (i když nesmí ořezávat obsah)
|
||||||
|
.table {
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
// Spodní linka posledního řádku by přečnívala přes zaoblené rohy
|
||||||
|
> :last-child > tr:last-child > * {
|
||||||
|
border-bottom-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
border-radius: var(--luncher-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid var(--kon-card-border);
|
||||||
|
|
||||||
|
> .list-group-item {
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select, .form-control,
|
||||||
|
input[type="text"], input[type="email"], input[type="password"] {
|
||||||
|
border-radius: var(--luncher-radius-sm);
|
||||||
|
border-width: 2px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--kon-cherry);
|
||||||
|
box-shadow: 0 0 0 4px rgb(196 51 88 / 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Tlačítka: zaoblené jako pedály efektů, s tmavě modrým „podstavcem" ----------
|
||||||
|
.btn {
|
||||||
|
border-radius: var(--luncher-radius-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(3px) !important;
|
||||||
|
box-shadow: 0 1px 0 var(--kon-btn-shadow, transparent) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
--kon-btn-shadow: var(--kon-blazer-shadow);
|
||||||
|
background: linear-gradient(180deg, #e0567a, var(--kon-cherry-deep));
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 0 var(--kon-blazer);
|
||||||
|
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
|
||||||
|
|
||||||
|
// Sunburst při najetí
|
||||||
|
&:hover {
|
||||||
|
background: radial-gradient(ellipse 90% 140% at 50% 40%, var(--kon-sunburst), var(--kon-cherry) 60%, var(--kon-cherry-deep));
|
||||||
|
box-shadow: 0 4px 0 var(--kon-blazer), 0 0 12px rgb(242 163 58 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #c8c3cf;
|
||||||
|
box-shadow: 0 4px 0 #a5a1b0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-primary {
|
||||||
|
--kon-btn-shadow: var(--kon-card-border);
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
border: 2px solid var(--kon-blazer);
|
||||||
|
color: var(--kon-blazer);
|
||||||
|
box-shadow: 0 4px 0 var(--kon-card-border);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--kon-blazer);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .btn-outline-primary {
|
||||||
|
border-color: #8fb2ff;
|
||||||
|
color: #c9d6ff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #2b367a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
--kon-btn-shadow: var(--luncher-border);
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
border: 2px solid var(--luncher-border);
|
||||||
|
color: var(--luncher-text);
|
||||||
|
box-shadow: 0 4px 0 var(--luncher-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
--kon-btn-shadow: #8e1d33;
|
||||||
|
background: linear-gradient(180deg, #ef5a74, var(--luncher-danger));
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 0 #8e1d33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon:hover {
|
||||||
|
background: var(--luncher-primary-light);
|
||||||
|
color: var(--kon-cherry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Modaly ----------
|
||||||
|
.modal-content {
|
||||||
|
border: 2px solid var(--kon-card-border);
|
||||||
|
border-radius: var(--luncher-radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
background:
|
||||||
|
#{$kon-members} bottom / 100% 4px no-repeat,
|
||||||
|
linear-gradient(90deg, var(--kon-blazer-shadow), var(--kon-blazer) 40%, var(--kon-blazer-light));
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: calc(var(--bs-modal-header-padding-y, 1rem) + 4px);
|
||||||
|
|
||||||
|
.modal-title, .modal-title h2 {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 900;
|
||||||
|
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
filter: invert(1) grayscale(1) brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
border-top: none;
|
||||||
|
background-image: #{$kon-members};
|
||||||
|
background-size: 100% 3px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes kon-bounce {
|
||||||
|
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||||
|
25% { transform: translateY(-4px) rotate(-12deg); }
|
||||||
|
50% { transform: translateY(0) rotate(0deg); }
|
||||||
|
75% { transform: translateY(-2px) rotate(10deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#{$kon} .navbar .navbar-brand::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
// ============================================
|
||||||
|
// STYL "MY LITTLE PONY" (volitelný, výchozí je jednoduchý styl)
|
||||||
|
// Inspirováno 4. generací — My Little Pony: Friendship is Magic.
|
||||||
|
// Fialová Twilight Sparkle, růžová Pinkie Pie, pastelová obloha Ponyville,
|
||||||
|
// duhový pruh Rainbow Dash (Sonic Rainboom) a hvězdička z cutie marku Twilight.
|
||||||
|
// Tmavý režim = noční obloha princezny Luny.
|
||||||
|
// Aktivní, pokud má <html> atribut data-luncher-theme="mlp".
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
$mlp: 'html[data-luncher-theme="mlp"]';
|
||||||
|
|
||||||
|
// Duha Rainbow Dash (červená, oranžová, žlutá, zelená, modrá, fialová)
|
||||||
|
$mlp-rainbow: linear-gradient(90deg, #ee4144 0 16.6%, #f37033 16.6% 33.3%, #fdf6af 33.3% 50%, #62bc4d 50% 66.6%, #1e98d3 66.6% 83.3%, #672f89 83.3% 100%);
|
||||||
|
$mlp-rainbow-soft: linear-gradient(90deg, #ee4144, #f37033, #fdf6af, #62bc4d, #1e98d3, #672f89);
|
||||||
|
|
||||||
|
#{$mlp} {
|
||||||
|
--mlp-font: 'Fredoka', 'Nunito', 'Inter', -apple-system, sans-serif;
|
||||||
|
--mlp-twilight: #8e4fd1; // srst Twilight (sytější kvůli kontrastu)
|
||||||
|
--mlp-twilight-deep: #6a35b0;
|
||||||
|
--mlp-twilight-shadow: #4b2287;
|
||||||
|
--mlp-twilight-light: #d9b8f5;
|
||||||
|
--mlp-pinkie: #f06aa6; // Pinkie Pie
|
||||||
|
--mlp-pinkie-deep: #d6417f;
|
||||||
|
--mlp-star: #ec3d8f; // cutie mark Twilight
|
||||||
|
--mlp-fluttershy: #fff3b0; // Fluttershy
|
||||||
|
--mlp-sky: #5ec8f2; // Rainbow Dash
|
||||||
|
--mlp-card-border: #e5d4f7;
|
||||||
|
--mlp-sparkle: rgb(142 79 209 / 0.10);
|
||||||
|
--mlp-sparkle-2: rgb(240 106 166 / 0.10);
|
||||||
|
|
||||||
|
--luncher-primary: var(--mlp-twilight-deep);
|
||||||
|
--luncher-primary-hover: var(--mlp-twilight-shadow);
|
||||||
|
--luncher-primary-light: #f1e6fc;
|
||||||
|
--luncher-bg: #faf5ff;
|
||||||
|
--luncher-bg-card: #ffffff;
|
||||||
|
--luncher-bg-hover: #f6edff;
|
||||||
|
--luncher-text: #2e1f47;
|
||||||
|
--luncher-text-secondary: #5d4a7a;
|
||||||
|
--luncher-text-muted: #9a8ab3;
|
||||||
|
--luncher-border: #e5d4f7;
|
||||||
|
--luncher-border-light: #f0e6fb;
|
||||||
|
--luncher-navbar-bg: var(--mlp-twilight-deep);
|
||||||
|
--luncher-navbar-text: #ffffff;
|
||||||
|
--luncher-action-icon: var(--mlp-twilight-deep);
|
||||||
|
--luncher-buyer-icon: #f37033;
|
||||||
|
--luncher-success: #3f9d3a;
|
||||||
|
--luncher-warning: #e8891c;
|
||||||
|
--luncher-danger: #e0395c;
|
||||||
|
--luncher-radius-sm: 12px;
|
||||||
|
--luncher-radius: 16px;
|
||||||
|
--luncher-radius-lg: 22px;
|
||||||
|
--luncher-radius-xl: 26px;
|
||||||
|
--luncher-shadow: 0 4px 14px -4px rgb(106 53 176 / 0.22);
|
||||||
|
--luncher-shadow-lg: 0 10px 28px -8px rgb(106 53 176 / 0.32);
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] {
|
||||||
|
// Noční obloha princezny Luny
|
||||||
|
--mlp-twilight: #b78af0;
|
||||||
|
--mlp-twilight-deep: #9b6be0;
|
||||||
|
--mlp-twilight-shadow: #3d2380;
|
||||||
|
--mlp-twilight-light: #3a2b6e;
|
||||||
|
--mlp-pinkie: #ff8cc0;
|
||||||
|
--mlp-pinkie-deep: #f06aa6;
|
||||||
|
--mlp-card-border: #362c6e;
|
||||||
|
--mlp-sparkle: rgb(255 255 255 / 0.55);
|
||||||
|
--mlp-sparkle-2: rgb(183 198 255 / 0.35);
|
||||||
|
|
||||||
|
--luncher-primary: #c4a0f7;
|
||||||
|
--luncher-primary-hover: #d9c0fb;
|
||||||
|
--luncher-primary-light: #2b2160;
|
||||||
|
--luncher-bg: #120e2e;
|
||||||
|
--luncher-bg-card: #1d1745;
|
||||||
|
--luncher-bg-hover: #282058;
|
||||||
|
--luncher-text: #f2edff;
|
||||||
|
--luncher-text-secondary: #c2b6e6;
|
||||||
|
--luncher-text-muted: #8a7fb5;
|
||||||
|
--luncher-border: #362c6e;
|
||||||
|
--luncher-border-light: #2b2360;
|
||||||
|
--luncher-navbar-bg: #2a1f66;
|
||||||
|
--luncher-action-icon: #c4a0f7;
|
||||||
|
--luncher-buyer-icon: #ffb36b;
|
||||||
|
--luncher-success: #7fd67a;
|
||||||
|
--luncher-warning: #ffcf5c;
|
||||||
|
--luncher-danger: #ff6f8f;
|
||||||
|
--luncher-shadow: 0 4px 16px -4px rgb(0 0 0 / 0.55);
|
||||||
|
--luncher-shadow-lg: 0 12px 30px -8px rgb(0 0 0 / 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--mlp-font);
|
||||||
|
background-color: var(--luncher-bg);
|
||||||
|
// Rozeseté jiskřičky (ve dne pastelové, v noci hvězdy)
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 20% 30%, var(--mlp-sparkle) 0 2px, transparent 3px),
|
||||||
|
radial-gradient(circle at 70% 80%, var(--mlp-sparkle-2) 0 2px, transparent 3px),
|
||||||
|
radial-gradient(circle at 85% 15%, var(--mlp-sparkle) 0 1.5px, transparent 2.5px),
|
||||||
|
linear-gradient(180deg, #fde9f4 0, var(--luncher-bg) 360px);
|
||||||
|
background-size: 140px 140px, 190px 190px, 230px 230px, 100% 100%;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] body {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 20% 30%, var(--mlp-sparkle) 0 1.5px, transparent 2.5px),
|
||||||
|
radial-gradient(circle at 70% 80%, var(--mlp-sparkle-2) 0 1px, transparent 2px),
|
||||||
|
radial-gradient(circle at 85% 15%, var(--mlp-sparkle) 0 1px, transparent 2px),
|
||||||
|
linear-gradient(180deg, #221a55 0, var(--luncher-bg) 420px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, .navbar-brand, .btn {
|
||||||
|
font-family: var(--mlp-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Navbar: fialová Twilight s duhovým pruhem dole ----------
|
||||||
|
.navbar {
|
||||||
|
background:
|
||||||
|
#{$mlp-rainbow} bottom / 100% 5px no-repeat,
|
||||||
|
linear-gradient(90deg, var(--mlp-twilight-deep), var(--mlp-twilight) 60%, var(--mlp-pinkie)) !important;
|
||||||
|
padding-bottom: calc(var(--bs-navbar-padding-y, 0.5rem) + 5px);
|
||||||
|
box-shadow: 0 3px 10px -2px rgb(106 53 176 / 0.35);
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
text-shadow: 0 2px 0 var(--mlp-twilight-shadow), 0 0 14px rgb(255 255 255 / 0.3);
|
||||||
|
|
||||||
|
// Hvězdička z cutie marku Twilight Sparkle
|
||||||
|
&::after {
|
||||||
|
content: '✦';
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
color: #ffd1ea;
|
||||||
|
font-size: 0.8em;
|
||||||
|
vertical-align: super;
|
||||||
|
text-shadow: 0 0 8px var(--mlp-star), 0 0 2px #fff;
|
||||||
|
animation: mlp-twinkle 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
border: 2px solid var(--mlp-card-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .navbar {
|
||||||
|
background:
|
||||||
|
#{$mlp-rainbow} bottom / 100% 5px no-repeat,
|
||||||
|
linear-gradient(90deg, #1f1650, #2f2276 60%, #3d2d8a) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Titulek dne: pilulka s duhovou linkou a jiskřičkami ----------
|
||||||
|
.day-navigator .title {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 30px 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
color: #fff !important;
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
#{$mlp-rainbow-soft} bottom / 100% 5px no-repeat,
|
||||||
|
linear-gradient(90deg, var(--mlp-twilight-deep), var(--mlp-pinkie));
|
||||||
|
text-shadow: 0 2px 0 rgb(0 0 0 / 0.15);
|
||||||
|
box-shadow: 0 4px 0 var(--mlp-twilight-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before, &::after {
|
||||||
|
content: '✨';
|
||||||
|
font-size: 0.7em;
|
||||||
|
margin: 0 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.text-muted {
|
||||||
|
background: linear-gradient(90deg, #a597bd, #c3b8d6);
|
||||||
|
box-shadow: 0 4px 0 #8a7ca3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Karty podniků ----------
|
||||||
|
.restaurant-card {
|
||||||
|
border: 3px solid var(--mlp-card-border);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
border-color: var(--mlp-twilight-light);
|
||||||
|
box-shadow: var(--luncher-shadow-lg), 0 0 0 4px rgb(240 106 166 / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restaurant-header {
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
#{$mlp-rainbow} bottom / 100% 4px no-repeat,
|
||||||
|
linear-gradient(90deg, var(--mlp-twilight-deep), var(--mlp-twilight));
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background:
|
||||||
|
#{$mlp-rainbow} bottom / 100% 4px no-repeat,
|
||||||
|
linear-gradient(90deg, var(--mlp-pinkie-deep), var(--mlp-pinkie));
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-weight: 700;
|
||||||
|
text-shadow: 0 2px 0 rgb(0 0 0 / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: rgb(255 255 255 / 0.88);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-table tbody tr:hover > td {
|
||||||
|
background: #fff0f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
|
||||||
|
background: #2e2160;
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-price {
|
||||||
|
color: var(--mlp-pinkie-deep);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .food-price {
|
||||||
|
color: var(--mlp-pinkie);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Panely a tabulka voleb ----------
|
||||||
|
.choice-section,
|
||||||
|
.choices-table,
|
||||||
|
.pizza-section,
|
||||||
|
.no-votes {
|
||||||
|
border: 3px solid var(--mlp-card-border);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
box-shadow: var(--luncher-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// <table> s border-collapse: collapse ignoruje border-radius — kulaté rohy jen se „separate"
|
||||||
|
.choices-table {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sloupec s podnikem: jemná žlutá Fluttershy přecházející do růžové
|
||||||
|
.choices-table > tbody > tr > td:first-child {
|
||||||
|
background: linear-gradient(90deg, var(--mlp-fluttershy), #ffe6f2);
|
||||||
|
color: var(--mlp-twilight-deep);
|
||||||
|
font-weight: 700;
|
||||||
|
border-right: 3px solid var(--mlp-card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
|
||||||
|
background: linear-gradient(90deg, #2f2468, #241c55);
|
||||||
|
color: var(--mlp-twilight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap karty (skupinové objednávky apod.) a seznamy — stejné zaoblení a lem jako ostatní panely
|
||||||
|
.card {
|
||||||
|
border: 3px solid var(--mlp-card-border);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
box-shadow: var(--luncher-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
|
||||||
|
// Rozbalovací výběr z nabídky nesmí karta oříznout
|
||||||
|
&:has(.select-search-container) {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .card-header {
|
||||||
|
background: var(--luncher-primary-light);
|
||||||
|
border-bottom: 3px solid var(--mlp-card-border);
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-radius: calc(var(--luncher-radius-lg) - 3px) calc(var(--luncher-radius-lg) - 3px) 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Průhledná tabulka — rohy pak zaobluje karta sama (i když nesmí ořezávat obsah)
|
||||||
|
.table {
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
// Spodní linka posledního řádku by přečnívala přes zaoblené rohy
|
||||||
|
> :last-child > tr:last-child > * {
|
||||||
|
border-bottom-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
border-radius: var(--luncher-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid var(--mlp-card-border);
|
||||||
|
|
||||||
|
> .list-group-item {
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select, .form-control,
|
||||||
|
input[type="text"], input[type="email"], input[type="password"] {
|
||||||
|
border-radius: 999px;
|
||||||
|
border-width: 2px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--mlp-twilight);
|
||||||
|
box-shadow: 0 0 0 4px rgb(240 106 166 / 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Tlačítka: kulaté „bonbony" s magickou září ----------
|
||||||
|
.btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.2s ease, filter 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(3px) !important;
|
||||||
|
box-shadow: 0 1px 0 var(--mlp-btn-shadow, transparent) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
--mlp-btn-shadow: var(--mlp-twilight-shadow);
|
||||||
|
background: linear-gradient(180deg, #b07ae6, var(--mlp-twilight-deep));
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 0 var(--mlp-twilight-shadow);
|
||||||
|
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
|
||||||
|
|
||||||
|
// Magická aura Twilight při najetí
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(180deg, #bf8cf0, #7a42c4);
|
||||||
|
box-shadow: 0 4px 0 var(--mlp-twilight-shadow), 0 0 14px rgb(236 61 143 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #c9bdd9;
|
||||||
|
box-shadow: 0 4px 0 #a99cbc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-primary {
|
||||||
|
--mlp-btn-shadow: var(--mlp-card-border);
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
border: 2px solid var(--mlp-twilight);
|
||||||
|
color: var(--luncher-primary);
|
||||||
|
box-shadow: 0 4px 0 var(--mlp-card-border);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--luncher-primary-light);
|
||||||
|
color: var(--luncher-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
--mlp-btn-shadow: var(--luncher-border);
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
border: 2px solid var(--luncher-border);
|
||||||
|
color: var(--luncher-text);
|
||||||
|
box-shadow: 0 4px 0 var(--luncher-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
--mlp-btn-shadow: #a8264a;
|
||||||
|
background: linear-gradient(180deg, #ff7e9a, var(--luncher-danger));
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 0 #a8264a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon:hover {
|
||||||
|
background: #ffe3f0;
|
||||||
|
color: var(--mlp-pinkie-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .action-icon:hover {
|
||||||
|
background: #3a2462;
|
||||||
|
color: var(--mlp-pinkie);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Modaly ----------
|
||||||
|
.modal-content {
|
||||||
|
border: 3px solid var(--mlp-card-border);
|
||||||
|
border-radius: var(--luncher-radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
background:
|
||||||
|
#{$mlp-rainbow} bottom / 100% 5px no-repeat,
|
||||||
|
linear-gradient(90deg, var(--mlp-twilight-deep), var(--mlp-twilight) 60%, var(--mlp-pinkie));
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: calc(var(--bs-modal-header-padding-y, 1rem) + 5px);
|
||||||
|
|
||||||
|
.modal-title, .modal-title h2 {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-shadow: 0 2px 0 rgb(0 0 0 / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
filter: invert(1) grayscale(1) brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
border-top: none;
|
||||||
|
background-image: #{$mlp-rainbow};
|
||||||
|
background-size: 100% 5px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mlp-twinkle {
|
||||||
|
0%, 100% { transform: scale(1) rotate(0deg); opacity: 1; }
|
||||||
|
50% { transform: scale(1.35) rotate(20deg); opacity: 0.75; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#{$mlp} .navbar .navbar-brand::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
// ============================================
|
||||||
|
// STYL "UMAMUSUME" (volitelný, výchozí je jednoduchý styl)
|
||||||
|
// Inspirováno Umamusume: Pretty Derby — trávník dostihové dráhy, růžové
|
||||||
|
// a nebesky modré akcenty, zlaté lemy, zaoblené tučné písmo a „tlačítka z her".
|
||||||
|
// Aktivní, pokud má <html> atribut data-luncher-theme="umamusume".
|
||||||
|
// Jednoduchý styl (data-luncher-theme="simple") zůstává beze změny v App.scss.
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
$uma: 'html[data-luncher-theme="umamusume"]';
|
||||||
|
|
||||||
|
#{$uma} {
|
||||||
|
--uma-font: 'M PLUS Rounded 1c', 'Nunito', 'Inter', -apple-system, sans-serif;
|
||||||
|
--uma-turf: #5cc23d;
|
||||||
|
--uma-turf-deep: #2e9a3a;
|
||||||
|
--uma-turf-shadow: #1f7a2c;
|
||||||
|
--uma-pink: #ff6fa3;
|
||||||
|
--uma-pink-deep: #e84d86;
|
||||||
|
--uma-sky: #43b8f0;
|
||||||
|
--uma-gold: #ffc531;
|
||||||
|
--uma-card-border: #c6ebb2;
|
||||||
|
--uma-stripe: rgb(92 194 61 / 0.06);
|
||||||
|
|
||||||
|
--luncher-primary: var(--uma-turf-deep);
|
||||||
|
--luncher-primary-hover: #268a31;
|
||||||
|
--luncher-primary-light: #e7f8dc;
|
||||||
|
--luncher-bg: #f4fbef;
|
||||||
|
--luncher-bg-card: #ffffff;
|
||||||
|
--luncher-bg-hover: #eef8e7;
|
||||||
|
--luncher-text: #1d3322;
|
||||||
|
--luncher-text-secondary: #4a6450;
|
||||||
|
--luncher-text-muted: #8aa290;
|
||||||
|
--luncher-border: #d7ecca;
|
||||||
|
--luncher-border-light: #e6f4dc;
|
||||||
|
--luncher-navbar-bg: var(--uma-turf-deep);
|
||||||
|
--luncher-navbar-text: #ffffff;
|
||||||
|
--luncher-action-icon: var(--uma-turf-deep);
|
||||||
|
--luncher-buyer-icon: #f08c00;
|
||||||
|
--luncher-success: var(--uma-turf-deep);
|
||||||
|
--luncher-warning: #f08c00;
|
||||||
|
--luncher-danger: #e8436a;
|
||||||
|
--luncher-radius-sm: 10px;
|
||||||
|
--luncher-radius: 14px;
|
||||||
|
--luncher-radius-lg: 18px;
|
||||||
|
--luncher-radius-xl: 22px;
|
||||||
|
--luncher-shadow: 0 4px 0 rgb(46 154 58 / 0.12), 0 8px 18px -8px rgb(29 51 34 / 0.18);
|
||||||
|
--luncher-shadow-lg: 0 6px 0 rgb(46 154 58 / 0.14), 0 14px 28px -10px rgb(29 51 34 / 0.25);
|
||||||
|
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] {
|
||||||
|
// Noční dostih pod reflektory
|
||||||
|
--uma-turf: #58c93a;
|
||||||
|
--uma-turf-deep: #3cae3f;
|
||||||
|
--uma-turf-shadow: #1c5e25;
|
||||||
|
--uma-card-border: #2c3a66;
|
||||||
|
--uma-stripe: rgb(255 255 255 / 0.025);
|
||||||
|
|
||||||
|
--luncher-primary: #6fdc4a;
|
||||||
|
--luncher-primary-hover: #8ae86a;
|
||||||
|
--luncher-primary-light: #1d3a24;
|
||||||
|
--luncher-bg: #10172b;
|
||||||
|
--luncher-bg-card: #19223d;
|
||||||
|
--luncher-bg-hover: #222d4f;
|
||||||
|
--luncher-text: #f3f6ff;
|
||||||
|
--luncher-text-secondary: #b8c2e0;
|
||||||
|
--luncher-text-muted: #7d89ad;
|
||||||
|
--luncher-border: #2c3a66;
|
||||||
|
--luncher-border-light: #232e52;
|
||||||
|
--luncher-navbar-bg: #1b6b2c;
|
||||||
|
--luncher-action-icon: #6fdc4a;
|
||||||
|
--luncher-buyer-icon: var(--uma-gold);
|
||||||
|
--luncher-success: #6fdc4a;
|
||||||
|
--luncher-warning: var(--uma-gold);
|
||||||
|
--luncher-danger: #ff6f93;
|
||||||
|
--luncher-shadow: 0 4px 0 rgb(0 0 0 / 0.35), 0 10px 20px -8px rgb(0 0 0 / 0.5);
|
||||||
|
--luncher-shadow-lg: 0 6px 0 rgb(0 0 0 / 0.4), 0 16px 30px -10px rgb(0 0 0 / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--uma-font);
|
||||||
|
background-color: var(--luncher-bg);
|
||||||
|
background-image: repeating-linear-gradient(135deg, var(--uma-stripe) 0 18px, transparent 18px 36px);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, .navbar-brand, .btn {
|
||||||
|
font-family: var(--uma-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Navbar: zelený pruh se šikmými proužky a zlatým lemem ----------
|
||||||
|
.navbar {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.10) 0 14px, transparent 14px 28px),
|
||||||
|
linear-gradient(90deg, var(--uma-turf-deep), var(--uma-turf)) !important;
|
||||||
|
border-bottom: 4px solid var(--uma-gold);
|
||||||
|
box-shadow: 0 3px 0 rgb(0 0 0 / 0.08);
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-shadow: 0 2px 0 var(--uma-turf-shadow), 0 0 12px rgb(255 255 255 / 0.25);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
margin-left: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--uma-pink);
|
||||||
|
box-shadow: 0 0 0 3px rgb(255 255 255 / 0.9);
|
||||||
|
vertical-align: super;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
border: 2px solid var(--uma-card-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Titulek dne: šikmá stuha jako název dostihu ----------
|
||||||
|
.day-navigator .title {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
color: #fff !important;
|
||||||
|
background: linear-gradient(90deg, var(--uma-pink-deep), var(--uma-pink));
|
||||||
|
clip-path: polygon(14px 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
|
||||||
|
text-shadow: 0 2px 0 rgb(0 0 0 / 0.15);
|
||||||
|
|
||||||
|
&.text-muted {
|
||||||
|
background: linear-gradient(90deg, #8aa290, #a9bfae);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Karty podniků ----------
|
||||||
|
.restaurant-card {
|
||||||
|
border: 3px solid var(--uma-card-border);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
border-color: var(--uma-turf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restaurant-header {
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.12) 0 10px, transparent 10px 20px),
|
||||||
|
linear-gradient(90deg, var(--uma-turf-deep), var(--uma-turf));
|
||||||
|
border-bottom: 3px solid var(--uma-gold);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.12) 0 10px, transparent 10px 20px),
|
||||||
|
linear-gradient(90deg, var(--uma-pink-deep), var(--uma-pink));
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-weight: 800;
|
||||||
|
text-shadow: 0 2px 0 rgb(0 0 0 / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: rgb(255 255 255 / 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-table tbody tr:hover > td {
|
||||||
|
background: #fff0f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
|
||||||
|
background: #2a2344;
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-price {
|
||||||
|
color: var(--uma-pink-deep);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .food-price {
|
||||||
|
color: var(--uma-pink);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Panely a tabulka voleb ----------
|
||||||
|
.choice-section,
|
||||||
|
.choices-table,
|
||||||
|
.pizza-section,
|
||||||
|
.no-votes {
|
||||||
|
border: 3px solid var(--uma-card-border);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
box-shadow: var(--luncher-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// <table> s border-collapse: collapse ignoruje border-radius — kulaté rohy jen se „separate"
|
||||||
|
.choices-table {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choices-table > tbody > tr > td:first-child {
|
||||||
|
background: linear-gradient(90deg, #ffe3ee, #fff3f8);
|
||||||
|
color: var(--uma-pink-deep);
|
||||||
|
font-weight: 800;
|
||||||
|
border-right: 3px solid var(--uma-card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
|
||||||
|
background: linear-gradient(90deg, #3b2446, #2a2140);
|
||||||
|
color: var(--uma-pink);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap karty (skupinové objednávky apod.) a seznamy — stejné zaoblení a lem jako ostatní panely
|
||||||
|
.card {
|
||||||
|
border: 3px solid var(--uma-card-border);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
box-shadow: var(--luncher-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
|
||||||
|
// Rozbalovací výběr z nabídky nesmí karta oříznout
|
||||||
|
&:has(.select-search-container) {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .card-header {
|
||||||
|
background: var(--luncher-primary-light);
|
||||||
|
border-bottom: 3px solid var(--uma-card-border);
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-radius: calc(var(--luncher-radius-lg) - 3px) calc(var(--luncher-radius-lg) - 3px) 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Průhledná tabulka — rohy pak zaobluje karta sama (i když nesmí ořezávat obsah)
|
||||||
|
.table {
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
// Spodní linka posledního řádku by přečnívala přes zaoblené rohy
|
||||||
|
> :last-child > tr:last-child > * {
|
||||||
|
border-bottom-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
border-radius: var(--luncher-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid var(--uma-card-border);
|
||||||
|
|
||||||
|
> .list-group-item {
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select, .form-control,
|
||||||
|
input[type="text"], input[type="email"], input[type="password"] {
|
||||||
|
border-radius: 999px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Tlačítka „ze hry": pilulka s vystouplým spodním okrajem ----------
|
||||||
|
.btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
transition: transform 0.08s ease, box-shadow 0.08s ease, background 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(3px) !important;
|
||||||
|
box-shadow: 0 1px 0 var(--uma-btn-shadow, transparent) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
--uma-btn-shadow: var(--uma-turf-shadow);
|
||||||
|
background: linear-gradient(180deg, #7ad85a, var(--uma-turf-deep));
|
||||||
|
box-shadow: 0 4px 0 var(--uma-turf-shadow);
|
||||||
|
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(180deg, #8be26b, #35a940);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #b9cbbd;
|
||||||
|
box-shadow: 0 4px 0 #97aa9b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-primary {
|
||||||
|
--uma-btn-shadow: var(--uma-card-border);
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
border: 2px solid var(--uma-turf);
|
||||||
|
color: var(--luncher-primary);
|
||||||
|
box-shadow: 0 4px 0 var(--uma-card-border);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--luncher-primary-light);
|
||||||
|
color: var(--luncher-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
--uma-btn-shadow: #c9d3cb;
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
border: 2px solid var(--luncher-border);
|
||||||
|
box-shadow: 0 4px 0 var(--luncher-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
--uma-btn-shadow: #b3264b;
|
||||||
|
background: linear-gradient(180deg, #ff7f9d, var(--luncher-danger));
|
||||||
|
box-shadow: 0 4px 0 #b3264b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon:hover {
|
||||||
|
background: #ffe3ee;
|
||||||
|
color: var(--uma-pink-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-bs-theme="dark"] .action-icon:hover {
|
||||||
|
background: #3b2446;
|
||||||
|
color: var(--uma-pink);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Modaly ----------
|
||||||
|
.modal-content {
|
||||||
|
border: 3px solid var(--uma-card-border);
|
||||||
|
border-radius: var(--luncher-radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.12) 0 12px, transparent 12px 24px),
|
||||||
|
linear-gradient(90deg, var(--uma-turf-deep), var(--uma-turf));
|
||||||
|
border-bottom: 3px solid var(--uma-gold);
|
||||||
|
|
||||||
|
.modal-title, .modal-title h2 {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
text-shadow: 0 2px 0 rgb(0 0 0 / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
filter: invert(1) grayscale(1) brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
border-top: 4px solid var(--uma-gold);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { GroupLimits } from "../../../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Výchozí limity skupinových objednávek — stejné jako výchozí hodnoty na serveru
|
||||||
|
* (server/src/limits.ts). Platí do načtení konfigurace nebo když endpoint neodpoví.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_GROUP_LIMITS: GroupLimits = {
|
||||||
|
maxNoteLength: 300,
|
||||||
|
maxSurchargeTextLength: 100,
|
||||||
|
maxNameLength: 100,
|
||||||
|
maxUrlLength: 500,
|
||||||
|
maxAmountKc: 10_000,
|
||||||
|
maxItemQuantity: 10,
|
||||||
|
maxItems: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Runtime konfigurace ze serveru (GET /api/config). */
|
||||||
|
export type AppRuntimeConfig = {
|
||||||
|
/** Pokus o načtení konfigurace skončil (úspěšně i neúspěšně) */
|
||||||
|
loaded: boolean,
|
||||||
|
/** Přihlášení přes důvěryhodné hlavičky z proxy; undefined = nevíme (konfigurace se nenačetla) */
|
||||||
|
trustedHeaders?: boolean,
|
||||||
|
/** Správa obchodů je zapnutá (server má admin heslo); undefined = nevíme */
|
||||||
|
storeAdmin?: boolean,
|
||||||
|
/** Adresa aplikace Lobby s minihrami; undefined = není nastavena nebo nevíme */
|
||||||
|
lobbyUrl?: string,
|
||||||
|
groupLimits: GroupLimits,
|
||||||
|
};
|
||||||
|
|
||||||
|
let config: AppRuntimeConfig = { loaded: false, groupLimits: DEFAULT_GROUP_LIMITS };
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function getAppConfig(): AppRuntimeConfig {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGroupLimits(): GroupLimits {
|
||||||
|
return config.groupLimits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uloží konfiguraci ze serveru (chybějící limity doplní výchozími) a upozorní odběratele. */
|
||||||
|
export function setAppConfig(patch: Partial<Omit<AppRuntimeConfig, 'groupLimits'>> & { groupLimits?: Partial<GroupLimits> }): void {
|
||||||
|
const { groupLimits, ...rest } = patch;
|
||||||
|
config = {
|
||||||
|
...config,
|
||||||
|
...rest,
|
||||||
|
groupLimits: groupLimits ? { ...DEFAULT_GROUP_LIMITS, ...groupLimits } : config.groupLimits,
|
||||||
|
};
|
||||||
|
listeners.forEach(l => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeAppConfig(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Zda se má stisk klávesy ignorovat pro navigaci šipkami mezi dny/týdny na pozadí stránky.
|
||||||
|
* Navigace se nemá provádět, pokud uživatel píše do formulářového pole, nebo pokud je otevřený
|
||||||
|
* modální dialog (např. minihra, která klávesnici používá k ovládání) –
|
||||||
|
* jinak by se stránka na pozadí neustále přepínala a zbytečně přenačítala data ze serveru.
|
||||||
|
*/
|
||||||
|
export function isArrowNavigationBlocked(e: KeyboardEvent): boolean {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return true;
|
||||||
|
return document.body.classList.contains('modal-open') || document.querySelector('.modal.show') != null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/** Obrázky stylu Hell Girl (plamen + symbol ohně) — server je cachuje, viz server/src/confetti.ts. */
|
||||||
|
const HELLGIRL_IMAGES_URL = '/api/confetti/hellgirl';
|
||||||
|
|
||||||
|
export type HellGirlImages = { flame: string, symbol: string };
|
||||||
|
|
||||||
|
let imagesPromise: Promise<HellGirlImages | undefined> | undefined;
|
||||||
|
|
||||||
|
/** Načte URL obrázků (jednou za běh aplikace). Při chybě vrátí undefined a příště to zkusí znovu. */
|
||||||
|
export const loadHellGirlImages = (): Promise<HellGirlImages | undefined> => {
|
||||||
|
imagesPromise ??= fetch(HELLGIRL_IMAGES_URL)
|
||||||
|
.then(r => r.ok ? r.json() : undefined)
|
||||||
|
.then(images => images?.flame && images?.symbol ? images as HellGirlImages : undefined)
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(images => {
|
||||||
|
if (!images) imagesPromise = undefined;
|
||||||
|
return images;
|
||||||
|
});
|
||||||
|
return imagesPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Z obrázku symbolu (rudý tvar na bílém pozadí) udělá masku — průhlednost podle toho, jak
|
||||||
|
* moc pixel není bílý (zelený kanál). Barvu pak symbolu dodá CSS gradientem v paletě stylu.
|
||||||
|
*/
|
||||||
|
export const createSymbolMask = (src: string, size = 96): Promise<string | undefined> => new Promise(resolve => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return resolve(undefined);
|
||||||
|
ctx.drawImage(img, 0, 0, size, size);
|
||||||
|
const pixels = ctx.getImageData(0, 0, size, size);
|
||||||
|
const data = pixels.data;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
data[i + 3] = 255 - data[i + 1];
|
||||||
|
data[i] = data[i + 1] = data[i + 2] = 0;
|
||||||
|
}
|
||||||
|
ctx.putImageData(pixels, 0, 0);
|
||||||
|
resolve(canvas.toDataURL('image/png'));
|
||||||
|
} catch {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => resolve(undefined);
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { GroupLimits, StoreMenuDish } from "../../../types";
|
||||||
|
import { getGroupLimits } from "./appConfig";
|
||||||
|
|
||||||
|
/** Normalizace pro porovnání: malá písmena, bez diakritiky, emoji, čísel položek („28." / „13A.") a interpunkce. */
|
||||||
|
export function normalizeDishName(text: string): string {
|
||||||
|
return text
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^\s*\*?\s*\d+[a-z]?\s*[.)]\s*/, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MatchedPart = {
|
||||||
|
/** Původní text části poznámky */
|
||||||
|
text: string,
|
||||||
|
quantity: number,
|
||||||
|
dish?: StoreMenuDish,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NoteMatch = {
|
||||||
|
parts: MatchedPart[],
|
||||||
|
/** Cena v Kč dle nabídky — jen pokud byly nalezeny všechny části a mají cenu */
|
||||||
|
total?: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Množství zapsané jako „2x burger", „2 × burger" nebo „burger 2x". */
|
||||||
|
function parseQuantity(text: string): { quantity: number, rest: string } {
|
||||||
|
const prefix = text.match(/^\s*(\d+)\s*[x×]\s*(.+)$/i);
|
||||||
|
if (prefix) return { quantity: Number(prefix[1]), rest: prefix[2] };
|
||||||
|
const suffix = text.match(/^(.+?)\s*[x×]?\s*(\d+)\s*[x×]\s*$/i);
|
||||||
|
if (suffix) return { quantity: Number(suffix[2]), rest: suffix[1] };
|
||||||
|
return { quantity: 1, rest: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Najde položku nabídky k textu — přesná shoda, jinak nejbližší položka obsahující text (či naopak). */
|
||||||
|
function findDish(text: string, dishes: { dish: StoreMenuDish, norm: string }[]): StoreMenuDish | undefined {
|
||||||
|
const norm = normalizeDishName(text);
|
||||||
|
if (norm.length < 3) return undefined;
|
||||||
|
const exact = dishes.find(d => d.norm === norm);
|
||||||
|
if (exact) return exact.dish;
|
||||||
|
const candidates = dishes.filter(d => d.norm.length >= 3 && (d.norm.includes(norm) || norm.includes(d.norm)));
|
||||||
|
// Nejbližší délkou = nejspecifičtější shoda („Kebab" vs. „Kebab box s hranolky")
|
||||||
|
candidates.sort((a, b) => Math.abs(a.norm.length - norm.length) - Math.abs(b.norm.length - norm.length));
|
||||||
|
return candidates[0]?.dish;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rozpozná v poznámce člena objednávky položky z nabídky podniku („Twister speciál + 2x Hranolky")
|
||||||
|
* a spočítá jejich cenu. Nejprve zkusí celou poznámku (názvy jídel mohou obsahovat „+").
|
||||||
|
*/
|
||||||
|
export function matchNoteToMenu(note: string | undefined, menu: StoreMenuDish[]): NoteMatch | undefined {
|
||||||
|
if (!note?.trim() || menu.length === 0) return undefined;
|
||||||
|
const dishes = menu.map(dish => ({ dish, norm: normalizeDishName(dish.name) }));
|
||||||
|
const parts: MatchedPart[] = splitNoteItems(note, menu).map(text => {
|
||||||
|
const exact = dishes.find(d => d.norm === normalizeDishName(text));
|
||||||
|
if (exact) return { text, quantity: 1, dish: exact.dish };
|
||||||
|
const { quantity, rest } = parseQuantity(text);
|
||||||
|
return { text, quantity, dish: findDish(rest, dishes) };
|
||||||
|
});
|
||||||
|
if (!parts.some(p => p.dish)) return undefined;
|
||||||
|
const complete = parts.every(p => p.dish?.price != null);
|
||||||
|
return {
|
||||||
|
parts,
|
||||||
|
total: complete ? parts.reduce((sum, p) => sum + p.dish!.price! * p.quantity, 0) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Počet položek v poznámce objednávky — části oddělené „+", včetně množství („2x Hranolky" = 2). */
|
||||||
|
export function countNoteItems(note: string | undefined, menu: StoreMenuDish[] = []): number {
|
||||||
|
return splitNoteItems(note, menu).reduce((sum, part) => sum + parseQuantity(part).quantity, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Části poznámky oddělené „+" (tak, jak je uživatel zapsal). Pokud celá poznámka
|
||||||
|
* odpovídá jednomu jídlu z nabídky (název může obsahovat „+"), je to jediná položka.
|
||||||
|
*/
|
||||||
|
export function splitNoteItems(note: string | undefined, menu: StoreMenuDish[] = []): string[] {
|
||||||
|
const tokens = (note ?? '').split('+').map(p => p.trim()).filter(Boolean);
|
||||||
|
if (menu.length === 0 || tokens.length < 2) return tokens;
|
||||||
|
const names = new Set(menu.map(d => normalizeDishName(d.name)));
|
||||||
|
const isDish = (text: string) => names.has(normalizeDishName(text)) || names.has(normalizeDishName(parseQuantity(text).rest));
|
||||||
|
// Sousední části, které dohromady tvoří název jídla s „+" („Stripsy 6 Ks + Coleslaw"), spojíme zpět
|
||||||
|
const result: string[] = [];
|
||||||
|
for (let i = 0; i < tokens.length;) {
|
||||||
|
let j = tokens.length;
|
||||||
|
while (j > i + 1 && !isDish(tokens.slice(i, j).join(' + '))) j--;
|
||||||
|
result.push(tokens.slice(i, j).join(' + '));
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odebere z poznámky jeden kus položky na daném indexu: „2x Hranolky" → „Hranolky",
|
||||||
|
* jinak celou část. Vrátí novou poznámku a cenu odebraného kusu dle nabídky (Kč), je-li známa.
|
||||||
|
*/
|
||||||
|
export function removeNoteItem(note: string | undefined, index: number, menu: StoreMenuDish[]): { note: string, removedPrice?: number } {
|
||||||
|
const parts = splitNoteItems(note, menu);
|
||||||
|
const part = parts[index];
|
||||||
|
if (part == null) return { note: note ?? '' };
|
||||||
|
const { quantity, rest } = parseQuantity(part);
|
||||||
|
const dishes = menu.map(dish => ({ dish, norm: normalizeDishName(dish.name) }));
|
||||||
|
const dish = dishes.find(d => d.norm === normalizeDishName(part))?.dish ?? findDish(rest, dishes);
|
||||||
|
if (quantity > 2) parts[index] = `${quantity - 1}x ${rest.trim()}`;
|
||||||
|
else if (quantity === 2) parts[index] = rest.trim();
|
||||||
|
else parts.splice(index, 1);
|
||||||
|
return { note: parts.join(' + '), removedPrice: dish?.price };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Přidá do poznámky jeden kus jídla. Pokud už v poznámce je (i s počtem, např. „2x Hranolky"),
|
||||||
|
* zvýší jeho počet („Hranolky" → „2x Hranolky"), jinak ho připojí za „+".
|
||||||
|
*/
|
||||||
|
export function addNoteItem(note: string | undefined, dishName: string, menu: StoreMenuDish[] = []): string {
|
||||||
|
const parts = splitNoteItems(note, menu);
|
||||||
|
const target = normalizeDishName(dishName);
|
||||||
|
const index = parts.findIndex(p => normalizeDishName(p) === target || normalizeDishName(parseQuantity(p).rest) === target);
|
||||||
|
if (index < 0) return [...parts, dishName].join(' + ');
|
||||||
|
const existing = normalizeDishName(parts[index]) === target ? { quantity: 1, rest: parts[index] } : parseQuantity(parts[index]);
|
||||||
|
parts[index] = `${existing.quantity + 1}x ${existing.rest.trim()}`;
|
||||||
|
return parts.join(' + ');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Kolik kusů daného jídla už poznámka obsahuje (0 = není tam). */
|
||||||
|
export function noteItemQuantity(note: string | undefined, dishName: string, menu: StoreMenuDish[] = []): number {
|
||||||
|
const target = normalizeDishName(dishName);
|
||||||
|
const part = splitNoteItems(note, menu).find(p => normalizeDishName(p) === target || normalizeDishName(parseQuantity(p).rest) === target);
|
||||||
|
if (!part) return 0;
|
||||||
|
return normalizeDishName(part) === target ? 1 : parseQuantity(part).quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí důvod, proč jídlo nejde přidat (limit kusů/položek — pojistky proti překlepům,
|
||||||
|
* hodnoty z konfigurace serveru), jinak undefined.
|
||||||
|
*/
|
||||||
|
export function addItemLimitError(note: string | undefined, dishName: string, menu: StoreMenuDish[] = [], limits: GroupLimits = getGroupLimits()): string | undefined {
|
||||||
|
if (noteItemQuantity(note, dishName, menu) >= limits.maxItemQuantity) {
|
||||||
|
return `Jedné položky lze přidat nejvýše ${limits.maxItemQuantity} kusů`;
|
||||||
|
}
|
||||||
|
if (countNoteItems(note, menu) >= limits.maxItems) {
|
||||||
|
return `Objednávka může mít nejvýše ${limits.maxItems} položek`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { StorePriceHistoryEntry } from "../../../types";
|
||||||
|
|
||||||
|
export type SinceStart = {
|
||||||
|
/** Od kdy se počítá (první známá verze) */
|
||||||
|
since: string,
|
||||||
|
changed: { name: string, start: number, current: number }[],
|
||||||
|
added: { name: string, price?: number }[],
|
||||||
|
removed: { name: string, price?: number }[],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Souhrn „od začátku": počáteční cena položky = „before" její nejstarší změny,
|
||||||
|
* aktuální = „after" nejnovější. Položka přidaná a později zase odebraná se vynechá.
|
||||||
|
*/
|
||||||
|
export function computeSinceStart(entries: StorePriceHistoryEntry[]): SinceStart | undefined {
|
||||||
|
if (entries.length === 0) return undefined;
|
||||||
|
const chronological = [...entries].reverse();
|
||||||
|
const items = new Map<string, { name: string, start?: number, current?: number, addedLater: boolean, removed: boolean }>();
|
||||||
|
for (const entry of chronological) {
|
||||||
|
for (const c of entry.changes) {
|
||||||
|
const key = c.name.trim().toLowerCase();
|
||||||
|
let item = items.get(key);
|
||||||
|
if (!item) {
|
||||||
|
item = { name: c.name, start: c.before, addedLater: c.before == null, removed: false };
|
||||||
|
items.set(key, item);
|
||||||
|
}
|
||||||
|
item.name = c.name;
|
||||||
|
item.current = c.after;
|
||||||
|
item.removed = c.after == null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result: SinceStart = { since: chronological[0].at, changed: [], added: [], removed: [] };
|
||||||
|
for (const item of items.values()) {
|
||||||
|
if (item.addedLater && item.removed) continue;
|
||||||
|
if (item.addedLater) result.added.push({ name: item.name, price: item.current });
|
||||||
|
else if (item.removed) result.removed.push({ name: item.name, price: item.start });
|
||||||
|
else if (item.start != null && item.current != null && item.start !== item.current) {
|
||||||
|
result.changed.push({ name: item.name, start: item.start, current: item.current });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Největší změny nahoře
|
||||||
|
result.changed.sort((a, b) => Math.abs(b.current - b.start) - Math.abs(a.current - a.start));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Český plurál: 1 změna, 2–4 změny, 5+ změn. */
|
||||||
|
export function plural(n: number, one: string, few: string, many: string): string {
|
||||||
|
const form = n === 1 ? one : n >= 2 && n <= 4 ? few : many;
|
||||||
|
return `${n} ${form}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/** Postavy Umamusume (chibi obrázky / portréty + hláška) — server obrázky cachuje, viz server/src/confetti.ts. */
|
||||||
|
const UMA_CHARACTERS_URL = '/api/confetti/umamusume';
|
||||||
|
|
||||||
|
/** `round` = čtvercový portrét, zobrazí se jako kulatá samolepka (chibi obrázky jsou průhledné). */
|
||||||
|
export type UmaCharacter = { name: string, quote?: string, round?: boolean, images: string[], /** portrét pro malé odznaky */ icon?: string };
|
||||||
|
|
||||||
|
let umaCharactersPromise: Promise<UmaCharacter[]> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Načte seznam postav (jednou za běh aplikace). Při chybě vrátí prázdný seznam
|
||||||
|
* a výsledek si nepamatuje — příště se zkusí znovu (např. po restartu serveru).
|
||||||
|
*/
|
||||||
|
export function loadUmaCharacters(): Promise<UmaCharacter[]> {
|
||||||
|
umaCharactersPromise ??= fetch(UMA_CHARACTERS_URL)
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
.then(list => Array.isArray(list) ? list.filter((c: UmaCharacter) => Array.isArray(c?.images)) : [])
|
||||||
|
.catch(() => [])
|
||||||
|
.then((list: UmaCharacter[]) => {
|
||||||
|
if (list.length === 0) umaCharactersPromise = undefined;
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
return umaCharactersPromise;
|
||||||
|
}
|
||||||
+15
-20
@@ -1,36 +1,31 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
// Tento test záměrně NEPOUŽÍVÁ trusted-header – testuje reálný login formulář.
|
// Tento soubor záměrně NEPOUŽÍVÁ výchozí trusted-header — testuje chování přihlašovací stránky.
|
||||||
test.use({ extraHTTPHeaders: {} });
|
test.use({ extraHTTPHeaders: {} });
|
||||||
|
|
||||||
test('uživatel se přihlásí formulářem a uvidí obsah aplikace', async ({ page }) => {
|
test('při přihlášení přes proxy se formulář nezobrazí — bez hlavičky hláška, opakování s hlavičkou přihlásí', async ({ page }) => {
|
||||||
// Server běží s HTTP_REMOTE_USER_ENABLED=true, takže POST /api/login vždy vyžaduje
|
// Server běží s HTTP_REMOTE_USER_ENABLED=true (přihlašuje proxy), takže formulář se jménem
|
||||||
// hlavičku remote-user. Zachytíme požadavky z formuláře (mají tělo s polem login)
|
// nemá smysl. Bez hlavičky remote-user se zobrazí hláška s tlačítkem „Zkusit znovu".
|
||||||
// a přidáme hlavičku; požadavek auto-loginu (bez těla) projde bez hlavičky a selže,
|
let sendHeader = false;
|
||||||
// čímž formulář zůstane viditelný.
|
|
||||||
await page.route('**/api/login', async (route) => {
|
await page.route('**/api/login', async (route) => {
|
||||||
const body = route.request().postData();
|
|
||||||
let login: string | undefined;
|
|
||||||
try { login = body ? JSON.parse(body)?.login : undefined; } catch {}
|
|
||||||
await route.continue({
|
await route.continue({
|
||||||
headers: login
|
headers: sendHeader
|
||||||
? { ...route.request().headers(), 'remote-user': login }
|
? { ...route.request().headers(), 'remote-user': 'testuser' }
|
||||||
: route.request().headers(),
|
: route.request().headers(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
// Formulář musí být viditelný – auto-login selhal (nepřišla hlavička)
|
const retry = page.getByRole('button', { name: 'Zkusit znovu' });
|
||||||
const loginInput = page.locator('#login-input');
|
await expect(retry).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(loginInput).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText('Přihlášení probíhá přes firemní přihlášení (proxy)')).toBeVisible();
|
||||||
|
await expect(page.locator('#login-input')).toHaveCount(0);
|
||||||
|
|
||||||
// Vyplnění loginu a odeslání Enterem
|
// Proxy teď identitu pošle — opakování přihlásí
|
||||||
await loginInput.fill('testuser');
|
sendHeader = true;
|
||||||
await loginInput.press('Enter');
|
await retry.click();
|
||||||
|
await expect(retry).not.toBeVisible({ timeout: 10_000 });
|
||||||
// Po přihlášení musí zmizet login formulář
|
|
||||||
await expect(loginInput).not.toBeVisible({ timeout: 10_000 });
|
|
||||||
|
|
||||||
// JWT musí být uloženo v localStorage jako 3-dílný token
|
// JWT musí být uloženo v localStorage jako 3-dílný token
|
||||||
const token = await page.evaluate(() => localStorage.getItem('token'));
|
const token = await page.evaluate(() => localStorage.getItem('token'));
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ test('zobrazí mock datum 10.01.2025', async ({ page }) => {
|
|||||||
await expect(page.locator('text=10.01')).toBeVisible({ timeout: 10_000 });
|
await expect(page.locator('text=10.01')).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('zobrazí čtyři restaurační karty z mock dat', async ({ page }) => {
|
test('zobrazí tři restaurační karty z mock dat', async ({ page }) => {
|
||||||
// Každá restaurace je obalena v .restaurant-card
|
// Každá restaurace je obalena v .restaurant-card
|
||||||
const cards = page.locator('.restaurant-card');
|
const cards = page.locator('.restaurant-card');
|
||||||
await expect(cards).toHaveCount(4, { timeout: 10_000 });
|
await expect(cards).toHaveCount(3, { timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('zobrazí alespoň jedno jídlo v menu každé restaurace', async ({ page }) => {
|
test('zobrazí alespoň jedno jídlo v menu každé restaurace', async ({ page }) => {
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ data:
|
|||||||
REDIS_PORT: "6379"
|
REDIS_PORT: "6379"
|
||||||
PORT: "3001"
|
PORT: "3001"
|
||||||
HOST: "0.0.0.0"
|
HOST: "0.0.0.0"
|
||||||
|
LOBBY_URL: "http://lobby.localhost"
|
||||||
+24
-1
@@ -33,7 +33,10 @@
|
|||||||
# V případě zapnutí je nutno vyplnit také HTTP_REMOTE_TRUSTED_IPS.
|
# V případě zapnutí je nutno vyplnit také HTTP_REMOTE_TRUSTED_IPS.
|
||||||
# HTTP_REMOTE_USER_ENABLED=true
|
# 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.
|
# Seznam IP adres nebo rozsahů (CIDR, IPv4 i IPv6) oddělených čárkou, ze kterých budou akceptovány důvěryhodné hlavičky.
|
||||||
|
# Rozhoduje skutečná adresa spojení (tj. proxy před aplikací, ne klient), hlavička od jiné adresy se ignoruje.
|
||||||
|
# Uveďte co nejužší rozsah — jen adresu/síť proxy (v Kubernetes síť podů, např. 10.42.0.0/16 u RKE2), ne celé privátní rozsahy.
|
||||||
|
# Neplatná položka = server nenaběhne.
|
||||||
# HTTP_REMOTE_TRUSTED_IPS=127.0.0.1,192.168.1.0/24
|
# 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'.
|
# Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'.
|
||||||
@@ -49,6 +52,10 @@
|
|||||||
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
|
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
|
||||||
# REFRESH_BYPASS_PASSWORD=
|
# REFRESH_BYPASS_PASSWORD=
|
||||||
|
|
||||||
|
# Adresa aplikace Lobby s minihrami. Skrytý odkaz (3× kliknutí na logo) ji otevře v nové záložce.
|
||||||
|
# Bez vyplnění 3× kliknutí jen čeká na kód hry, která zůstala v Luncheru (kreslení).
|
||||||
|
# LOBBY_URL='https://lobby.example.com'
|
||||||
|
|
||||||
# Admin heslo pro správu seznamu obchodů na stránce /objednani.
|
# Admin heslo pro správu seznamu obchodů na stránce /objednani.
|
||||||
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
|
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
|
||||||
# ADMIN_PASSWORD=
|
# ADMIN_PASSWORD=
|
||||||
@@ -64,3 +71,19 @@
|
|||||||
# SENTRY_CLIENT_DSN=https://...@sentry.example.com/2
|
# SENTRY_CLIENT_DSN=https://...@sentry.example.com/2
|
||||||
# Interní logy Sentry SDK do konzole — pro ladění, když eventy nedorazí do Sentry.
|
# Interní logy Sentry SDK do konzole — pro ladění, když eventy nedorazí do Sentry.
|
||||||
# SENTRY_DEBUG=true
|
# SENTRY_DEBUG=true
|
||||||
|
|
||||||
|
# Limity skupinových objednávek (pojistky proti překlepům, ne ochrana proti zlému úmyslu).
|
||||||
|
# Nevyplněné nebo neplatné hodnoty (ne celé kladné číslo) = výchozí hodnota. Klient je dostává přes GET /api/config.
|
||||||
|
# Max. délka poznámky člena (co si objednává), výchozí 300 znaků.
|
||||||
|
# GROUP_MAX_NOTE_LENGTH=300
|
||||||
|
# Max. délka popisu příplatku, výchozí 100 znaků.
|
||||||
|
# GROUP_MAX_SURCHARGE_TEXT_LENGTH=100
|
||||||
|
# Max. délka názvu skupiny, výchozí 100 znaků.
|
||||||
|
# GROUP_MAX_NAME_LENGTH=100
|
||||||
|
# Max. délka URL nabídky / odkazu na sledování, výchozí 500 znaků.
|
||||||
|
# GROUP_MAX_URL_LENGTH=500
|
||||||
|
# Max. výše jedné částky (útrata člena, příplatek, poplatky, doprava, spropitné, pevná sleva) v Kč, výchozí 10000.
|
||||||
|
# GROUP_MAX_AMOUNT_KC=10000
|
||||||
|
# Výběr z nabídky: max. kusů jedné položky (výchozí 10) a max. položek celkem (výchozí 20).
|
||||||
|
# GROUP_MAX_ITEM_QUANTITY=10
|
||||||
|
# GROUP_MAX_ITEMS=20
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[
|
||||||
|
"Kolo štěstí pro nerozhodnuté — vylosuje podnik (dnešní menu i rozvoz) a pak konkrétní jídlo z jeho nabídky (u rozvozu staženou z Bolt Food/Wolt), možnosti lze z kola vyřazovat",
|
||||||
|
"Objednání: nový sloupec „Z nabídky\" — výběr jídla z aktuální nabídky podniku (doplní poznámku i cenu) a kontrola ceny podle nabídky",
|
||||||
|
"Objednání → Obchody: historie cen nabídek podniků — kdy a jak se změnily ceny, nové a odebrané položky"
|
||||||
|
]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[
|
||||||
|
"Opraveno načítání jídelního lístku TechTower po změně jejich webu",
|
||||||
|
"Šenk Šeříková je dočasně skryt, protože je podnik uzavřen"
|
||||||
|
]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[
|
||||||
|
"Možnost v nastavení úplně vypnout poletující motýlky",
|
||||||
|
"Přidány minihry 😉"
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
"Export stravování už neobsahuje objednávky, ve kterých jste si nic neobjednali a nemáte k nim poznámku"
|
||||||
|
]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[
|
||||||
|
"Nové nastavení: po založení skupinové objednávky nebo připojení k ní se vaše dnešní volba automaticky přepne na \"Budu objednávat\"",
|
||||||
|
"Ovládání miniher šipkami už na pozadí nepřepíná dny ani týdny"
|
||||||
|
]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
"Tetris: kostky se otáčí proti směru hodinových ručiček kolem svého středu, tyčka a S/Z kostky už jen přepínají mezi vodorovnou a svislou polohou",
|
||||||
|
"Tetris: nová kostka se objeví nad hrací plochou, takže ji jde otočit dřív, než do plochy vjede",
|
||||||
|
"Minihry jdou hrát i na mobilu a tabletu 📱 — Tetris, had i Pong mají dotykové ovládání a okna her se na telefonu roztáhnou přes celou obrazovku",
|
||||||
|
"Přibyla další minihra — tentokrát se bude střílet 😉"
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
"U oběda v restauraci si můžete zadat cenu (ikonka cenovky u vaší volby) — promítne se do měsíčního přehledu stravování a předvyplní se i v „Zaplatit za všechny“",
|
||||||
|
"V lobby miniher (Pong, Reakční hra, Had, Tetris, Tanky) si hráči mohou psát v chatu, emotikony se vkládají jedním kliknutím 😉",
|
||||||
|
"Tanky mají nově výchozí směrové ovládání — šipka nebo WSAD určuje směr jízdy na mapě; původní tankové ovládání (vpřed/vzad a otáčení) lze zapnout volbou Ovládání",
|
||||||
|
"Tanky: větší mapy s více překážkami, vlastní tank je výrazně označený (při startu a po návratu do hry na něj ukazuje šipka) a pod hrací plochou je legenda bonusů",
|
||||||
|
"Minihry se přestěhovaly do vlastní aplikace 😉"
|
||||||
|
]
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import axios from "axios";
|
||||||
|
import getStorage from "./storage";
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Postavy Umamusume pro konfety kola štěstí (styl Umamusume): chibi obrázky (případně
|
||||||
|
* portrét s `round: true`) a typická hláška.
|
||||||
|
* Zdroj: Umamusume Wiki (umamusume.fandom.com) — obrázky se do repozitáře neukládají,
|
||||||
|
* server je při prvním použití stáhne a nacachuje ve storage (viz {@link getConfettiImage}).
|
||||||
|
*/
|
||||||
|
export const UMA_CHARACTERS: { name: string, quote?: string, round?: boolean, images: string[], icon?: string }[] = [
|
||||||
|
{
|
||||||
|
name: "Air Groove",
|
||||||
|
quote: "Just as my esteemed mother once did, I must train to perfection and lead others along my path!",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/c/c3/Air_Groove_Original_Chibi_2.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/f/f9/Air_Groove_Original_Chibi_1.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bamboo Memory",
|
||||||
|
quote: "Let's keep the pecking order nice and neat. We don't want anything bad to happen, right?",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/c/c5/Bamboo_Memory_Original_Chibi_1.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/e/e7/Bamboo_Memory_Original_Chibi_2.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cesario",
|
||||||
|
quote: "I will not give up this Tiara. It's time for me to show the way.",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/7/74/Cesario_Original_Chibi_2.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/a/ad/Cesario_Original_Chibi_1.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "El Condor Pasa",
|
||||||
|
quote: "Soaring towards victory with the heart of a wrestler! El's the world's strooongest!",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/7/72/El_Condor_Pasa_Original_Chibi_1.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/c/c0/El_Condor_Pasa_Original_Chibi_2.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Gold Ship",
|
||||||
|
quote: "So long as it's fun, right? Right?!",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/8/84/Gold_Ship_Original_Chibi_2.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/d/db/Gold_Ship_Original_Chibi_1.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Manhattan Cafe",
|
||||||
|
quote: "I'm... right here, with someone you can't see.",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/8/8f/Manhattan_Cafe_Original_Chibi_1.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/d/de/Manhattan_Cafe_Original_Chibi_2.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Matikanetannhauser",
|
||||||
|
quote: "Y'know, I kinda get the feeling I can really do it this time!",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/6/6b/Matikanetannhauser_Original_Chibi_2.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/9/90/Matikanetannhauser_Original_Chibi_1.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mejiro Bright",
|
||||||
|
quote: "Oh, is it time for my race already? Well then, I suppose I shall get ready.",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/3/3d/Mejiro_Bright_Original_Chibi_2.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/c/cc/Mejiro_Bright_Original_Chibi_1.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Nakayama Festa",
|
||||||
|
quote: "Cold… is that so? When my passion heats up it'll be hot enough to burn you.",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/1/16/Nakayama_Festa_Original_Chibi_1.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/5/57/Nakayama_Festa_Original_Chibi_2.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Neo Universe",
|
||||||
|
quote: "Neo Universe would like to request a friendly \"ENCOUNTER\". In other words, I'm in your hands.",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/3/34/Neo_Universe_Original_Chibi_1.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/4/40/Neo_Universe_Original_Chibi_2.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Phalaenopsis",
|
||||||
|
quote: "Frail health, poor form—what are they to me? Legs that dull at such trifles have no business claiming victory.",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/6/65/Phalaenopsis_Original_Chibi_2.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/9/95/Phalaenopsis_Original_Chibi_1.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Satono Diamond",
|
||||||
|
quote: "Everyone believes in my ability to win. I shall shine on like a brilliant diamond for them!",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/3/3b/Satono_Diamond_Original_Chibi_1.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/9/9d/Satono_Diamond_Original_Chibi_2.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Silence Suzuka",
|
||||||
|
quote: "I just want to see... that scenery from the lead.",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/1/1f/Silence_Suzuka_Original_Chibi_1.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/d/df/Silence_Suzuka_Original_Chibi_2.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Special Week",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/4/4f/Special_Week_Original_Chibi_2.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/6/6c/Special_Week_Original_Chibi_1.png',
|
||||||
|
],
|
||||||
|
// Portrét pro odznak „jedlíka" (chibi je v malé velikosti nečitelný)
|
||||||
|
icon: 'https://static.wikia.nocookie.net/umamusume/images/3/3a/Special_Week_%28icon%29.png/revision/latest/scale-to-width-down/128',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tokai Teio",
|
||||||
|
quote: "Here begins the legend of the invincible Teio!",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/3/36/Tokai_Teio_Original_Chibi_1.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/9/9e/Tokai_Teio_Original_Chibi_2.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tosen Jordan",
|
||||||
|
quote: "My dream is... actually, serious topics are NG~☆ Anyways, look at my nails! Aren't they cool?",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/3/3e/Tosen_Jordan_Original_Chibi_1.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/7/7b/Tosen_Jordan_Original_Chibi_2.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Yaeno Muteki",
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/9/9d/Yaeno_Muteki_Original_Chibi_2.png',
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/d/d7/Yaeno_Muteki_Original_Chibi_1.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Postavy bez chibi obrázku na wiki — čtvercový portrét (ikona), klient ho zobrazí jako kulatou samolepku
|
||||||
|
{
|
||||||
|
name: "Kitasan Black",
|
||||||
|
quote: "I'll give it a go! You'll never know unless you try, right?",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/c/c8/Kitasan_Black_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Oguri Cap",
|
||||||
|
quote: "I'm going to win this for the folks back home... But first, sustenance.",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/5/57/Oguri_Cap_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Haru Urara",
|
||||||
|
quote: "I won't let losing get me down! Urara, la la la! ♪",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/7/7b/Haru_Urara_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mejiro McQueen",
|
||||||
|
quote: "I'll bring honor and glory to the Mejiro family!",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/1/15/Mejiro_McQueen_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Rice Shower",
|
||||||
|
quote: "Rice only brings misfortune to everyone... I really... really hope I can change that!",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/0/07/Rice_Shower_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Daiwa Scarlet",
|
||||||
|
quote: "Second-best won't cut it. Everything I do is for the sake of being number one!",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/d/d7/Daiwa_Scarlet_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Vodka",
|
||||||
|
quote: "I've just gotta be the coolest to ever do it!",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/0/0d/Vodka_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Agnes Tachyon",
|
||||||
|
quote: "Life is an experiment, and we its guinea pigs. You, me, the other Umamusume—everyone!",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/9/9d/Agnes_Tachyon_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Twin Turbo",
|
||||||
|
quote: "Turbo's my name, and speed's my game! Let's race, right now! Hey! You listenin' to me?!",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/9/97/Twin_Turbo_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Symboli Rudolf",
|
||||||
|
quote: "Fortune smiles upon the brave. Let us create a world where all Umamusume can live in bliss.",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/5/56/Symboli_Rudolf_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Nice Nature",
|
||||||
|
quote: "Yup, I'm happy with a third place finish—it's all an extra like me can ask for.",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/7/77/Nice_Nature_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Maruzensky",
|
||||||
|
quote: "The track's like a dance floor, and I'm feelin' groovy today!♪",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/b/b4/Maruzensky_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Grass Wonder",
|
||||||
|
quote: "Noble and gallant, I march on... towards my ambitions.",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/d/d0/Grass_Wonder_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mihono Bourbon",
|
||||||
|
quote: "Target acquired. Commencing Operation: Triple Crown.",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/3/37/Mihono_Bourbon_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Curren Chan",
|
||||||
|
quote: "New day, same adorable Curren! ☆ Spread the word with #CurrenChan!",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/9/91/Curren_Chan_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Super Creek",
|
||||||
|
quote: "I can spoil you as much as you'd like. Aww, there, there. ♪",
|
||||||
|
round: true,
|
||||||
|
images: [
|
||||||
|
'https://static.wikia.nocookie.net/umamusume/images/5/5e/Super_Creek_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obrázky stylu Hell Girl — plamen (jednorázová animace při otevření dialogu a změně dne)
|
||||||
|
* a symbol ohně ze znělky (ozdoba u titulku dne). Cachují se stejně jako obrázky postav.
|
||||||
|
*/
|
||||||
|
const HELLGIRL_IMAGES = {
|
||||||
|
flame: 'https://www.jigokutsushin.it/images/flame.gif',
|
||||||
|
symbol: 'https://images.seeklogo.com/logo-png/6/1/hell-girl-fire-symbol-logo-png_seeklogo-66173.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Po neúspěšném stažení obrázku se další pokus smí provést až po této době. */
|
||||||
|
const RETRY_AFTER_MS = 10 * 60 * 1000;
|
||||||
|
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
type CachedImage = { contentType: string, data: string };
|
||||||
|
|
||||||
|
/** Stabilní ID obrázku z jeho URL — nemění se při úpravách seznamu (prohlížeč obrázky cachuje natrvalo). */
|
||||||
|
const imageId = (url: string) => crypto.createHash('sha1').update(url).digest('hex').slice(0, 16);
|
||||||
|
const imageKey = (url: string) => `confetti_img_${imageId(url)}`;
|
||||||
|
const imageUrl = (url: string) => `/api/confetti/image/${imageId(url)}`;
|
||||||
|
|
||||||
|
/** Povolené obrázky: ID → zdrojová URL (nic jiného server nestáhne). */
|
||||||
|
const ALLOWED_IMAGES = new Map([
|
||||||
|
...UMA_CHARACTERS.flatMap(c => c.icon ? [...c.images, c.icon] : c.images),
|
||||||
|
...Object.values(HELLGIRL_IMAGES),
|
||||||
|
].map(url => [imageId(url), url]));
|
||||||
|
const failedAt = new Map<string, number>();
|
||||||
|
const inFlight = new Map<string, Promise<CachedImage | undefined>>();
|
||||||
|
|
||||||
|
/** Seznam postav pro klienta — obrázky vedou na cachující proxy tohoto serveru. */
|
||||||
|
export function getConfettiCharacters() {
|
||||||
|
return UMA_CHARACTERS.map(c => ({
|
||||||
|
name: c.name,
|
||||||
|
...(c.quote && { quote: c.quote }),
|
||||||
|
...(c.round && { round: true }),
|
||||||
|
images: c.images.map(imageUrl),
|
||||||
|
...(c.icon && { icon: imageUrl(c.icon) }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Obrázky stylu Hell Girl pro klienta — vedou na cachující proxy tohoto serveru. */
|
||||||
|
export function getHellGirlImages() {
|
||||||
|
return { flame: imageUrl(HELLGIRL_IMAGES.flame), symbol: imageUrl(HELLGIRL_IMAGES.symbol) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí obrázek konfet dle jeho ID. Poprvé ho stáhne a uloží do storage, další
|
||||||
|
* požadavky (i při výpadku internetu) obslouží z cache. Stahují se jen URL ze seznamu výše.
|
||||||
|
*/
|
||||||
|
export async function getConfettiImage(id: string): Promise<{ contentType: string, data: Buffer } | undefined> {
|
||||||
|
const url = ALLOWED_IMAGES.get(id);
|
||||||
|
if (!url) return undefined;
|
||||||
|
const key = imageKey(url);
|
||||||
|
const cached = await storage.getData<CachedImage>(key);
|
||||||
|
if (cached?.data) {
|
||||||
|
return { contentType: cached.contentType, data: Buffer.from(cached.data, 'base64') };
|
||||||
|
}
|
||||||
|
const lastFailure = failedAt.get(id);
|
||||||
|
if (lastFailure && Date.now() - lastFailure < RETRY_AFTER_MS) return undefined;
|
||||||
|
|
||||||
|
let pending = inFlight.get(id);
|
||||||
|
if (!pending) {
|
||||||
|
pending = downloadImage(url, key)
|
||||||
|
.catch(e => {
|
||||||
|
console.warn(`Konfety: obrázek ${url} se nepodařilo stáhnout — ${e?.message ?? e}`);
|
||||||
|
failedAt.set(id, Date.now());
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
.finally(() => inFlight.delete(id));
|
||||||
|
inFlight.set(id, pending);
|
||||||
|
}
|
||||||
|
const image = await pending;
|
||||||
|
return image && { contentType: image.contentType, data: Buffer.from(image.data, 'base64') };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadImage(url: string, key: string): Promise<CachedImage> {
|
||||||
|
const res = await axios.get<ArrayBuffer>(url, { responseType: 'arraybuffer', timeout: 10_000, maxContentLength: MAX_IMAGE_BYTES });
|
||||||
|
const contentType = String(res.headers['content-type'] ?? '');
|
||||||
|
if (!contentType.startsWith('image/')) {
|
||||||
|
throw new Error(`Neočekávaný typ obsahu ${contentType}`);
|
||||||
|
}
|
||||||
|
const image = { contentType, data: Buffer.from(res.data).toString('base64') };
|
||||||
|
await storage.setData<CachedImage>(key, image);
|
||||||
|
return image;
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { Server } from "socket.io";
|
||||||
|
import { Clock, defaultClock, GameError, gameHandler, loginFromPayload, minPlayersForEnv } from "./minigames";
|
||||||
|
import { getActiveRestaurants, getRestaurantMenu } from "./service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kreslení — jeden hráč kreslí jídlo z dnešních menu, ostatní hádají v chatu.
|
||||||
|
*
|
||||||
|
* Každý hráč jednou kreslí. Hádá se klíčové slovo jídla (např. "guláš" u
|
||||||
|
* "Plzeňský hovězí guláš"), diakritika ani drobné koncovky nevadí. Kdo uhodne dřív,
|
||||||
|
* dostane víc bodů; kreslíř dostává body za každého, kdo uhodl.
|
||||||
|
*
|
||||||
|
* Hádané slovo dostává jen kreslíř (osobní místnost), ostatním chodí maska s nápovědou.
|
||||||
|
* Stav je v paměti, předpokládá jednu repliku.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ROUND_MS = 80_000;
|
||||||
|
export const REVEAL_MS = 5000;
|
||||||
|
export const MAX_PLAYERS = 10;
|
||||||
|
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
export const GUESS_MAX_POINTS = 100;
|
||||||
|
export const GUESS_MIN_POINTS = 20;
|
||||||
|
export const DRAWER_POINTS_PER_GUESS = 25;
|
||||||
|
/** Podíly času kola, kdy se v nápovědě odkryje další písmeno. */
|
||||||
|
const HINT_REVEALS = [0.4, 0.7];
|
||||||
|
const MAX_MESSAGES = 30;
|
||||||
|
const MAX_MESSAGE_LENGTH = 100;
|
||||||
|
/** Pojistka proti nekonečné kresbě (součet bodů všech tahů v kole). */
|
||||||
|
const MAX_POINTS_PER_ROUND = 20_000;
|
||||||
|
export const WATCH_ROOM = 'draw-watchers';
|
||||||
|
export const userRoom = (login: string) => `draw-user:${login}`;
|
||||||
|
|
||||||
|
/** Záložní slova, když dnešní menu nejsou k dispozici (víkend, výpadek). */
|
||||||
|
export const FALLBACK_WORDS = [
|
||||||
|
'pizza', 'guláš', 'řízek', 'svíčková', 'burger', 'palačinky', 'knedlíky', 'špagety', 'sushi', 'kebab',
|
||||||
|
'salát', 'hranolky', 'párek', 'koláč', 'dort', 'bramboráky', 'rizoto', 'lasagne', 'tacos', 'vepřo knedlo zelo',
|
||||||
|
];
|
||||||
|
|
||||||
|
export type DrawPhase = 'idle' | 'lobby' | 'drawing' | 'reveal' | 'finished';
|
||||||
|
|
||||||
|
export type DrawPlayer = { login: string, score: number, guessed: boolean, place?: number };
|
||||||
|
export type ChatMessage = { login?: string, text: string, system?: boolean };
|
||||||
|
/** Tah štětcem; souřadnice 0–1000 nezávisle na velikosti plátna. */
|
||||||
|
export type Stroke = { color: string, size: number, points: [number, number][], start: boolean };
|
||||||
|
|
||||||
|
export type DrawState = {
|
||||||
|
phase: DrawPhase,
|
||||||
|
host?: string,
|
||||||
|
players: DrawPlayer[],
|
||||||
|
drawer?: string,
|
||||||
|
round: number,
|
||||||
|
totalRounds: number,
|
||||||
|
/** Maska hádaného jídla, např. "g _ _ _ š". */
|
||||||
|
hint?: string,
|
||||||
|
/** Řešení — jen ve fázi reveal a finished. */
|
||||||
|
answer?: string,
|
||||||
|
/** Zbývající čas kola (ms) v okamžiku odeslání. */
|
||||||
|
remainingMs?: number,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
minPlayers: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Word = { display: string, keywords: string[] };
|
||||||
|
|
||||||
|
/** Malá písmena bez diakritiky a nadbytečných mezer. */
|
||||||
|
export const normalize = (s: string) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Z názvu jídla v menu udělá hádatelné slovo: bez alergenů, jen hlavní část
|
||||||
|
* (před čárkou a přílohou "s/se ..."), klíčová slova bez přídavných jmen.
|
||||||
|
*/
|
||||||
|
export function toWord(dish: string): Word {
|
||||||
|
let display = dish.replace(/\([^)]*\)/g, '').split(',')[0].split(/\s+(?:s|se|a|na)\s+/i)[0].replace(/\s+/g, ' ').trim();
|
||||||
|
const words = display.split(' ');
|
||||||
|
if (display.length > 30) display = words.slice(0, 3).join(' ');
|
||||||
|
// Přídavná jména v češtině končí obvykle na -ý/-á/-é/-í ("Plzeňský hovězí guláš" → guláš)
|
||||||
|
let keywords = display.split(' ').filter(w => w.length >= 4 && !/[ýáéí]$/i.test(w)).map(normalize);
|
||||||
|
if (keywords.length === 0) keywords = display.split(' ').filter(w => w.length >= 3).map(normalize);
|
||||||
|
if (keywords.length === 0) keywords = [normalize(display)];
|
||||||
|
return { display, keywords };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uhodl hráč? Stačí jedno klíčové slovo; tolerují se koncovky (knedlík ~ knedlíky). */
|
||||||
|
export function isCorrectGuess(guess: string, word: Word): boolean {
|
||||||
|
const g = normalize(guess);
|
||||||
|
if (!g) return false;
|
||||||
|
if (g === normalize(word.display)) return true;
|
||||||
|
return g.split(' ').some(gw => word.keywords.some(kw => {
|
||||||
|
if (gw === kw) return true;
|
||||||
|
const need = Math.max(4, kw.length - 2);
|
||||||
|
return gw.length >= need && kw.slice(0, need) === gw.slice(0, need);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DrawingGame {
|
||||||
|
private phase: DrawPhase = 'idle';
|
||||||
|
private host?: string;
|
||||||
|
private players: DrawPlayer[] = [];
|
||||||
|
private order: string[] = [];
|
||||||
|
private round = 0;
|
||||||
|
private word?: Word;
|
||||||
|
private words: Word[] = [];
|
||||||
|
private revealed = new Set<number>();
|
||||||
|
private roundEndsAt = 0;
|
||||||
|
private messages: ChatMessage[] = [];
|
||||||
|
private strokes: Stroke[] = [];
|
||||||
|
private strokePoints = 0;
|
||||||
|
private starting = false;
|
||||||
|
private timer: unknown;
|
||||||
|
private hintTimers: unknown[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly broadcastAll: (state: DrawState) => void,
|
||||||
|
private readonly sendWord: (login: string, word: string) => void,
|
||||||
|
private readonly relayStroke: (stroke: Stroke) => void,
|
||||||
|
private readonly relayClear: () => void,
|
||||||
|
private readonly loadDishes: () => Promise<string[]>,
|
||||||
|
private readonly minPlayers = 2,
|
||||||
|
private readonly clock: Clock = defaultClock,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
get drawer(): string | undefined {
|
||||||
|
return this.phase === 'drawing' || this.phase === 'reveal' ? this.order[this.round - 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): DrawState {
|
||||||
|
const showAnswer = this.phase === 'reveal' || this.phase === 'finished';
|
||||||
|
return {
|
||||||
|
phase: this.phase,
|
||||||
|
host: this.host,
|
||||||
|
players: this.players.map(p => ({ ...p })),
|
||||||
|
drawer: this.drawer,
|
||||||
|
round: this.round,
|
||||||
|
totalRounds: this.order.length,
|
||||||
|
hint: this.phase === 'drawing' ? this.hint() : undefined,
|
||||||
|
answer: showAnswer ? this.word?.display : undefined,
|
||||||
|
remainingMs: this.phase === 'drawing' ? Math.max(0, this.roundEndsAt - this.clock.now()) : undefined,
|
||||||
|
messages: [...this.messages],
|
||||||
|
minPlayers: this.minPlayers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getStrokes(): Stroke[] {
|
||||||
|
return this.strokes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hádané slovo pro daného hráče (jen kreslíř během kola). */
|
||||||
|
wordFor(login: string): string | undefined {
|
||||||
|
return this.phase === 'drawing' && login === this.drawer ? this.word?.display : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
join(login: string) {
|
||||||
|
if (this.phase === 'idle' || this.phase === 'finished') {
|
||||||
|
this.reset();
|
||||||
|
this.phase = 'lobby';
|
||||||
|
this.host = login;
|
||||||
|
this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
|
||||||
|
} else if (this.phase !== 'lobby') {
|
||||||
|
throw new GameError('Hra už běží, počkejte na další.');
|
||||||
|
}
|
||||||
|
if (this.players.some(p => p.login === login)) return;
|
||||||
|
if (this.players.length >= MAX_PLAYERS) {
|
||||||
|
throw new GameError('Lobby je plné.');
|
||||||
|
}
|
||||||
|
this.players.push({ login, score: 0, guessed: false });
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
leave(login: string) {
|
||||||
|
if (this.phase !== 'lobby' || !this.players.some(p => p.login === login)) return;
|
||||||
|
if (login === this.host) {
|
||||||
|
this.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.players = this.players.filter(p => p.login !== login);
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(login: string) {
|
||||||
|
if (this.phase !== 'lobby' || this.starting) {
|
||||||
|
throw new GameError('Není otevřené žádné lobby.');
|
||||||
|
}
|
||||||
|
if (login !== this.host) {
|
||||||
|
throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
|
||||||
|
}
|
||||||
|
if (this.players.length < this.minPlayers) {
|
||||||
|
throw new GameError(`Ke hře je potřeba alespoň ${this.minPlayers} hráčů.`);
|
||||||
|
}
|
||||||
|
this.starting = true;
|
||||||
|
let dishes: string[] = [];
|
||||||
|
try {
|
||||||
|
dishes = await this.loadDishes();
|
||||||
|
} catch {
|
||||||
|
// Menu nejsou k dispozici — hraje se se záložními slovy
|
||||||
|
} finally {
|
||||||
|
this.starting = false;
|
||||||
|
}
|
||||||
|
if (this.phase !== 'lobby') return;
|
||||||
|
const unique = [...new Map(dishes.map(toWord).map(w => [normalize(w.display), w])).values()];
|
||||||
|
// Dnešní jídla mají přednost; když dojdou, doplní se záložní slova (viz nextRound)
|
||||||
|
this.words = unique.length > 0 ? unique : FALLBACK_WORDS.map(toWord);
|
||||||
|
this.order = this.players.map(p => p.login);
|
||||||
|
this.round = 0;
|
||||||
|
this.nextRound();
|
||||||
|
}
|
||||||
|
|
||||||
|
stroke(login: string, payload: unknown) {
|
||||||
|
if (this.phase !== 'drawing' || login !== this.drawer) return;
|
||||||
|
const { color, size, points, start } = (payload ?? {}) as Partial<Stroke>;
|
||||||
|
if (typeof color !== 'string' || !/^#[0-9a-f]{6}$/i.test(color)) return;
|
||||||
|
if (typeof size !== 'number' || size < 1 || size > 40) return;
|
||||||
|
if (!Array.isArray(points) || points.length === 0 || points.length > 200) return;
|
||||||
|
if (!points.every(p => Array.isArray(p) && p.length === 2 && p.every(v => Number.isFinite(v) && v >= 0 && v <= 1000))) return;
|
||||||
|
if (this.strokePoints + points.length > MAX_POINTS_PER_ROUND) return;
|
||||||
|
const stroke: Stroke = { color, size, points: points.map(([x, y]) => [Math.round(x), Math.round(y)]), start: !!start };
|
||||||
|
this.strokes.push(stroke);
|
||||||
|
this.strokePoints += points.length;
|
||||||
|
this.relayStroke(stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(login: string) {
|
||||||
|
if (this.phase !== 'drawing' || login !== this.drawer) return;
|
||||||
|
this.strokes = [];
|
||||||
|
this.strokePoints = 0;
|
||||||
|
this.relayClear();
|
||||||
|
}
|
||||||
|
|
||||||
|
guess(login: string, text: unknown) {
|
||||||
|
if (typeof text !== 'string') return;
|
||||||
|
const message = text.trim().slice(0, MAX_MESSAGE_LENGTH);
|
||||||
|
const player = this.players.find(p => p.login === login);
|
||||||
|
if (!message || !player) return;
|
||||||
|
if (this.phase !== 'drawing') {
|
||||||
|
this.pushMessage({ login, text: message });
|
||||||
|
this.emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Kreslíř a ti, kdo už uhodli, by mohli prozradit řešení
|
||||||
|
if (login === this.drawer || player.guessed || !this.word) return;
|
||||||
|
if (isCorrectGuess(message, this.word)) {
|
||||||
|
const share = Math.max(0, this.roundEndsAt - this.clock.now()) / ROUND_MS;
|
||||||
|
player.guessed = true;
|
||||||
|
player.score += Math.max(GUESS_MIN_POINTS, Math.round(GUESS_MAX_POINTS * share));
|
||||||
|
const drawer = this.players.find(p => p.login === this.drawer);
|
||||||
|
if (drawer) drawer.score += DRAWER_POINTS_PER_GUESS;
|
||||||
|
this.pushMessage({ text: `${login} uhodl(a)! 🎉`, system: true });
|
||||||
|
const guessers = this.players.filter(p => p.login !== this.drawer);
|
||||||
|
if (guessers.every(p => p.guessed)) {
|
||||||
|
this.endRound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.pushMessage({ login, text: message });
|
||||||
|
}
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextRound() {
|
||||||
|
this.round++;
|
||||||
|
if (this.round > this.order.length) {
|
||||||
|
this.finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = Math.floor(this.clock.random() * this.words.length);
|
||||||
|
this.word = this.words.splice(index, 1)[0] ?? toWord(FALLBACK_WORDS[0]);
|
||||||
|
if (this.words.length === 0) this.words = FALLBACK_WORDS.map(toWord);
|
||||||
|
this.revealed.clear();
|
||||||
|
this.strokes = [];
|
||||||
|
this.strokePoints = 0;
|
||||||
|
this.players.forEach(p => { p.guessed = false; });
|
||||||
|
this.phase = 'drawing';
|
||||||
|
this.roundEndsAt = this.clock.now() + ROUND_MS;
|
||||||
|
this.pushMessage({ text: `Kreslí ${this.drawer}.`, system: true });
|
||||||
|
this.relayClear();
|
||||||
|
this.schedule(() => this.endRound(), ROUND_MS);
|
||||||
|
this.hintTimers = HINT_REVEALS.map(share => this.clock.setTimeout(() => this.revealLetter(), ROUND_MS * share));
|
||||||
|
this.emit();
|
||||||
|
this.sendWord(this.drawer!, this.word.display);
|
||||||
|
}
|
||||||
|
|
||||||
|
private revealLetter() {
|
||||||
|
if (this.phase !== 'drawing' || !this.word) return;
|
||||||
|
const hidden = [...this.word.display].map((c, i) => i).filter(i => this.word!.display[i] !== ' ' && !this.revealed.has(i));
|
||||||
|
// Poslední písmeno nikdy neodkrýváme
|
||||||
|
if (hidden.length <= 1) return;
|
||||||
|
this.revealed.add(hidden[Math.floor(this.clock.random() * hidden.length)]);
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private hint(): string {
|
||||||
|
if (!this.word) return '';
|
||||||
|
return [...this.word.display].map((c, i) => c === ' ' ? ' ' : this.revealed.has(i) ? c : '_').join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private endRound() {
|
||||||
|
this.clearHintTimers();
|
||||||
|
this.phase = 'reveal';
|
||||||
|
this.pushMessage({ text: `Bylo to: ${this.word?.display}`, system: true });
|
||||||
|
this.schedule(() => this.nextRound(), REVEAL_MS);
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish() {
|
||||||
|
const ranked = [...this.players].sort((a, b) => b.score - a.score);
|
||||||
|
ranked.forEach((p, i) => { p.place = i + 1; });
|
||||||
|
this.phase = 'finished';
|
||||||
|
this.clearTimer();
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private pushMessage(message: ChatMessage) {
|
||||||
|
this.messages.push(message);
|
||||||
|
if (this.messages.length > MAX_MESSAGES) this.messages.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancel() {
|
||||||
|
this.reset();
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset() {
|
||||||
|
this.clearTimer();
|
||||||
|
this.clearHintTimers();
|
||||||
|
this.phase = 'idle';
|
||||||
|
this.host = undefined;
|
||||||
|
this.players = [];
|
||||||
|
this.order = [];
|
||||||
|
this.round = 0;
|
||||||
|
this.word = undefined;
|
||||||
|
this.messages = [];
|
||||||
|
this.strokes = [];
|
||||||
|
this.strokePoints = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(fn: () => void, ms: number) {
|
||||||
|
this.clearTimer();
|
||||||
|
this.timer = this.clock.setTimeout(() => {
|
||||||
|
this.timer = undefined;
|
||||||
|
fn();
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer() {
|
||||||
|
if (this.timer !== undefined) {
|
||||||
|
this.clock.clearTimeout(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearHintTimers() {
|
||||||
|
this.hintTimers.forEach(t => this.clock.clearTimeout(t));
|
||||||
|
this.hintTimers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit() {
|
||||||
|
this.broadcastAll(this.getState());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Názvy dnešních jídel (bez polévek) ze všech aktivních podniků. */
|
||||||
|
async function loadTodaysDishes(): Promise<string[]> {
|
||||||
|
const menus = await Promise.all(getActiveRestaurants().map(r => getRestaurantMenu(r).catch(() => undefined)));
|
||||||
|
return menus.flatMap(m => m?.food ?? []).filter(f => !f.isSoup && f.name).map(f => f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zaregistruje websocket handlery Kreslení. */
|
||||||
|
export function registerDrawingGame(io: Server) {
|
||||||
|
const game = new DrawingGame(
|
||||||
|
state => io.emit('draw:state', state),
|
||||||
|
(login, word) => io.to(userRoom(login)).emit('draw:word', word),
|
||||||
|
stroke => io.to(WATCH_ROOM).emit('draw:stroke', stroke),
|
||||||
|
() => io.to(WATCH_ROOM).emit('draw:clear'),
|
||||||
|
loadTodaysDishes,
|
||||||
|
minPlayersForEnv(2),
|
||||||
|
);
|
||||||
|
|
||||||
|
io.on('connection', socket => {
|
||||||
|
const handle = (action: (login: string, payload: unknown) => void | Promise<void>) => gameHandler(socket, 'draw:error', action);
|
||||||
|
socket.on('draw:sync', () => socket.emit('draw:state', game.getState()));
|
||||||
|
// Sledování vyžaduje přihlášení — kreslíř si tak po znovupřipojení vyzvedne své slovo
|
||||||
|
socket.on('draw:watch', payload => {
|
||||||
|
let login: string | undefined;
|
||||||
|
try { login = loginFromPayload(payload); } catch { /* sleduje anonymně */ }
|
||||||
|
socket.join(WATCH_ROOM);
|
||||||
|
if (login) socket.join(userRoom(login));
|
||||||
|
socket.emit('draw:state', game.getState());
|
||||||
|
socket.emit('draw:strokes', game.getStrokes());
|
||||||
|
const word = login ? game.wordFor(login) : undefined;
|
||||||
|
if (word) socket.emit('draw:word', word);
|
||||||
|
});
|
||||||
|
socket.on('draw:unwatch', () => {
|
||||||
|
socket.leave(WATCH_ROOM);
|
||||||
|
[...socket.rooms].filter(r => r.startsWith('draw-user:')).forEach(r => socket.leave(r));
|
||||||
|
});
|
||||||
|
socket.on('draw:join', handle(login => game.join(login)));
|
||||||
|
socket.on('draw:leave', handle(login => game.leave(login)));
|
||||||
|
socket.on('draw:start', handle(login => game.start(login)));
|
||||||
|
socket.on('draw:stroke', handle((login, payload) => game.stroke(login, payload)));
|
||||||
|
socket.on('draw:clear', handle(login => game.clear(login)));
|
||||||
|
socket.on('draw:guess', handle((login, payload) => game.guess(login, (payload as { text?: unknown })?.text)));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -240,6 +240,10 @@ export async function updateGroupFees(login: string, groupId: string, fees?: num
|
|||||||
if (tip !== undefined) group.tip = tip > 0 ? tip : undefined;
|
if (tip !== undefined) group.tip = tip > 0 ? tip : undefined;
|
||||||
if (discountType !== undefined) group.discountType = (discountType as any) || undefined;
|
if (discountType !== undefined) group.discountType = (discountType as any) || undefined;
|
||||||
if (discountValue !== undefined) group.discountValue = discountValue > 0 ? discountValue : undefined;
|
if (discountValue !== undefined) group.discountValue = discountValue > 0 ? discountValue : undefined;
|
||||||
|
// Pojistka i pro změnu jen hodnoty při již nastavené procentní slevě — nad 100 % by vyšly záporné částky
|
||||||
|
if (group.discountType === 'percent' && (group.discountValue ?? 0) > 100) {
|
||||||
|
throw new Error('Procentní sleva může být nejvýše 100 %');
|
||||||
|
}
|
||||||
return saveExtraData(data, date);
|
return saveExtraData(data, date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-10
@@ -29,6 +29,9 @@ import changelogRoutes from "./routes/changelogRoutes";
|
|||||||
import groupRoutes from "./routes/groupRoutes";
|
import groupRoutes from "./routes/groupRoutes";
|
||||||
import storeRoutes from "./routes/storeRoutes";
|
import storeRoutes from "./routes/storeRoutes";
|
||||||
import butterflyRoutes from "./routes/butterflyRoutes";
|
import butterflyRoutes from "./routes/butterflyRoutes";
|
||||||
|
import { getConfettiCharacters, getConfettiImage, getHellGirlImages } from "./confetti";
|
||||||
|
import { getTrustedRemoteUser, parseTrustedIps } from "./trustedHeaders";
|
||||||
|
import { getGroupLimits } from "./limits";
|
||||||
|
|
||||||
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}`) });
|
||||||
@@ -53,12 +56,15 @@ app.use(cors({ origin: '*' }));
|
|||||||
|
|
||||||
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
|
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';
|
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
|
||||||
|
let trustedProxies = parseTrustedIps('');
|
||||||
if (HTTP_REMOTE_USER_ENABLED) {
|
if (HTTP_REMOTE_USER_ENABLED) {
|
||||||
if (!process.env.HTTP_REMOTE_TRUSTED_IPS) {
|
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.');
|
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());
|
const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim());
|
||||||
app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS);
|
app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS);
|
||||||
|
// Hlavička s identitou se přijímá jen od těchto adres (kontrola skutečné adresy spojení)
|
||||||
|
trustedProxies = parseTrustedIps(process.env.HTTP_REMOTE_TRUSTED_IPS);
|
||||||
console.log('Zapnutý login přes hlavičky z proxy.');
|
console.log('Zapnutý login přes hlavičky z proxy.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +141,18 @@ app.get("/api/config", (_req, res) => {
|
|||||||
dsn: process.env.SENTRY_CLIENT_DSN ?? process.env.SENTRY_DSN ?? null,
|
dsn: process.env.SENTRY_CLIENT_DSN ?? process.env.SENTRY_DSN ?? null,
|
||||||
environment: ENVIRONMENT,
|
environment: ENVIRONMENT,
|
||||||
},
|
},
|
||||||
|
limits: {
|
||||||
|
group: getGroupLimits(),
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
trustedHeaders: HTTP_REMOTE_USER_ENABLED,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
storeAdmin: !!process.env.ADMIN_PASSWORD,
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
lobbyUrl: process.env.LOBBY_URL || null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,16 +164,17 @@ app.get("/api/whoami", (req, res) => {
|
|||||||
delete req.headers["cookie"]
|
delete req.headers["cookie"]
|
||||||
console.log(req.headers)
|
console.log(req.headers)
|
||||||
}
|
}
|
||||||
res.send(req.header(HTTP_REMOTE_USER_HEADER_NAME));
|
res.send(getTrustedRemoteUser(req, HTTP_REMOTE_USER_HEADER_NAME, trustedProxies) ?? '');
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post("/api/login", (req, res) => {
|
app.post("/api/login", (req, res) => {
|
||||||
if (HTTP_REMOTE_USER_ENABLED) {
|
if (HTTP_REMOTE_USER_ENABLED) {
|
||||||
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
|
const remoteUser = getTrustedRemoteUser(req, HTTP_REMOTE_USER_HEADER_NAME, trustedProxies);
|
||||||
if (remoteUser && remoteUser.length > 0) {
|
if (remoteUser && remoteUser.length > 0) {
|
||||||
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
|
res.status(200).json(generateToken(remoteUser, true));
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
|
// Přihlašuje proxy (trusted headers) — bez hlavičky nejde o chybu serveru, ale o nepřihlášeného uživatele
|
||||||
|
res.status(401).json({ error: 'Přihlášení probíhá přes firemní přihlášení (proxy), ale nepřišla identita uživatele. Zkuste stránku obnovit nebo se přihlásit znovu.' });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!req.body?.login || req.body.login.trim().length === 0) {
|
if (!req.body?.login || req.body.login.trim().length === 0) {
|
||||||
@@ -200,20 +219,40 @@ app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
|
|||||||
} catch (e: any) { next(e); }
|
} catch (e: any) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Konfety kola štěstí — veřejné (načítá je <img>), obrázky jen z pevného seznamu, cachované ve storage
|
||||||
|
app.get("/api/confetti/umamusume", (_req, res) => {
|
||||||
|
// Seznam je malý — vždy revalidovat (ETag → 304), aby se změny projevily hned po nasazení
|
||||||
|
res.set('Cache-Control', 'no-cache').json(getConfettiCharacters());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/confetti/hellgirl", (_req, res) => {
|
||||||
|
res.set('Cache-Control', 'no-cache').json(getHellGirlImages());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/confetti/image/:id", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id;
|
||||||
|
const image = /^[0-9a-f]{16}$/.test(id) ? await getConfettiImage(id) : undefined;
|
||||||
|
if (!image) {
|
||||||
|
return res.status(404).json({ error: 'Obrázek není dostupný' });
|
||||||
|
}
|
||||||
|
res.set('Content-Type', image.contentType);
|
||||||
|
res.set('Cache-Control', 'public, max-age=2592000, immutable');
|
||||||
|
res.end(image.data);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.use("/api/", (req, res, next) => {
|
app.use("/api/", (req, res, next) => {
|
||||||
if (HTTP_REMOTE_USER_ENABLED) {
|
if (HTTP_REMOTE_USER_ENABLED) {
|
||||||
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
|
const remoteName = getTrustedRemoteUser(req, HTTP_REMOTE_USER_HEADER_NAME, trustedProxies);
|
||||||
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
|
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
|
||||||
delete req.headers["cookie"]
|
delete req.headers["cookie"]
|
||||||
console.log(req.headers)
|
console.log(req.headers)
|
||||||
}
|
}
|
||||||
if (remoteUser && remoteUser.length > 0) {
|
if (remoteName && ENVIRONMENT !== "production") {
|
||||||
const remoteName = Buffer.from(remoteUser, 'latin1').toString();
|
console.log("Tvuj username: %s.", remoteName);
|
||||||
if (ENVIRONMENT !== "production") {
|
|
||||||
console.log("Tvuj username: %s.", remoteName);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!req.headers.authorization) {
|
if (!req.headers.authorization) {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { GroupLimits } from "../../types/gen/types.gen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Výchozí limity skupinových objednávek — pojistky proti překlepům (dvojklik, omylem vložený text),
|
||||||
|
* ne ochrana proti zlému úmyslu. Lze je přepsat proměnnými prostředí (viz .env.template).
|
||||||
|
*/
|
||||||
|
export const DEFAULT_GROUP_LIMITS: GroupLimits = {
|
||||||
|
maxNoteLength: 300,
|
||||||
|
maxSurchargeTextLength: 100,
|
||||||
|
maxNameLength: 100,
|
||||||
|
maxUrlLength: 500,
|
||||||
|
maxAmountKc: 10_000,
|
||||||
|
maxItemQuantity: 10,
|
||||||
|
maxItems: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Proměnná prostředí a rozumný strop pro každý limit. */
|
||||||
|
const ENV: Record<keyof GroupLimits, { name: string, max: number }> = {
|
||||||
|
maxNoteLength: { name: 'GROUP_MAX_NOTE_LENGTH', max: 5_000 },
|
||||||
|
maxSurchargeTextLength: { name: 'GROUP_MAX_SURCHARGE_TEXT_LENGTH', max: 1_000 },
|
||||||
|
maxNameLength: { name: 'GROUP_MAX_NAME_LENGTH', max: 1_000 },
|
||||||
|
maxUrlLength: { name: 'GROUP_MAX_URL_LENGTH', max: 5_000 },
|
||||||
|
maxAmountKc: { name: 'GROUP_MAX_AMOUNT_KC', max: 1_000_000 },
|
||||||
|
maxItemQuantity: { name: 'GROUP_MAX_ITEM_QUANTITY', max: 1_000 },
|
||||||
|
maxItems: { name: 'GROUP_MAX_ITEMS', max: 1_000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
let cached: GroupLimits | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí limity skupinových objednávek (výchozí hodnoty přepsané z env). Čte se líně při prvním
|
||||||
|
* použití — moduly s routami se importují dřív, než dotenv načte .env soubor.
|
||||||
|
* Neplatná hodnota (ne celé kladné číslo, nad stropem) se ignoruje s varováním.
|
||||||
|
*/
|
||||||
|
export function getGroupLimits(): GroupLimits {
|
||||||
|
if (cached) return cached;
|
||||||
|
const limits = { ...DEFAULT_GROUP_LIMITS };
|
||||||
|
for (const key of Object.keys(ENV) as (keyof GroupLimits)[]) {
|
||||||
|
const { name, max } = ENV[key];
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (raw === undefined || raw.trim() === '') continue;
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isInteger(value) || value < 1 || value > max) {
|
||||||
|
console.warn(`Limity: neplatná hodnota ${name}="${raw}" (povoleno 1–${max}), používám výchozí ${limits[key]}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
limits[key] = value;
|
||||||
|
}
|
||||||
|
cached = limits;
|
||||||
|
return limits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pro testy — znovu načte limity z env při dalším volání. */
|
||||||
|
export function resetGroupLimits(): void {
|
||||||
|
cached = undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Socket } from "socket.io";
|
||||||
|
import { getLogin } from "./auth";
|
||||||
|
|
||||||
|
/** Pomůcky pro websocketové minihry (kreslení). Ostatní minihry jsou v samostatné aplikaci Lobby. */
|
||||||
|
|
||||||
|
/** Chyba určená hráči — posílá se zpět klientovi, nic se neloguje. */
|
||||||
|
export class GameError extends Error { }
|
||||||
|
|
||||||
|
/** Hodiny a náhoda vyčleněné kvůli testům. */
|
||||||
|
export type Clock = {
|
||||||
|
now: () => number,
|
||||||
|
setTimeout: (fn: () => void, ms: number) => unknown,
|
||||||
|
clearTimeout: (handle: unknown) => void,
|
||||||
|
random: () => number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultClock: Clock = {
|
||||||
|
now: () => Date.now(),
|
||||||
|
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
||||||
|
clearTimeout: handle => clearTimeout(handle as NodeJS.Timeout),
|
||||||
|
random: () => Math.random(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ověří token z eventu a vrátí login, jinak vyhodí chybu. */
|
||||||
|
export function loginFromPayload(payload: unknown): string {
|
||||||
|
const token = (payload as { token?: unknown } | undefined)?.token;
|
||||||
|
if (typeof token !== 'string') {
|
||||||
|
throw new GameError('Chybí přihlášení.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const login = getLogin(token);
|
||||||
|
if (!login) throw new Error();
|
||||||
|
return login;
|
||||||
|
} catch {
|
||||||
|
throw new GameError('Neplatné přihlášení.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vytvoří handler websocket eventu, který ověří přihlášení a herní chyby
|
||||||
|
* pošle zpět jen danému klientovi jako `errorEvent`.
|
||||||
|
*/
|
||||||
|
export function gameHandler(socket: Socket, errorEvent: string, action: (login: string, payload: unknown) => void | Promise<void>) {
|
||||||
|
const onError = (e: unknown) => {
|
||||||
|
if (e instanceof GameError) {
|
||||||
|
socket.emit(errorEvent, e.message);
|
||||||
|
} else {
|
||||||
|
console.error(`Chyba minihry (${errorEvent})`, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (payload: unknown) => {
|
||||||
|
try {
|
||||||
|
const result = action(loginFromPayload(payload), payload);
|
||||||
|
if (result instanceof Promise) result.catch(onError);
|
||||||
|
} catch (e) {
|
||||||
|
onError(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimální počet hráčů — lokálně lze hrát i sám. */
|
||||||
|
export const minPlayersForEnv = (prod: number) => process.env.NODE_ENV === 'development' ? 1 : prod;
|
||||||
@@ -20,11 +20,22 @@ const SOUP_NAMES = [
|
|||||||
'boršč',
|
'boršč',
|
||||||
'slepičí s ',
|
'slepičí s ',
|
||||||
'zeleninová s ',
|
'zeleninová s ',
|
||||||
|
'zeleninový krém',
|
||||||
'hovězí s ',
|
'hovězí s ',
|
||||||
'kachní kaldoun',
|
'kachní kaldoun',
|
||||||
'dršťková',
|
'dršťková',
|
||||||
'žampionový krém',
|
'žampionový krém',
|
||||||
'zelná',
|
'zelná',
|
||||||
|
'čočková',
|
||||||
|
'gulášová',
|
||||||
|
'bramboračka',
|
||||||
|
'dýňový krém',
|
||||||
|
'ovarová',
|
||||||
|
'fazolačka',
|
||||||
|
'kyselica',
|
||||||
|
'rajčatová s ',
|
||||||
|
'hrášková s ',
|
||||||
|
'hrachová s ',
|
||||||
];
|
];
|
||||||
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'];
|
||||||
|
|
||||||
@@ -328,6 +339,13 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
|
|||||||
if (!font) {
|
if (!font) {
|
||||||
throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
|
throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
|
||||||
}
|
}
|
||||||
|
// Šestý pokus - formát z 10/2026: hlavička je <p><strong><font class="wsw-41">,
|
||||||
|
// jednotlivé dny a položky jsou sourozenecké <p> v témže bloku. Třetí pokus sice najde
|
||||||
|
// hlavičku, ale <font> nemá žádné sourozenecké <p>, proto se použije parsing pátého pokusu.
|
||||||
|
if (thirdTry && $(font).parent().siblings('p').length === 0) {
|
||||||
|
thirdTry = false;
|
||||||
|
fifthTry = true;
|
||||||
|
}
|
||||||
|
|
||||||
const result: Food[][] = [];
|
const result: Food[][] = [];
|
||||||
|
|
||||||
@@ -365,7 +383,11 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
|
|||||||
if (fifthTry) {
|
if (fifthTry) {
|
||||||
let parsing = false;
|
let parsing = false;
|
||||||
let currentDayIndex = 0;
|
let currentDayIndex = 0;
|
||||||
const container = $(font).parent().parent();
|
let container = $(font).parent().parent();
|
||||||
|
if (container.find('p').length === 0) {
|
||||||
|
// Formát 10/2026 - hlavička je přímo v <p>, kontejnerem je jeho rodič
|
||||||
|
container = $(font).closest('p').parent();
|
||||||
|
}
|
||||||
container.find('p').each((_, p) => {
|
container.find('p').each((_, p) => {
|
||||||
const pHtml = $(p).html() ?? '';
|
const pHtml = $(p).html() ?? '';
|
||||||
const lines = pHtml.split(/<br\s*\/?>/i)
|
const lines = pHtml.split(/<br\s*\/?>/i)
|
||||||
@@ -373,7 +395,15 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
|
|||||||
.filter(line => line.length > 0);
|
.filter(line => line.length > 0);
|
||||||
for (const text of lines) {
|
for (const text of lines) {
|
||||||
const lower = text.toLocaleLowerCase();
|
const lower = text.toLocaleLowerCase();
|
||||||
|
// Za aktuálním týdnem může následovat menu na další týden - to již nezpracováváme
|
||||||
|
if (parsing && lower.startsWith('následující týden')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (DAYS_IN_WEEK.includes(lower)) {
|
if (DAYS_IN_WEEK.includes(lower)) {
|
||||||
|
if (result[DAYS_IN_WEEK.indexOf(lower)]) {
|
||||||
|
// Opakující se den = začátek dalšího týdne
|
||||||
|
return false;
|
||||||
|
}
|
||||||
currentDayIndex = DAYS_IN_WEEK.indexOf(lower);
|
currentDayIndex = DAYS_IN_WEEK.indexOf(lower);
|
||||||
parsing = true;
|
parsing = true;
|
||||||
result[currentDayIndex] ??= [];
|
result[currentDayIndex] ??= [];
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import express, { Request } from "express";
|
import express, { Request } from "express";
|
||||||
import { getDateForWeekIndex, getData, getRestaurantMenu, getToday, initIfNeeded } from "../service";
|
import { getDateForWeekIndex, getData, isRestaurantDisabled, getActiveRestaurants, getRestaurantMenu, getToday, initIfNeeded } from "../service";
|
||||||
import { formatDate, getDayOfWeekIndex } from "../utils";
|
import { formatDate, getDayOfWeekIndex } from "../utils";
|
||||||
import getStorage from "../storage";
|
import getStorage from "../storage";
|
||||||
import { getWebsocket } from "../websocket";
|
import { getWebsocket } from "../websocket";
|
||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { parseToken } from "../utils";
|
import { parseToken } from "../utils";
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import { ClientData, GroupState, MealSlot, OrderGroupMember, TrackingProvider } from "../../../types/gen/types.gen";
|
import { ClientData, GroupState, MealSlot, OrderGroupMember, Restaurant, TrackingProvider } from "../../../types/gen/types.gen";
|
||||||
import { getStores } from "../stores";
|
import { getStores } from "../stores";
|
||||||
import { createGroup } from "../groups";
|
import { createGroup } from "../groups";
|
||||||
import {
|
import {
|
||||||
@@ -36,7 +36,7 @@ const LUNCH_CHOICES = [
|
|||||||
'OBJEDNAVAM',
|
'OBJEDNAVAM',
|
||||||
'NEOBEDVAM',
|
'NEOBEDVAM',
|
||||||
'ROZHODUJI',
|
'ROZHODUJI',
|
||||||
];
|
].filter(choice => !isRestaurantDisabled(choice as Restaurant));
|
||||||
|
|
||||||
// Náhodné názvy jídel do poznámek mock objednávek
|
// Náhodné názvy jídel do poznámek mock objednávek
|
||||||
const MOCK_ORDER_FOODS = [
|
const MOCK_ORDER_FOODS = [
|
||||||
@@ -72,12 +72,7 @@ const MAX_ORDER_MEMBER_COUNT = 50;
|
|||||||
const MAX_ORDER_AMOUNT = 100000;
|
const MAX_ORDER_AMOUNT = 100000;
|
||||||
|
|
||||||
// Restaurace s menu
|
// Restaurace s menu
|
||||||
const RESTAURANTS_WITH_MENU = [
|
const RESTAURANTS_WITH_MENU = getActiveRestaurants();
|
||||||
'SLADOVNICKA',
|
|
||||||
'TECHTOWER',
|
|
||||||
'ZASTAVKAUMICHALA',
|
|
||||||
'SENKSERIKOVA',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware pro kontrolu DEV režimu
|
* Middleware pro kontrolu DEV režimu
|
||||||
@@ -138,7 +133,7 @@ async function generateMockDataForDay(dayIndex: number, requestedCount?: number)
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Pokud má restaurace menu, vybereme náhodné jídlo
|
// Pokud má restaurace menu, vybereme náhodné jídlo
|
||||||
if (RESTAURANTS_WITH_MENU.includes(choice) && menus[choice]?.length) {
|
if (RESTAURANTS_WITH_MENU.includes(choice as Restaurant) && menus[choice]?.length) {
|
||||||
const foods = menus[choice];
|
const foods = menus[choice];
|
||||||
// Vybereme náhodné jídlo (ne polévku)
|
// Vybereme náhodné jídlo (ne polévku)
|
||||||
const mainFoods = foods.filter((f: any) => !f.isSoup);
|
const mainFoods = foods.filter((f: any) => !f.isSoup);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import express, { Request, Response } from "express";
|
import express, { Request, Response } from "express";
|
||||||
import { getLogin, getTrusted } from "../auth";
|
import { getLogin, getTrusted } from "../auth";
|
||||||
import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu, updateBuyer } from "../service";
|
import { addChoice, getActiveRestaurants, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, updatePrice, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu, updateBuyer } from "../service";
|
||||||
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
|
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
|
||||||
import { getWebsocket } from "../websocket";
|
import { getWebsocket } from "../websocket";
|
||||||
import { callNotifikace } from "../notifikace";
|
import { callNotifikace } from "../notifikace";
|
||||||
import { AddChoiceData, ChangeDepartureTimeData, MealSlot, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen";
|
import { AddChoiceData, ChangeDepartureTimeData, MealSlot, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData, UpdatePriceData } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
|
|
||||||
// RateLimit na refresh endpoint
|
// RateLimit na refresh endpoint
|
||||||
@@ -54,7 +54,7 @@ function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean {
|
|||||||
* @param req request
|
* @param req request
|
||||||
* @returns index dne v týdnu
|
* @returns index dne v týdnu
|
||||||
*/
|
*/
|
||||||
const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"]>) => {
|
const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"] | UpdatePriceData["body"]>) => {
|
||||||
if (req.body.dayIndex == null) {
|
if (req.body.dayIndex == null) {
|
||||||
throw new Error(`Nebyl předán index dne v týdnu.`);
|
throw new Error(`Nebyl předán index dne v týdnu.`);
|
||||||
}
|
}
|
||||||
@@ -171,6 +171,24 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
|
|||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("/updatePrice", async (req: Request<{}, any, UpdatePriceData["body"]>, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const trusted = getTrusted(parseToken(req));
|
||||||
|
let date = undefined;
|
||||||
|
if (req.body.dayIndex != null) {
|
||||||
|
try {
|
||||||
|
date = getDateForWeekIndex(parseValidateFutureDayIndex(req));
|
||||||
|
} catch (e: any) {
|
||||||
|
return res.status(400).json({ error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await updatePrice(login, trusted, req.body.price ?? undefined, date);
|
||||||
|
getWebsocket().emit("message", data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e) }
|
||||||
|
});
|
||||||
|
|
||||||
router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeData["body"]>, res, next) => {
|
router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeData["body"]>, res, next) => {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
let date = undefined;
|
let date = undefined;
|
||||||
@@ -232,8 +250,8 @@ export const refreshMetoda = async (req: Request, res: Response) => {
|
|||||||
return res.status(400).json({ error: "ještě neumim TODO..." });
|
return res.status(400).json({ error: "ještě neumim TODO..." });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Pro všechny restaurace refreshni menu na aktuální týden
|
// Pro všechny aktivní restaurace refreshni menu na aktuální týden
|
||||||
const restaurants = ["SLADOVNICKA", "TECHTOWER", "ZASTAVKAUMICHALA", "SENKSERIKOVA"] as const;
|
const restaurants = getActiveRestaurants();
|
||||||
const firstDay = getFirstWorkDayOfWeek(getToday());
|
const firstDay = getFirstWorkDayOfWeek(getToday());
|
||||||
const results: Record<string, any> = {};
|
const results: Record<string, any> = {};
|
||||||
const successfulRestaurants: string[] = [];
|
const successfulRestaurants: string[] = [];
|
||||||
|
|||||||
@@ -5,9 +5,20 @@ import { getWebsocket } from "../websocket";
|
|||||||
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupTracking, getOrderDates } from "../groups";
|
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupTracking, getOrderDates } from "../groups";
|
||||||
import { GroupState } from "../../../types/gen/types.gen";
|
import { GroupState } from "../../../types/gen/types.gen";
|
||||||
import { checkOrderTracking } from "../orderTracking";
|
import { checkOrderTracking } from "../orderTracking";
|
||||||
|
import { getGroupLimits } from "../limits";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Pojistky proti překlepům (dvojklik, omylem vložený text) — limity viz limits.ts (lze přepsat env)
|
||||||
|
|
||||||
|
/** Vrátí chybovou hlášku, pokud částka (v haléřích) není celé číslo v rozsahu 0 až limit. */
|
||||||
|
function invalidAmount(value: unknown, label: string): string | undefined {
|
||||||
|
const { maxAmountKc } = getGroupLimits();
|
||||||
|
if (!Number.isInteger(value) || (value as number) < 0) return `Neplatná výše: ${label}`;
|
||||||
|
if ((value as number) > maxAmountKc * 100) return `${label} může být nejvýše ${maxAmountKc} Kč`;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function broadcastExtra(data: any) {
|
function broadcastExtra(data: any) {
|
||||||
getWebsocket().emit("message", data);
|
getWebsocket().emit("message", data);
|
||||||
}
|
}
|
||||||
@@ -25,7 +36,11 @@ router.post("/create", async (req: Request, res, next) => {
|
|||||||
if (!name || typeof name !== 'string') {
|
if (!name || typeof name !== 'string') {
|
||||||
return res.status(400).json({ error: 'Nebyl předán název skupiny' });
|
return res.status(400).json({ error: 'Nebyl předán název skupiny' });
|
||||||
}
|
}
|
||||||
if (url != null && typeof url !== 'string') {
|
const limits = getGroupLimits();
|
||||||
|
if (name.length > limits.maxNameLength) {
|
||||||
|
return res.status(400).json({ error: `Název skupiny může mít nejvýše ${limits.maxNameLength} znaků` });
|
||||||
|
}
|
||||||
|
if (url != null && (typeof url !== 'string' || url.length > limits.maxUrlLength)) {
|
||||||
return res.status(400).json({ error: 'Neplatná URL nabídky' });
|
return res.status(400).json({ error: 'Neplatná URL nabídky' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -80,23 +95,29 @@ router.post("/updateMember", async (req: Request, res, next) => {
|
|||||||
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
|
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
|
||||||
const patch: Record<string, any> = {};
|
const patch: Record<string, any> = {};
|
||||||
if (amount !== undefined) {
|
if (amount !== undefined) {
|
||||||
if (!Number.isInteger(amount) || amount < 0) {
|
const error = invalidAmount(amount, 'Částka');
|
||||||
return res.status(400).json({ error: 'Neplatná částka' });
|
if (error) return res.status(400).json({ error });
|
||||||
}
|
|
||||||
patch.amount = amount;
|
patch.amount = amount;
|
||||||
}
|
}
|
||||||
if (note !== undefined) {
|
if (note !== undefined) {
|
||||||
if (typeof note !== 'string') return res.status(400).json({ error: 'Neplatná poznámka' });
|
if (typeof note !== 'string') return res.status(400).json({ error: 'Neplatná poznámka' });
|
||||||
|
const { maxNoteLength } = getGroupLimits();
|
||||||
|
if (note.length > maxNoteLength) {
|
||||||
|
return res.status(400).json({ error: `Poznámka může mít nejvýše ${maxNoteLength} znaků` });
|
||||||
|
}
|
||||||
patch.note = note;
|
patch.note = note;
|
||||||
}
|
}
|
||||||
if (surchargeText !== undefined) {
|
if (surchargeText !== undefined) {
|
||||||
if (typeof surchargeText !== 'string') return res.status(400).json({ error: 'Neplatný text příplatku' });
|
if (typeof surchargeText !== 'string') return res.status(400).json({ error: 'Neplatný text příplatku' });
|
||||||
|
const { maxSurchargeTextLength } = getGroupLimits();
|
||||||
|
if (surchargeText.length > maxSurchargeTextLength) {
|
||||||
|
return res.status(400).json({ error: `Popis příplatku může mít nejvýše ${maxSurchargeTextLength} znaků` });
|
||||||
|
}
|
||||||
patch.surchargeText = surchargeText;
|
patch.surchargeText = surchargeText;
|
||||||
}
|
}
|
||||||
if (surchargeAmount !== undefined) {
|
if (surchargeAmount !== undefined) {
|
||||||
if (!Number.isInteger(surchargeAmount) || surchargeAmount < 0) {
|
const error = invalidAmount(surchargeAmount, 'Příplatek');
|
||||||
return res.status(400).json({ error: 'Neplatná výše příplatku' });
|
if (error) return res.status(400).json({ error });
|
||||||
}
|
|
||||||
patch.surchargeAmount = surchargeAmount;
|
patch.surchargeAmount = surchargeAmount;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -124,20 +145,25 @@ router.post("/updateFees", async (req: Request, res, next) => {
|
|||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {};
|
const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {};
|
||||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
if (fees !== undefined && (!Number.isInteger(fees) || fees < 0)) {
|
for (const [value, label] of [[fees, 'Poplatky'], [shipping, 'Doprava'], [tip, 'Spropitné']] as const) {
|
||||||
return res.status(400).json({ error: 'Neplatná výše poplatků' });
|
const error = value !== undefined ? invalidAmount(value, label) : undefined;
|
||||||
}
|
if (error) return res.status(400).json({ error });
|
||||||
if (shipping !== undefined && (!Number.isInteger(shipping) || shipping < 0)) {
|
|
||||||
return res.status(400).json({ error: 'Neplatná výše dopravy' });
|
|
||||||
}
|
|
||||||
if (tip !== undefined && (!Number.isInteger(tip) || tip < 0)) {
|
|
||||||
return res.status(400).json({ error: 'Neplatná výše spropitného' });
|
|
||||||
}
|
}
|
||||||
if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) {
|
if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) {
|
||||||
return res.status(400).json({ error: 'Neplatný typ slevy' });
|
return res.status(400).json({ error: 'Neplatný typ slevy' });
|
||||||
}
|
}
|
||||||
if (discountValue !== undefined && (!Number.isInteger(discountValue) || discountValue < 0)) {
|
if (discountValue !== undefined) {
|
||||||
return res.status(400).json({ error: 'Neplatná výše slevy' });
|
if (!Number.isInteger(discountValue) || discountValue < 0) {
|
||||||
|
return res.status(400).json({ error: 'Neplatná výše slevy' });
|
||||||
|
}
|
||||||
|
// Procentní sleva nad 100 % by vedla k záporným částkám
|
||||||
|
if (discountType === 'percent' && discountValue > 100) {
|
||||||
|
return res.status(400).json({ error: 'Procentní sleva může být nejvýše 100 %' });
|
||||||
|
}
|
||||||
|
const { maxAmountKc } = getGroupLimits();
|
||||||
|
if (discountType !== 'percent' && discountValue > maxAmountKc * 100) {
|
||||||
|
return res.status(400).json({ error: `Sleva může být nejvýše ${maxAmountKc} Kč` });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await updateGroupFees(login, id, fees, shipping, tip, discountType, discountValue);
|
const data = await updateGroupFees(login, id, fees, shipping, tip, discountType, discountValue);
|
||||||
@@ -168,7 +194,7 @@ router.post("/setTracking", async (req: Request, res, next) => {
|
|||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const { id, shareUrl } = req.body ?? {};
|
const { id, shareUrl } = req.body ?? {};
|
||||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
if (shareUrl !== undefined && typeof shareUrl !== 'string') {
|
if (shareUrl !== undefined && (typeof shareUrl !== 'string' || shareUrl.length > getGroupLimits().maxUrlLength)) {
|
||||||
return res.status(400).json({ error: 'Neplatný odkaz na sledování objednávky' });
|
return res.status(400).json({ error: 'Neplatný odkaz na sledování objednávky' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { getStores, addStore, updateStore, removeStore } from "../stores";
|
import { getStores, addStore, updateStore, removeStore } from "../stores";
|
||||||
|
import { getStoreMenu, getStoreMenuHistory, getStoreMenuStatus, refreshStoreMenu, renameStoreMenus } from "../storeMenus";
|
||||||
|
import { StoreMenuProvider } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -10,6 +12,69 @@ router.get("/", async (_req, res, next) => {
|
|||||||
} catch (e: any) { next(e); }
|
} catch (e: any) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/menu", async (req, res, next) => {
|
||||||
|
const name = req.query.name;
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.status(200).json(await getStoreMenu(name));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message === 'Obchod nebyl nalezen') {
|
||||||
|
return res.status(404).json({ error: e.message });
|
||||||
|
}
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/menu/status", async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.status(200).json(await getStoreMenuStatus());
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/menu/history", async (req, res, next) => {
|
||||||
|
const name = req.query.name;
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.status(200).json(await getStoreMenuHistory(name));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message === 'Obchod nebyl nalezen') {
|
||||||
|
return res.status(404).json({ error: e.message });
|
||||||
|
}
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/menu/refresh", async (req, res, next) => {
|
||||||
|
const { name, provider, heslo } = req.body ?? {};
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
|
||||||
|
}
|
||||||
|
if (!Object.values<string>(StoreMenuProvider).includes(provider)) {
|
||||||
|
return res.status(400).json({ error: 'Neplatná dovozová služba' });
|
||||||
|
}
|
||||||
|
if (!heslo || typeof heslo !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Nebylo předáno heslo' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.status(200).json(await refreshStoreMenu(name, provider, heslo));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message === 'UNAUTHORIZED') {
|
||||||
|
return res.status(403).json({ error: 'Nesprávné heslo' });
|
||||||
|
}
|
||||||
|
if (e.message === 'Obchod nebyl nalezen') {
|
||||||
|
return res.status(404).json({ error: e.message });
|
||||||
|
}
|
||||||
|
if (e.message?.startsWith('Obchod nemá URL')) {
|
||||||
|
return res.status(400).json({ error: e.message });
|
||||||
|
}
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post("/add", async (req, res, next) => {
|
router.post("/add", async (req, res, next) => {
|
||||||
const { name, heslo, urls } = req.body ?? {};
|
const { name, heslo, urls } = req.body ?? {};
|
||||||
if (!name || typeof name !== 'string') {
|
if (!name || typeof name !== 'string') {
|
||||||
@@ -48,6 +113,12 @@ router.post("/update", async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const stores = await updateStore(name, heslo, newName ?? undefined, urls ?? undefined);
|
const stores = await updateStore(name, heslo, newName ?? undefined, urls ?? undefined);
|
||||||
|
// Uložené nabídky jsou vázané na název obchodu
|
||||||
|
const renamed = newName && stores.find(s => s.name.toLowerCase() === newName.trim().toLowerCase());
|
||||||
|
const original = stores.find(s => s.name.toLowerCase() === name.trim().toLowerCase());
|
||||||
|
if (renamed && !original) {
|
||||||
|
await renameStoreMenus(name.trim(), renamed.name);
|
||||||
|
}
|
||||||
res.status(200).json(stores);
|
res.status(200).json(stores);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.message === 'UNAUTHORIZED') {
|
if (e.message === 'UNAUTHORIZED') {
|
||||||
@@ -67,6 +138,7 @@ router.post("/delete", async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const stores = await removeStore(name, heslo);
|
const stores = await removeStore(name, heslo);
|
||||||
|
await renameStoreMenus(name.trim());
|
||||||
res.status(200).json(stores);
|
res.status(200).json(stores);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.message === 'UNAUTHORIZED') {
|
if (e.message === 'UNAUTHORIZED') {
|
||||||
|
|||||||
+59
-7
@@ -9,6 +9,24 @@ import { ClientData, DepartureTime, LunchChoice, LunchChoices, MealSlot, PizzaDa
|
|||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
const MENU_PREFIX = 'menu';
|
const MENU_PREFIX = 'menu';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dočasně uzavřené podniky – nezobrazují se na hlavní stránce, nelze je zvolit
|
||||||
|
* a neprovádí se pro ně načítání menu z jejich webu.
|
||||||
|
*/
|
||||||
|
export const DISABLED_RESTAURANTS: readonly Restaurant[] = [
|
||||||
|
Restaurant.SENKSERIKOVA, // dočasně uzavřen
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Vrátí true, pokud je podnik dočasně vyřazen z nabídky. */
|
||||||
|
export function isRestaurantDisabled(restaurant: Restaurant | LunchChoice): boolean {
|
||||||
|
return DISABLED_RESTAURANTS.includes(restaurant as Restaurant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrátí podniky s menu, které nejsou dočasně vyřazeny. */
|
||||||
|
export function getActiveRestaurants(): Restaurant[] {
|
||||||
|
return (Object.values(Restaurant) as Restaurant[]).filter(r => !isRestaurantDisabled(r));
|
||||||
|
}
|
||||||
|
|
||||||
function getDataKey(date: Date, slot?: MealSlot): string {
|
function getDataKey(date: Date, slot?: MealSlot): string {
|
||||||
const base = formatDate(date);
|
const base = formatDate(date);
|
||||||
return slot === MealSlot.EXTRA ? `${base}_extra` : base;
|
return slot === MealSlot.EXTRA ? `${base}_extra` : base;
|
||||||
@@ -55,13 +73,12 @@ export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData>
|
|||||||
if (slot === MealSlot.EXTRA) {
|
if (slot === MealSlot.EXTRA) {
|
||||||
clientData.stores = await getStores();
|
clientData.stores = await getStores();
|
||||||
} else {
|
} else {
|
||||||
clientData.menus = {
|
// Dočasně vyřazené podniky se do odpovědi vůbec nezahrnují (klient je pak nezobrazí)
|
||||||
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
|
const menus: RestaurantDayMenuMap = {};
|
||||||
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
|
for (const restaurant of getActiveRestaurants()) {
|
||||||
TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
|
menus[restaurant] = await getRestaurantMenu(restaurant, date);
|
||||||
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
|
|
||||||
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
|
|
||||||
}
|
}
|
||||||
|
clientData.menus = menus;
|
||||||
// Očistí volby jídel odkazující na indexy, které v aktuálním menu již neexistují.
|
// Očistí volby jídel odkazující na indexy, které v aktuálním menu již neexistují.
|
||||||
// Pokud se v odpovědi něco změnilo, promítne očistu i do uložených dat (self-heal).
|
// Pokud se v odpovědi něco změnilo, promítne očistu i do uložených dat (self-heal).
|
||||||
if (pruneStaleFoodChoices(clientData.choices, clientData.menus)) {
|
if (pruneStaleFoodChoices(clientData.choices, clientData.menus)) {
|
||||||
@@ -236,7 +253,8 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for
|
|||||||
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();
|
||||||
if (getIsWeekend(usedDate)) {
|
// O víkendu a u dočasně vyřazených podniků menu vůbec nenačítáme
|
||||||
|
if (getIsWeekend(usedDate) || isRestaurantDisabled(restaurant)) {
|
||||||
return {
|
return {
|
||||||
lastUpdate: now,
|
lastUpdate: now,
|
||||||
closed: true,
|
closed: true,
|
||||||
@@ -468,6 +486,9 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
|
|||||||
await initIfNeeded(usedDate, slot);
|
await initIfNeeded(usedDate, slot);
|
||||||
let data = await getClientData(usedDate, slot);
|
let data = await getClientData(usedDate, slot);
|
||||||
validateTrusted(data, login, trusted);
|
validateTrusted(data, login, trusted);
|
||||||
|
if (isRestaurantDisabled(locationKey)) {
|
||||||
|
throw new Error(`Podnik ${locationKey} je dočasně uzavřen`);
|
||||||
|
}
|
||||||
await validateFoodIndex(locationKey, foodIndex, date);
|
await validateFoodIndex(locationKey, foodIndex, date);
|
||||||
|
|
||||||
if (!slot || slot === MealSlot.OBED) {
|
if (!slot || slot === MealSlot.OBED) {
|
||||||
@@ -569,6 +590,37 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Volby, u kterých nemá smysl zadávat cenu ručně — cena je dána objednávkou nebo se neobědvá. */
|
||||||
|
export const CHOICES_WITHOUT_PRICE: LunchChoice[] = [LunchChoice.OBJEDNAVAM, LunchChoice.PIZZA, LunchChoice.NEOBEDVAM, LunchChoice.ROZHODUJI];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nastaví nebo odstraní cenu oběda u aktuálně vybrané možnosti uživatele.
|
||||||
|
*
|
||||||
|
* @param login login uživatele
|
||||||
|
* @param trusted příznak, zda se jedná o ověřeného uživatele
|
||||||
|
* @param price cena v haléřích, při nevyplnění se cena odstraní
|
||||||
|
* @param date datum, ke kterému se volba vztahuje
|
||||||
|
*/
|
||||||
|
export async function updatePrice(login: string, trusted: boolean, price?: number, date?: Date) {
|
||||||
|
if (price != null && (!Number.isInteger(price) || price < 0)) {
|
||||||
|
throw new Error(`Neplatná cena: ${price}`);
|
||||||
|
}
|
||||||
|
const usedDate = date ?? getToday();
|
||||||
|
const snapshot = await getClientData(usedDate);
|
||||||
|
validateTrusted(snapshot, login, trusted);
|
||||||
|
return storage.updateData<ClientData>(getDataKey(usedDate), (current) => {
|
||||||
|
const data = current ?? getEmptyData(date);
|
||||||
|
const userEntry = Object.entries(data.choices ?? {}).find(entry => entry[1][login] != null);
|
||||||
|
if (!userEntry) throw new Error("Cenu lze zadat jen k vybrané volbě");
|
||||||
|
if (CHOICES_WITHOUT_PRICE.includes(userEntry[0] as LunchChoice)) {
|
||||||
|
throw new Error("K této volbě nelze zadat cenu");
|
||||||
|
}
|
||||||
|
if (price == null) delete userEntry[1][login].price;
|
||||||
|
else userEntry[1][login].price = price;
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aktualizuje preferovaný čas odchodu strávníka.
|
* Aktualizuje preferovaný čas odchodu strávníka.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,505 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import getStorage from "./storage";
|
||||||
|
import { getStoreDishes, getStores } from "./stores";
|
||||||
|
import { Store, StoreMenu, StoreMenuDish, StoreMenuProvider, StoreMenuSource, StorePriceChange, StorePriceHistory, StorePriceHistoryEntry, StoreScrapeInfo, StoreScrapeState, StoreScrapeStatus } from "../../types/gen/types.gen";
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Klíč ve storage s nabídkami všech obchodů: název obchodu → záznamy dle služby.
|
||||||
|
* Nabídka se stahuje jen tehdy, když pro službu ještě žádná není (žádná expirace),
|
||||||
|
* nebo při ručním obnovení adminem.
|
||||||
|
*/
|
||||||
|
const STORE_MENUS_KEY = 'store_menus';
|
||||||
|
/** Historie cen: název obchodu → služba → verze (nejnovější první). Ukládají se jen rozdíly. */
|
||||||
|
const STORE_MENU_HISTORY_KEY = 'store_menu_history';
|
||||||
|
/** Kolik verzí historie cen se drží na obchod a službu. */
|
||||||
|
const MAX_HISTORY_ENTRIES = 200;
|
||||||
|
/** Kolikrát se po sobě automaticky zkusí stáhnout nabídku, než se služba pro obchod zablokuje. */
|
||||||
|
const MAX_AUTO_ATTEMPTS = 2;
|
||||||
|
/** Minimální odstup automatických pokusů po selhání (tj. nejvýše ~2× týdně). */
|
||||||
|
const RETRY_AFTER_MS = 3 * 24 * 60 * 60 * 1000;
|
||||||
|
/** Minimální rozestup požadavků na jednu službu — Bolt po ~24 rychlých dotazech vrací 429. */
|
||||||
|
const MIN_REQUEST_INTERVAL_MS = process.env.NODE_ENV === 'test' ? 0 : 1500;
|
||||||
|
/** Pauza služby po odpovědi 429, pokud služba neuvede Retry-After. */
|
||||||
|
const RATE_LIMIT_PAUSE_MS = 5 * 60 * 1000;
|
||||||
|
const BOLT_MENU_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getMenuCategories';
|
||||||
|
const WOLT_MENU_URL = 'https://consumer-api.wolt.com/consumer-api/consumer-assortment/v1/venues/slug/';
|
||||||
|
const DEVICE_ID = crypto.randomUUID();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kategorie, které nejsou hlavním jídlem — do kola štěstí je nezahrnujeme
|
||||||
|
* (polévky, nápoje, omáčky/dipy, přílohy, dezerty, přídavky, příbory apod.).
|
||||||
|
*/
|
||||||
|
const EXCLUDED_CATEGORY_REGEX = /pol[eé]vk|soup|n[aá]poj|drink|nealko|alkohol|\bpit[ií]|limon[aá]d|\bpiv[oa]\b|\bv[ií]n[oa]\b|\bk[aá]v[ay]\b|coffee|om[aá][cč]k|sauce|\bdip|p[řr][ií]loh|\bsides?\b|p[řr]edkrm|starter|appetizer|dezert|dessert|sladk[eé]|p[řr][ií]davk|p[řr][ií]plat|\badd-?ons?\b|\bextras?\b|p[řr][ií]bor|cutlery|\bobal|krabic|d[ěe]tsk|chutney|nakl[aá]dan|raita|\bnaa?n\b|parant?ha|chl[ée]b|pe[čc]ivo|^\W*(basmati\s+)?r[ýy][žz]e\W*$/i;
|
||||||
|
/** Jednotlivé položky mimo vyloučené kategorie, které nejsou hlavním jídlem (polévky, příplatky). */
|
||||||
|
const SOUP_ITEM_REGEX = /pol[eé]vk|v[ýy]var|soup|kulajd|gul[aá][sš]ovk|dr[šs][ťt]kov[aá]$|[čc]esne[čc]k|bor[šs][čc]|p[řr][ií]platek/i;
|
||||||
|
|
||||||
|
/** Uložený stav stahování nabídky obchodu od jedné služby. */
|
||||||
|
type ProviderRecord = {
|
||||||
|
/** URL podniku u služby — při změně URL se záznam zahodí */
|
||||||
|
url: string,
|
||||||
|
dishes?: StoreMenuDish[],
|
||||||
|
lastSuccessAt?: string,
|
||||||
|
lastAttemptAt?: string,
|
||||||
|
/** Počet neúspěšných pokusů od posledního úspěchu (resp. ručního obnovení) */
|
||||||
|
failures: number,
|
||||||
|
blocked: boolean,
|
||||||
|
lastError?: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoreMenuRecord = Partial<Record<StoreMenuProvider, ProviderRecord>>;
|
||||||
|
type StoreMenusData = Record<string, StoreMenuRecord>;
|
||||||
|
type StoreMenuHistoryData = Record<string, Partial<Record<StoreMenuProvider, StorePriceHistoryEntry[]>>>;
|
||||||
|
|
||||||
|
const PROVIDERS: StoreMenuProvider[] = [StoreMenuProvider.BOLT, StoreMenuProvider.WOLT];
|
||||||
|
|
||||||
|
/** Vytáhne ID podniku z URL Bolt Food (…/p/67293-ruds-pizza-skvrnany/). */
|
||||||
|
export function parseBoltProviderId(url: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (!u.hostname.endsWith('bolt.eu')) return undefined;
|
||||||
|
return u.pathname.match(/\/p\/(\d+)/)?.[1];
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vytáhne slug podniku z URL Wolt (…/restaurant/ruds-pizza-prazska). */
|
||||||
|
export function parseWoltSlug(url: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (!u.hostname.endsWith('wolt.com')) return undefined;
|
||||||
|
return u.pathname.match(/\/(?:restaurant|venue)\/([^/?#]+)/)?.[1];
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMainDish = (name: string, category?: string) =>
|
||||||
|
!(category && EXCLUDED_CATEGORY_REGEX.test(category)) && !SOUP_ITEM_REGEX.test(name);
|
||||||
|
|
||||||
|
/** Odstraní duplicity (stejný název) a prázdné názvy. */
|
||||||
|
function dedupe(dishes: StoreMenuDish[]): StoreMenuDish[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return dishes.filter(d => {
|
||||||
|
const key = d.name.trim().toLowerCase();
|
||||||
|
if (!key || seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoltItem = {
|
||||||
|
type: string,
|
||||||
|
id: number,
|
||||||
|
parent_id: number | null,
|
||||||
|
index?: number,
|
||||||
|
name?: { value?: string },
|
||||||
|
price?: { value?: number },
|
||||||
|
availability?: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stáhne nabídku podniku z Bolt Food — dostupné položky, hlavní jídla označená `isMain`. */
|
||||||
|
export async function fetchBoltMenu(providerId: string): Promise<StoreMenuDish[]> {
|
||||||
|
const res = await axios.get(BOLT_MENU_URL, {
|
||||||
|
params: {
|
||||||
|
provider_id: providerId,
|
||||||
|
version: 'FW.1.113',
|
||||||
|
language: 'cs-CZ',
|
||||||
|
country: 'cz',
|
||||||
|
device_name: 'web',
|
||||||
|
device_os_version: 'web',
|
||||||
|
deviceType: 'web',
|
||||||
|
session_id: DEVICE_ID,
|
||||||
|
distinct_id: `$device:${DEVICE_ID}`,
|
||||||
|
deviceId: DEVICE_ID,
|
||||||
|
},
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
if (res.data?.code !== 0) {
|
||||||
|
throw new Error(`Bolt API vrátilo kód ${res.data?.code}: ${res.data?.message}`);
|
||||||
|
}
|
||||||
|
const items: Record<string, BoltItem> = res.data?.data?.items ?? {};
|
||||||
|
const all = Object.values(items);
|
||||||
|
const categories = new Map(all.filter(i => i.type === 'category').map(c => [c.id, c]));
|
||||||
|
return dedupe(all
|
||||||
|
.filter(i => i.type === 'dish' && i.name?.value && i.availability !== 'out_of_stock')
|
||||||
|
.map(i => {
|
||||||
|
const category = i.parent_id != null ? categories.get(i.parent_id) : undefined;
|
||||||
|
return { dish: i, category };
|
||||||
|
})
|
||||||
|
.sort((a, b) => (a.category?.index ?? 0) - (b.category?.index ?? 0) || (a.dish.index ?? 0) - (b.dish.index ?? 0))
|
||||||
|
.map(({ dish, category }) => ({
|
||||||
|
name: dish.name!.value!.trim(),
|
||||||
|
price: dish.price?.value,
|
||||||
|
category: category?.name?.value?.trim(),
|
||||||
|
isMain: isMainDish(dish.name!.value!, category?.name?.value),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
type WoltAssortment = {
|
||||||
|
categories?: { name?: string, item_ids?: string[], subcategories?: { name?: string, item_ids?: string[] }[] }[],
|
||||||
|
items?: { id: string, name?: string, price?: number, disabled_info?: unknown, is_cutlery?: boolean }[],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stáhne nabídku podniku z Wolt — dostupné položky, hlavní jídla označená `isMain`. */
|
||||||
|
export async function fetchWoltMenu(slug: string): Promise<StoreMenuDish[]> {
|
||||||
|
const res = await axios.get<WoltAssortment>(`${WOLT_MENU_URL}${encodeURIComponent(slug)}/assortment`, {
|
||||||
|
params: { language: 'cs' },
|
||||||
|
headers: { Accept: 'application/json', 'Accept-Language': 'cs' },
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
const items = new Map((res.data?.items ?? []).map(i => [i.id, i]));
|
||||||
|
const dishes: StoreMenuDish[] = [];
|
||||||
|
for (const category of res.data?.categories ?? []) {
|
||||||
|
const groups = [{ name: category.name, item_ids: category.item_ids }, ...(category.subcategories ?? [])];
|
||||||
|
for (const group of groups) {
|
||||||
|
const categoryName = [category.name, group.name !== category.name ? group.name : undefined].filter(Boolean).join(' / ');
|
||||||
|
for (const id of group.item_ids ?? []) {
|
||||||
|
const item = items.get(id);
|
||||||
|
if (!item?.name || item.disabled_info || item.is_cutlery) continue;
|
||||||
|
dishes.push({
|
||||||
|
name: item.name.trim(),
|
||||||
|
// Wolt uvádí ceny v haléřích
|
||||||
|
price: item.price != null ? Math.round(item.price / 100) : undefined,
|
||||||
|
category: categoryName.trim() || undefined,
|
||||||
|
isMain: isMainDish(item.name, categoryName),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dedupe(dishes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrátí URL podniku u dané služby (první odpovídající). */
|
||||||
|
function providerUrl(store: Store, provider: StoreMenuProvider): string | undefined {
|
||||||
|
const parse = provider === StoreMenuProvider.BOLT ? parseBoltProviderId : parseWoltSlug;
|
||||||
|
return (store.urls ?? []).find(url => parse(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Služba odmítla požadavek kvůli omezení počtu dotazů (HTTP 429) — nejde o chybu podniku. */
|
||||||
|
export class RateLimitedError extends Error {
|
||||||
|
constructor(provider: StoreMenuProvider, public readonly until: number) {
|
||||||
|
super(`${provider === StoreMenuProvider.BOLT ? 'Bolt Food' : 'Wolt'} omezil počet dotazů (HTTP 429), další pokus po ${new Date(until).toLocaleTimeString('cs-CZ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fronta požadavků každé služby (sériově, s rozestupem) a případná pauza po 429. */
|
||||||
|
const providerQueue = new Map<StoreMenuProvider, Promise<unknown>>();
|
||||||
|
const providerPausedUntil = new Map<StoreMenuProvider, number>();
|
||||||
|
|
||||||
|
/** Délka pauzy dle hlavičky Retry-After (sekundy nebo datum), jinak výchozí. */
|
||||||
|
function retryAfterMs(header: unknown): number {
|
||||||
|
const value = Array.isArray(header) ? header[0] : header;
|
||||||
|
if (typeof value === 'string' || typeof value === 'number') {
|
||||||
|
const seconds = Number(value);
|
||||||
|
if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000;
|
||||||
|
const date = Date.parse(String(value));
|
||||||
|
if (!Number.isNaN(date) && date > Date.now()) return date - Date.now();
|
||||||
|
}
|
||||||
|
return RATE_LIMIT_PAUSE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stáhne nabídku přes frontu dané služby: požadavky jdou po jednom s rozestupem
|
||||||
|
* {@link MIN_REQUEST_INTERVAL_MS}; po 429 se služba pozastaví a další požadavky
|
||||||
|
* rovnou selžou s {@link RateLimitedError} (nezatěžujeme službu dalšími dotazy).
|
||||||
|
*/
|
||||||
|
function fetchProviderMenu(provider: StoreMenuProvider, url: string): Promise<StoreMenuDish[]> {
|
||||||
|
const run = async () => {
|
||||||
|
const pausedUntil = providerPausedUntil.get(provider) ?? 0;
|
||||||
|
if (Date.now() < pausedUntil) {
|
||||||
|
throw new RateLimitedError(provider, pausedUntil);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await (provider === StoreMenuProvider.BOLT
|
||||||
|
? fetchBoltMenu(parseBoltProviderId(url)!)
|
||||||
|
: fetchWoltMenu(parseWoltSlug(url)!));
|
||||||
|
} catch (e) {
|
||||||
|
if (axios.isAxiosError(e) && e.response?.status === 429) {
|
||||||
|
const until = Date.now() + retryAfterMs(e.response.headers?.['retry-after']);
|
||||||
|
providerPausedUntil.set(provider, until);
|
||||||
|
throw new RateLimitedError(provider, until);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
if (MIN_REQUEST_INTERVAL_MS > 0) await sleep(MIN_REQUEST_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const previous = providerQueue.get(provider) ?? Promise.resolve();
|
||||||
|
const result = previous.catch(() => undefined).then(run);
|
||||||
|
providerQueue.set(provider, result.catch(() => undefined));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pro testy — zruší pauzy a frontu služeb. */
|
||||||
|
export function resetProviderThrottle(): void {
|
||||||
|
providerQueue.clear();
|
||||||
|
providerPausedUntil.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Záznam služby pro aktuální URL obchodu — při změně URL začíná stav nanovo. */
|
||||||
|
function currentRecord(record: StoreMenuRecord | undefined, provider: StoreMenuProvider, url: string): ProviderRecord {
|
||||||
|
const existing = record?.[provider];
|
||||||
|
return existing && existing.url === url ? existing : { url, failures: 0, blocked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrapeState(rec: ProviderRecord): StoreScrapeState {
|
||||||
|
if (rec.dishes?.length) return StoreScrapeState.OK;
|
||||||
|
if (rec.blocked) return StoreScrapeState.BLOCKED;
|
||||||
|
return rec.failures > 0 ? StoreScrapeState.FAILED : StoreScrapeState.PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Smí se nabídka od služby stáhnout automaticky? Jen pokud chybí a služba není zablokovaná. */
|
||||||
|
function canAutoScrape(rec: ProviderRecord, now: number): boolean {
|
||||||
|
if (rec.dishes?.length || rec.blocked) return false;
|
||||||
|
return !rec.lastAttemptAt || now - Date.parse(rec.lastAttemptAt) >= RETRY_AFTER_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rozdíl dvou verzí nabídky: změněné ceny, nové a odebrané položky (porovnání dle názvu).
|
||||||
|
*/
|
||||||
|
export function diffMenus(before: StoreMenuDish[], after: StoreMenuDish[]): StorePriceChange[] {
|
||||||
|
const key = (d: StoreMenuDish) => d.name.trim().toLowerCase();
|
||||||
|
const old = new Map(before.map(d => [key(d), d]));
|
||||||
|
const changes: StorePriceChange[] = [];
|
||||||
|
for (const dish of after) {
|
||||||
|
const prev = old.get(key(dish));
|
||||||
|
old.delete(key(dish));
|
||||||
|
if (!prev) {
|
||||||
|
changes.push({ name: dish.name, ...(dish.price != null && { after: dish.price }) });
|
||||||
|
} else if (prev.price !== dish.price) {
|
||||||
|
changes.push({ name: dish.name, ...(prev.price != null && { before: prev.price }), ...(dish.price != null && { after: dish.price }) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const removed of old.values()) {
|
||||||
|
changes.push({ name: removed.name, ...(removed.price != null && { before: removed.price }) });
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zapíše novou verzi do historie cen — výchozí stav při prvním stažení, dále jen při změně. */
|
||||||
|
async function recordPriceHistory(storeName: string, provider: StoreMenuProvider, previous: ProviderRecord | undefined, dishes: StoreMenuDish[], at: string): Promise<void> {
|
||||||
|
const before = previous?.dishes;
|
||||||
|
const entry: StorePriceHistoryEntry = before?.length
|
||||||
|
? { at, itemCount: dishes.length, changes: diffMenus(before, dishes) }
|
||||||
|
: { at, initial: true, itemCount: dishes.length, changes: [] };
|
||||||
|
if (!entry.initial && entry.changes.length === 0) return;
|
||||||
|
await storage.updateData<StoreMenuHistoryData>(STORE_MENU_HISTORY_KEY, current => {
|
||||||
|
const all = current ?? {};
|
||||||
|
let existing = all[storeName]?.[provider] ?? [];
|
||||||
|
// Nabídka stažená před zavedením historie — její původní stav uložíme jako výchozí verzi
|
||||||
|
if (existing.length === 0 && !entry.initial && previous?.lastSuccessAt) {
|
||||||
|
existing = [{ at: previous.lastSuccessAt, initial: true, itemCount: before!.length, changes: [] }];
|
||||||
|
}
|
||||||
|
const entries = [entry, ...existing].slice(0, MAX_HISTORY_ENTRIES);
|
||||||
|
all[storeName] = { ...all[storeName], [provider]: entries };
|
||||||
|
return all;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provede jeden pokus o stažení nabídky a výsledek zapíše do storage.
|
||||||
|
* Při neúspěchu ponechá případnou dříve staženou nabídku; po {@link MAX_AUTO_ATTEMPTS}
|
||||||
|
* neúspěších v řadě službu pro obchod zablokuje (automaticky se už nezkouší).
|
||||||
|
*/
|
||||||
|
async function attemptScrape(storeName: string, provider: StoreMenuProvider, url: string, manual: boolean): Promise<ProviderRecord> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
let result: Partial<ProviderRecord>;
|
||||||
|
try {
|
||||||
|
const dishes = await fetchProviderMenu(provider, url);
|
||||||
|
if (!dishes.some(d => d.isMain)) {
|
||||||
|
throw new Error('Nabídka neobsahuje žádná hlavní jídla');
|
||||||
|
}
|
||||||
|
result = { dishes, lastSuccessAt: now, failures: 0, blocked: false, lastError: undefined };
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(`Nabídka podniku "${storeName}" (${provider}): stažení selhalo — ${e?.message ?? e}`);
|
||||||
|
result = { lastError: String(e?.message ?? e).slice(0, 300) };
|
||||||
|
if (e instanceof RateLimitedError) {
|
||||||
|
// Omezení dotazů není chyba podniku — nepočítá se do pokusů ani nespouští 3denní odklad
|
||||||
|
const data = await storage.updateData<StoreMenusData>(STORE_MENUS_KEY, current => {
|
||||||
|
const all = current ?? {};
|
||||||
|
const rec = currentRecord(all[storeName], provider, url);
|
||||||
|
all[storeName] = { ...all[storeName], [provider]: { ...rec, lastError: result.lastError } };
|
||||||
|
return all;
|
||||||
|
});
|
||||||
|
return data[storeName]![provider]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let previousRecord: ProviderRecord | undefined;
|
||||||
|
const data = await storage.updateData<StoreMenusData>(STORE_MENUS_KEY, current => {
|
||||||
|
const all = current ?? {};
|
||||||
|
const rec = currentRecord(all[storeName], provider, url);
|
||||||
|
previousRecord = rec;
|
||||||
|
const next: ProviderRecord = { ...rec, ...result, lastAttemptAt: now };
|
||||||
|
if (!result.dishes) {
|
||||||
|
next.failures = rec.failures + 1;
|
||||||
|
// Ruční obnovení službu nezablokuje — admin to může zkoušet dál
|
||||||
|
next.blocked = manual ? rec.blocked : next.failures >= MAX_AUTO_ATTEMPTS;
|
||||||
|
}
|
||||||
|
if (!next.lastError) delete next.lastError;
|
||||||
|
all[storeName] = { ...all[storeName], [provider]: next };
|
||||||
|
return all;
|
||||||
|
});
|
||||||
|
if (result.dishes) {
|
||||||
|
await recordPriceHistory(storeName, provider, previousRecord, result.dishes, now);
|
||||||
|
}
|
||||||
|
return data[storeName]![provider]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrátí historii cen obchodu po službách (nejnovější verze první). */
|
||||||
|
export async function getStoreMenuHistory(name: string): Promise<StorePriceHistory> {
|
||||||
|
const store = (await getStores()).find(s => s.name.toLowerCase() === name.trim().toLowerCase());
|
||||||
|
if (!store) {
|
||||||
|
throw new Error('Obchod nebyl nalezen');
|
||||||
|
}
|
||||||
|
const [historyData, menus] = await Promise.all([
|
||||||
|
storage.getData<StoreMenuHistoryData>(STORE_MENU_HISTORY_KEY),
|
||||||
|
storage.getData<StoreMenusData>(STORE_MENUS_KEY),
|
||||||
|
]);
|
||||||
|
const history = historyData?.[store.name] ?? {};
|
||||||
|
const record = menus?.[store.name];
|
||||||
|
return {
|
||||||
|
store: store.name,
|
||||||
|
providers: PROVIDERS
|
||||||
|
.map(provider => {
|
||||||
|
const entries = history[provider] ?? [];
|
||||||
|
const rec = record?.[provider];
|
||||||
|
// Nabídka stažená dřív, než existovala historie — její stav je výchozí verzí
|
||||||
|
if (entries.length === 0 && rec?.dishes?.length && rec.lastSuccessAt) {
|
||||||
|
return { provider, entries: [{ at: rec.lastSuccessAt, initial: true, itemCount: rec.dishes.length, changes: [] }] };
|
||||||
|
}
|
||||||
|
return { provider, entries };
|
||||||
|
})
|
||||||
|
.filter(p => p.entries.length > 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Souběžné požadavky na stejný obchod sdílí jedno stahování. */
|
||||||
|
const inFlight = new Map<string, Promise<StoreMenu>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí nabídku hlavních jídel obchodu pro kolo štěstí. Použije uloženou nabídku
|
||||||
|
* z Bolt Food (přednostně) nebo Wolt; pokud žádná není, zkusí ji stáhnout (s omezeným
|
||||||
|
* počtem pokusů). Jinak použije jídla z historie objednávek.
|
||||||
|
*
|
||||||
|
* @param name název obchodu ze seznamu povolených
|
||||||
|
*/
|
||||||
|
export async function getStoreMenu(name: string): Promise<StoreMenu> {
|
||||||
|
const store = (await getStores()).find(s => s.name.toLowerCase() === name.trim().toLowerCase());
|
||||||
|
if (!store) {
|
||||||
|
throw new Error('Obchod nebyl nalezen');
|
||||||
|
}
|
||||||
|
const pending = inFlight.get(store.name);
|
||||||
|
if (pending) return pending;
|
||||||
|
const promise = resolveStoreMenu(store).finally(() => inFlight.delete(store.name));
|
||||||
|
inFlight.set(store.name, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveStoreMenu(store: Store): Promise<StoreMenu> {
|
||||||
|
const record = (await storage.getData<StoreMenusData>(STORE_MENUS_KEY))?.[store.name];
|
||||||
|
const candidates = PROVIDERS
|
||||||
|
.map(provider => ({ provider, url: providerUrl(store, provider) }))
|
||||||
|
.filter((c): c is { provider: StoreMenuProvider, url: string } => !!c.url)
|
||||||
|
.map(c => ({ ...c, rec: currentRecord(record, c.provider, c.url) }));
|
||||||
|
|
||||||
|
const stored = candidates.find(c => c.rec.dishes?.length);
|
||||||
|
if (stored) {
|
||||||
|
return { store: store.name, source: stored.provider, dishes: stored.rec.dishes! };
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
for (const c of candidates.filter(c => canAutoScrape(c.rec, now))) {
|
||||||
|
const rec = await attemptScrape(store.name, c.provider, c.url, false);
|
||||||
|
if (rec.dishes?.length) {
|
||||||
|
return { store: store.name, source: c.provider, dishes: rec.dishes };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Záloha — historie objednávek
|
||||||
|
const history = (await getStoreDishes()).find(s => s.store === store.name)?.dishes ?? [];
|
||||||
|
return {
|
||||||
|
store: store.name,
|
||||||
|
source: history.length > 0 ? StoreMenuSource.HISTORY : StoreMenuSource.NONE,
|
||||||
|
dishes: history.map(dishName => ({ name: dishName, isMain: true })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStatus(provider: StoreMenuProvider, rec: ProviderRecord): StoreScrapeStatus {
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
url: rec.url,
|
||||||
|
state: scrapeState(rec),
|
||||||
|
dishCount: rec.dishes?.filter(d => d.isMain !== false).length ?? 0,
|
||||||
|
failures: rec.failures,
|
||||||
|
...(rec.lastSuccessAt && { lastSuccessAt: rec.lastSuccessAt }),
|
||||||
|
...(rec.lastAttemptAt && { lastAttemptAt: rec.lastAttemptAt }),
|
||||||
|
...(rec.lastError && { lastError: rec.lastError }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrapeInfo(store: Store, record: StoreMenuRecord | undefined): StoreScrapeInfo {
|
||||||
|
const providers: StoreScrapeStatus[] = [];
|
||||||
|
for (const provider of PROVIDERS) {
|
||||||
|
const url = providerUrl(store, provider);
|
||||||
|
if (url) providers.push(toStatus(provider, currentRecord(record, provider, url)));
|
||||||
|
}
|
||||||
|
return { store: store.name, providers };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrátí stav stahování nabídek pro všechny obchody (pro správu obchodů). */
|
||||||
|
export async function getStoreMenuStatus(): Promise<StoreScrapeInfo[]> {
|
||||||
|
const [stores, data] = await Promise.all([getStores(), storage.getData<StoreMenusData>(STORE_MENUS_KEY)]);
|
||||||
|
return stores.map(store => scrapeInfo(store, data?.[store.name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ručně znovu stáhne nabídku obchodu od dané služby (admin). Při úspěchu nahradí
|
||||||
|
* uloženou nabídku a zruší zablokování; při neúspěchu ponechá původní nabídku.
|
||||||
|
*/
|
||||||
|
export async function refreshStoreMenu(name: string, provider: StoreMenuProvider, heslo: string): Promise<StoreScrapeInfo> {
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||||
|
if (!adminPassword || heslo !== adminPassword) {
|
||||||
|
throw new Error('UNAUTHORIZED');
|
||||||
|
}
|
||||||
|
if (!PROVIDERS.includes(provider)) {
|
||||||
|
throw new Error('Nepodporovaná služba');
|
||||||
|
}
|
||||||
|
const store = (await getStores()).find(s => s.name.toLowerCase() === name.trim().toLowerCase());
|
||||||
|
if (!store) {
|
||||||
|
throw new Error('Obchod nebyl nalezen');
|
||||||
|
}
|
||||||
|
const url = providerUrl(store, provider);
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(`Obchod nemá URL pro ${provider === StoreMenuProvider.BOLT ? 'Bolt Food' : 'Wolt'}`);
|
||||||
|
}
|
||||||
|
await attemptScrape(store.name, provider, url, true);
|
||||||
|
const data = await storage.getData<StoreMenusData>(STORE_MENUS_KEY);
|
||||||
|
return scrapeInfo(store, data?.[store.name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Přejmenuje/odebere uložené nabídky obchodu (volá se při úpravě seznamu obchodů). */
|
||||||
|
export async function renameStoreMenus(oldName: string, newName?: string): Promise<void> {
|
||||||
|
await storage.updateData<StoreMenuHistoryData>(STORE_MENU_HISTORY_KEY, current => {
|
||||||
|
const all = current ?? {};
|
||||||
|
const key = Object.keys(all).find(k => k.toLowerCase() === oldName.trim().toLowerCase());
|
||||||
|
if (!key) return all;
|
||||||
|
const record = all[key];
|
||||||
|
delete all[key];
|
||||||
|
if (newName) all[newName] = record;
|
||||||
|
return all;
|
||||||
|
});
|
||||||
|
await storage.updateData<StoreMenusData>(STORE_MENUS_KEY, current => {
|
||||||
|
const all = current ?? {};
|
||||||
|
const key = Object.keys(all).find(k => k.toLowerCase() === oldName.trim().toLowerCase());
|
||||||
|
if (!key) return all;
|
||||||
|
const record = all[key];
|
||||||
|
delete all[key];
|
||||||
|
if (newName) all[newName] = record;
|
||||||
|
return all;
|
||||||
|
});
|
||||||
|
}
|
||||||
+57
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Store } from "../../types/gen/types.gen";
|
import { ClientData, Store } from "../../types/gen/types.gen";
|
||||||
import getStorage from "./storage";
|
import getStorage from "./storage";
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
@@ -151,3 +151,59 @@ export async function removeStore(name: string, heslo: string): Promise<Store[]>
|
|||||||
await storage.setData(STORES_KEY, updated);
|
await storage.setData(STORES_KEY, updated);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Maximální počet jídel vrácených pro jeden obchod v {@link getStoreDishes}. */
|
||||||
|
const MAX_DISHES_PER_STORE = 40;
|
||||||
|
|
||||||
|
/** Rozpoznání polévek v poznámkách objednávek — ty se do kola štěstí nezahrnují. */
|
||||||
|
/** Jídla historicky objednaná u daného obchodu. */
|
||||||
|
export type StoreDishes = { store: string, dishes: string[] };
|
||||||
|
|
||||||
|
const SOUP_REGEX = /pol[eé]vk|v[ýy]var|soup|\bpho\b|ramen|tom yum|kulajda|gul[aá][sš]ovka/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí pro každý povolený obchod seznam jídel, která si u něj uživatelé v minulosti
|
||||||
|
* objednali (z poznámek členů skupinových objednávek). Slouží jako záloha pro druhé
|
||||||
|
* kolo „kola štěstí", pokud se nabídku z Bolt Food/Wolt nepodaří stáhnout.
|
||||||
|
* Jídla jsou seřazena dle četnosti, bez duplicit a bez polévek.
|
||||||
|
*/
|
||||||
|
export async function getStoreDishes(): Promise<StoreDishes[]> {
|
||||||
|
const stores = await getStores();
|
||||||
|
const counts = new Map<string, Map<string, { name: string, count: number }>>();
|
||||||
|
for (const store of stores) {
|
||||||
|
counts.set(store.name.toLowerCase(), new Map());
|
||||||
|
}
|
||||||
|
const keys = await storage.listKeys('_extra');
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!key.endsWith('_extra')) continue;
|
||||||
|
const data = await storage.getData<ClientData>(key);
|
||||||
|
for (const group of data?.groups ?? []) {
|
||||||
|
const dishes = counts.get(group.name?.toLowerCase());
|
||||||
|
if (!dishes) continue;
|
||||||
|
for (const member of Object.values(group.members ?? {})) {
|
||||||
|
const dish = normalizeDish(member?.note);
|
||||||
|
if (!dish || SOUP_REGEX.test(dish)) continue;
|
||||||
|
const dishKey = dish.toLowerCase();
|
||||||
|
const existing = dishes.get(dishKey);
|
||||||
|
if (existing) existing.count++;
|
||||||
|
else dishes.set(dishKey, { name: dish, count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stores.map(store => ({
|
||||||
|
store: store.name,
|
||||||
|
dishes: [...counts.get(store.name.toLowerCase())!.values()]
|
||||||
|
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
|
||||||
|
.slice(0, MAX_DISHES_PER_STORE)
|
||||||
|
.map(d => d.name),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Očistí poznámku člena objednávky (mezery, číslo položky z menu typu „28. ") na název jídla; poznámky nezačínající písmenem/číslicí zahodí. */
|
||||||
|
function normalizeDish(note?: string): string | undefined {
|
||||||
|
if (!note) return undefined;
|
||||||
|
const cleaned = note.replace(/\s+/g, ' ').replace(/^\d+\s*[.)]\s*/, '').trim();
|
||||||
|
// Poznámky typu „⬇️ totéž" nebo „(bez cibule)" nejsou názvy jídel
|
||||||
|
if (!/^[\p{L}\p{N}]/u.test(cleaned)) return undefined;
|
||||||
|
return cleaned.length >= 2 && cleaned.length <= 120 ? cleaned : undefined;
|
||||||
|
}
|
||||||
@@ -357,8 +357,15 @@ describe('zloděj', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('poražení zloděje dá odměnu a zvýší statistiku', async () => {
|
test('poražení zloděje dá odměnu a zvýší statistiku', async () => {
|
||||||
await seed(USER, { caught: 0, coins: 10, goldenCaught: 0 });
|
const now = 4_000_000;
|
||||||
const stats = await defeatThief(USER);
|
const day = formatDate(new Date(now));
|
||||||
|
// Denní úkol pevně nastavíme na jiný druh – jinak by se v některé dny
|
||||||
|
// (kdy je úkolem "poraž 1 zloděje") k odměně přičetla i jeho výplata.
|
||||||
|
await seed(USER, {
|
||||||
|
caught: 0, coins: 10, goldenCaught: 0,
|
||||||
|
daily: { day, taskId: 'wasp', progress: 0, target: 15, done: false },
|
||||||
|
});
|
||||||
|
const stats = await defeatThief(USER, now);
|
||||||
expect(stats.coins).toBe(10 + THIEF_REWARD);
|
expect(stats.coins).toBe(10 + THIEF_REWARD);
|
||||||
expect(stats.thievesDefeated).toBe(1);
|
expect(stats.thievesDefeated).toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { resetMemoryStorage } from '../storage/memory';
|
||||||
|
import { getConfettiCharacters, getConfettiImage } from '../confetti';
|
||||||
|
|
||||||
|
jest.mock('axios');
|
||||||
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMemoryStorage();
|
||||||
|
mockedAxios.get.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('postavy odkazují jen na cachující proxy serveru', () => {
|
||||||
|
const images = getConfettiCharacters().flatMap(c => c.images);
|
||||||
|
expect(images.length).toBeGreaterThan(0);
|
||||||
|
expect(images.every(u => /^\/api\/confetti\/image\/[0-9a-f]{16}$/.test(u))).toBe(true);
|
||||||
|
// ID jsou stabilní (hash URL) a unikátní
|
||||||
|
expect(new Set(images).size).toBe(images.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
const idAt = (i: number) => getConfettiCharacters().flatMap(c => c.images)[i].split('/').pop()!;
|
||||||
|
|
||||||
|
test('obrázek stáhne jen jednou, pak ho vrací ze storage', async () => {
|
||||||
|
mockedAxios.get.mockResolvedValue({ data: Buffer.from('PNGDATA'), headers: { 'content-type': 'image/png' } });
|
||||||
|
const first = await getConfettiImage(idAt(0));
|
||||||
|
const second = await getConfettiImage(idAt(0));
|
||||||
|
expect(first?.contentType).toBe('image/png');
|
||||||
|
expect(second?.data.toString()).toBe('PNGDATA');
|
||||||
|
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neznámé ID ani ne-obrázek nevrátí', async () => {
|
||||||
|
expect(await getConfettiImage('ffffffffffffffff')).toBeUndefined();
|
||||||
|
mockedAxios.get.mockResolvedValue({ data: Buffer.from('<html>'), headers: { 'content-type': 'text/html' } });
|
||||||
|
expect(await getConfettiImage(idAt(1))).toBeUndefined();
|
||||||
|
// po selhání se hned znovu nestahuje
|
||||||
|
expect(await getConfettiImage(idAt(1))).toBeUndefined();
|
||||||
|
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { DrawingGame, DrawState, Stroke, toWord, isCorrectGuess, ROUND_MS, REVEAL_MS, GUESS_MAX_POINTS, DRAWER_POINTS_PER_GUESS, FALLBACK_WORDS } from '../drawingGame';
|
||||||
|
import { GameError } from '../minigames';
|
||||||
|
import { fakeClock } from './helpers/fakeClock';
|
||||||
|
|
||||||
|
describe('kreslení — slova z menu', () => {
|
||||||
|
test.each([
|
||||||
|
['Plzeňský hovězí guláš s kynutým knedlíkem (1, 3, 7)', 'Plzeňský hovězí guláš', ['gulas']],
|
||||||
|
['Tortilla wrap s trhaným vepřovým masem, slaninou a čedarem', 'Tortilla wrap', ['tortilla', 'wrap']],
|
||||||
|
['Čevabčiči, brambory, cibule, hořčice (1, 3, 7)', 'Čevabčiči', ['cevabcici']],
|
||||||
|
['Sýrová pizza, čerstvá bazalka (1, 7)', 'Sýrová pizza', ['pizza']],
|
||||||
|
['Medailonky z panenky se slaninovým kroupotem', 'Medailonky z panenky', ['medailonky', 'panenky']],
|
||||||
|
])('%s', (dish, display, keywords) => {
|
||||||
|
expect(toWord(dish)).toEqual({ display, keywords });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hádání toleruje diakritiku, velikost písmen a koncovky', () => {
|
||||||
|
const gulas = toWord('Plzeňský hovězí guláš');
|
||||||
|
expect(isCorrectGuess('gulas', gulas)).toBe(true);
|
||||||
|
expect(isCorrectGuess(' GULÁŠ ', gulas)).toBe(true);
|
||||||
|
expect(isCorrectGuess('je to guláš?', gulas)).toBe(true);
|
||||||
|
expect(isCorrectGuess('hovězí', gulas)).toBe(false);
|
||||||
|
expect(isCorrectGuess('gul', gulas)).toBe(false);
|
||||||
|
expect(isCorrectGuess('knedliky', toWord('Knedlík'))).toBe(true);
|
||||||
|
expect(isCorrectGuess('knedliky', toWord('Bramborový knedlík'))).toBe(true);
|
||||||
|
expect(isCorrectGuess('pizzu', toWord('Sýrová pizza'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setup(dishes: string[] = ['Plzeňský hovězí guláš', 'Sýrová pizza', 'Smažený řízek']) {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const states: DrawState[] = [];
|
||||||
|
const words: { login: string, word: string }[] = [];
|
||||||
|
const strokes: Stroke[] = [];
|
||||||
|
let clears = 0;
|
||||||
|
const game = new DrawingGame(
|
||||||
|
s => states.push(s),
|
||||||
|
(login, word) => words.push({ login, word }),
|
||||||
|
s => strokes.push(s),
|
||||||
|
() => { clears++; },
|
||||||
|
async () => dishes,
|
||||||
|
2,
|
||||||
|
clock,
|
||||||
|
);
|
||||||
|
const last = () => states[states.length - 1];
|
||||||
|
const player = (login: string) => last().players.find(p => p.login === login)!;
|
||||||
|
const startGame = async (...logins: string[]) => {
|
||||||
|
logins.forEach(l => game.join(l));
|
||||||
|
await game.start(logins[0]);
|
||||||
|
};
|
||||||
|
return { game, clock, states, words, strokes, last, player, startGame, clears: () => clears };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('kreslení — průběh hry', () => {
|
||||||
|
test('slovo dostane jen kreslíř, ostatní vidí masku', async () => {
|
||||||
|
const ctx = setup();
|
||||||
|
await ctx.startGame('alice', 'bob');
|
||||||
|
const state = ctx.last();
|
||||||
|
expect(state).toMatchObject({ phase: 'drawing', drawer: 'alice', round: 1, totalRounds: 2 });
|
||||||
|
expect(ctx.words).toHaveLength(1);
|
||||||
|
expect(ctx.words[0].login).toBe('alice');
|
||||||
|
expect(JSON.stringify(ctx.states)).not.toContain(ctx.words[0].word);
|
||||||
|
expect(state.hint).toMatch(/^[_ ]+$/);
|
||||||
|
expect(ctx.game.wordFor('bob')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('správný tip dá body hádajícímu i kreslíři a ukončí kolo, když uhodli všichni', async () => {
|
||||||
|
const ctx = setup(['Plzeňský hovězí guláš', 'Plzeňský hovězí guláš']);
|
||||||
|
await ctx.startGame('alice', 'bob');
|
||||||
|
ctx.game.guess('bob', 'svíčková');
|
||||||
|
expect(ctx.last().messages.at(-1)).toEqual({ login: 'bob', text: 'svíčková' });
|
||||||
|
ctx.game.guess('bob', 'guláš');
|
||||||
|
expect(ctx.player('bob').score).toBe(GUESS_MAX_POINTS);
|
||||||
|
expect(ctx.player('alice').score).toBe(DRAWER_POINTS_PER_GUESS);
|
||||||
|
expect(ctx.last()).toMatchObject({ phase: 'reveal', answer: 'Plzeňský hovězí guláš' });
|
||||||
|
ctx.clock.advance(REVEAL_MS);
|
||||||
|
expect(ctx.last()).toMatchObject({ phase: 'drawing', drawer: 'bob', round: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kreslíř a ti, kdo už uhodli, nemohou psát do chatu', async () => {
|
||||||
|
const ctx = setup(['Plzeňský hovězí guláš', 'Plzeňský hovězí guláš']);
|
||||||
|
await ctx.startGame('alice', 'bob', 'carol');
|
||||||
|
const before = ctx.last().messages.length;
|
||||||
|
ctx.game.guess('alice', 'je to guláš');
|
||||||
|
ctx.game.guess('bob', 'guláš');
|
||||||
|
ctx.game.guess('bob', 'guláš s knedlíkem!');
|
||||||
|
const texts = ctx.last().messages.slice(before).map(m => m.text);
|
||||||
|
expect(texts).toEqual(['bob uhodl(a)! 🎉']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pozdější tip má méně bodů a nápověda postupně odkrývá písmena', async () => {
|
||||||
|
const ctx = setup(['Plzeňský hovězí guláš', 'Plzeňský hovězí guláš']);
|
||||||
|
await ctx.startGame('alice', 'bob', 'carol');
|
||||||
|
ctx.clock.advance(ROUND_MS * 0.75);
|
||||||
|
expect(ctx.last().hint!.replace(/[_ ]/g, '')).toHaveLength(2);
|
||||||
|
ctx.game.guess('bob', 'gulas');
|
||||||
|
expect(ctx.player('bob').score).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('po vypršení času kolo skončí a po všech kolech hra', async () => {
|
||||||
|
const ctx = setup();
|
||||||
|
await ctx.startGame('alice', 'bob');
|
||||||
|
ctx.clock.advance(ROUND_MS);
|
||||||
|
expect(ctx.last().phase).toBe('reveal');
|
||||||
|
ctx.clock.advance(REVEAL_MS + ROUND_MS + REVEAL_MS);
|
||||||
|
expect(ctx.last().phase).toBe('finished');
|
||||||
|
expect(ctx.last().players.every(p => p.place !== undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kreslit smí jen kreslíř a neplatné tahy se zahodí', async () => {
|
||||||
|
const ctx = setup();
|
||||||
|
await ctx.startGame('alice', 'bob');
|
||||||
|
const stroke = { color: '#ff0000', size: 4, points: [[10, 10], [20, 20]], start: true };
|
||||||
|
ctx.game.stroke('bob', stroke);
|
||||||
|
ctx.game.stroke('alice', { ...stroke, color: 'red' });
|
||||||
|
ctx.game.stroke('alice', { ...stroke, points: [[5000, 1]] });
|
||||||
|
expect(ctx.strokes).toHaveLength(0);
|
||||||
|
ctx.game.stroke('alice', stroke);
|
||||||
|
expect(ctx.strokes).toHaveLength(1);
|
||||||
|
expect(ctx.game.getStrokes()).toHaveLength(1);
|
||||||
|
ctx.game.clear('alice');
|
||||||
|
expect(ctx.game.getStrokes()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bez menu se hraje se záložními slovy', async () => {
|
||||||
|
const ctx = setup([]);
|
||||||
|
await ctx.startGame('alice', 'bob');
|
||||||
|
expect(FALLBACK_WORDS.map(w => toWord(w).display)).toContain(ctx.words[0].word);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hru spustí jen zakladatel s dostatkem hráčů', async () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.game.join('alice');
|
||||||
|
await expect(ctx.game.start('alice')).rejects.toThrow(GameError);
|
||||||
|
ctx.game.join('bob');
|
||||||
|
await expect(ctx.game.start('bob')).rejects.toThrow(GameError);
|
||||||
|
});
|
||||||
|
});
|
||||||
+305
@@ -0,0 +1,305 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html class="no-js" prefix="og: https://ogp.me/ns#" lang="cs">
|
||||||
|
<head><link rel="preconnect" href="https://duyn491kcolsw.cloudfront.net" crossorigin><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><meta charset="utf-8"><link rel="shortcut icon" href="https://duyn491kcolsw.cloudfront.net/files/1x/1xh/1xh0pi.ico?ph=59d3cce388"><link rel="apple-touch-icon" href="https://duyn491kcolsw.cloudfront.net/files/1x/1xh/1xh0pi.ico?ph=59d3cce388"><link rel="icon" href="https://duyn491kcolsw.cloudfront.net/files/1x/1xh/1xh0pi.ico?ph=59d3cce388">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Restaurace :: Equifarm</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="msapplication-tap-highlight" content="no">
|
||||||
|
|
||||||
|
<link href="https://duyn491kcolsw.cloudfront.net/files/0r/0rk/0rklmm.css?ph=59d3cce388" media="print" rel="stylesheet">
|
||||||
|
<link href="https://duyn491kcolsw.cloudfront.net/files/4f/4fk/4fkxao.css?ph=59d3cce388" media="screen and (min-width:100000em)" rel="stylesheet" data-type="cq" disabled>
|
||||||
|
<link rel="stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/01/018/018gum.css?ph=59d3cce388"><link rel="stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/0k/0k1/0k1ucs.css?ph=59d3cce388" media="screen and (min-width:37.5em)"><link rel="stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/0o/0on/0onzbj.css?ph=59d3cce388" data-wnd_color_scheme_file=""><link rel="stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/34/34q/34qmjb.css?ph=59d3cce388" data-wnd_color_scheme_desktop_file="" media="screen and (min-width:37.5em)" disabled=""><link rel="stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/1j/1jo/1jorv9.css?ph=59d3cce388" data-wnd_additive_color_file=""><link rel="stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/48/48g/48gira.css?ph=59d3cce388" data-wnd_typography_file=""><link rel="stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/2b/2b9/2b9a9p.css?ph=59d3cce388" data-wnd_typography_desktop_file="" media="screen and (min-width:37.5em)" disabled=""><script>(()=>{let e=!1;const t=()=>{if(!e&&window.innerWidth>=600){for(let e=0,t=document.querySelectorAll('head > link[href*="css"][media="screen and (min-width:37.5em)"]');e<t.length;e++)t[e].removeAttribute("disabled");e=!0}};t(),window.addEventListener("resize",t),"container"in document.documentElement.style||fetch(document.querySelector('head > link[data-type="cq"]').getAttribute("href")).then((e=>{e.text().then((e=>{const t=document.createElement("style");document.head.appendChild(t),t.appendChild(document.createTextNode(e)),import("https://duyn491kcolsw.cloudfront.net/client/js.polyfill/container-query-polyfill.modern.js").then((()=>{let e=setInterval((function(){document.body&&(document.body.classList.add("cq-polyfill-loaded"),clearInterval(e))}),100)}))}))}))})()</script>
|
||||||
|
<link rel="preload stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/2m/2m8/2m8msc.css?ph=59d3cce388" as="style"><meta name="description" content="Otevírací dobapo – pá 10.00 - 14.00obědy 10.00 - 13:45"><meta name="keywords" content=""><meta name="generator" content="Webnode 2"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="black"><meta name="format-detection" content="telephone=no">
|
||||||
|
|
||||||
|
<meta property="og:url" content="https://www.equifarm.cz/restaurace-techtower/"><meta property="og:title" content="Restaurace :: Equifarm"><meta property="og:type" content="article"><meta property="og:description" content="Otevírací dobapo – pá 10.00 - 14.00obědy 10.00 - 13:45"><meta property="og:site_name" content="Equifarm"><meta property="og:article:published_time" content="2026-10-05 00:00:00"><meta name="robots" content="index,follow"><link rel="canonical" href="https://www.equifarm.cz/restaurace-techtower/"><script>window.checkAndChangeSvgColor=function(c){try{var a=document.getElementById(c);if(a){c=[["border","borderColor"],["outline","outlineColor"],["color","color"]];for(var h,b,d,f=[],e=0,m=c.length;e<m;e++)if(h=window.getComputedStyle(a)[c[e][1]].replace(/\s/g,"").match(/^rgb[a]?\(([0-9]{1,3}),([0-9]{1,3}),([0-9]{1,3})/i)){b="";for(var g=1;3>=g;g++)b+=("0"+parseInt(h[g],10).toString(16)).slice(-2);"0"===b.charAt(0)&&(d=parseInt(b.substr(0,2),16),d=Math.max(16,d),b=d.toString(16)+b.slice(-4));f.push(c[e][0]+"="+b)}if(f.length){var k=a.getAttribute("data-src"),l=k+(0>k.indexOf("?")?"?":"&")+f.join("&");a.src!=l&&(a.src=l,a.outerHTML=a.outerHTML)}}}catch(n){}};</script></head>
|
||||||
|
<body class="l wt-page ac-i l-default l-d-none b-btn-sq b-btn-s-l b-btn-dn b-btn-bw-1 img-d-n img-t-o img-h-z line-solid b-e-ds lbox-d c-s-s wnd-fe"><noscript>
|
||||||
|
<iframe
|
||||||
|
src="https://www.googletagmanager.com/ns.html?id=GTM-542MMSL"
|
||||||
|
height="0"
|
||||||
|
width="0"
|
||||||
|
style="display:none;visibility:hidden"
|
||||||
|
>
|
||||||
|
</iframe>
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<div class="wnd-page l-page cs-gray ac-none t-t-fs-l t-t-fw-l t-t-sp-n t-t-d-n t-s-fs-m t-s-fw-l t-s-sp-n t-s-d-n t-p-fs-s t-p-fw-m t-p-sp-n t-h-fs-m t-h-fw-l t-h-sp-n t-bq-fs-s t-bq-fw-l t-bq-sp-n t-bq-d-q t-btn-fw-l t-nav-fw-l t-pd-fw-s t-nav-tt-u">
|
||||||
|
<div class="l-w t cf t-30">
|
||||||
|
<div class="l-bg cf">
|
||||||
|
<div class="s-bg-l">
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<header class="l-h cf">
|
||||||
|
<div class="sw cf">
|
||||||
|
<div class="sw-c cf"><section data-space="true" class="s s-hn s-hn-default wnd-mt-classic wnd-na-c logo-classic sc-ml wnd-background-pattern wnd-w-wider wnd-nh-m hm-hidden wnd-nav-border menu-default-underline">
|
||||||
|
<div class="s-w">
|
||||||
|
<div class="s-o">
|
||||||
|
|
||||||
|
<div class="s-bg">
|
||||||
|
<div class="s-bg-l wnd-background-pattern bgpos-center-center bgatt-scroll" style="background-image:url('https://duyn491kcolsw.cloudfront.net/files/17/17i/17ib2g.png?ph=59d3cce388')">
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-w h-f wnd-fixed">
|
||||||
|
|
||||||
|
<div class="n-l">
|
||||||
|
<div class="s-c menu-nav">
|
||||||
|
<div class="logo-block">
|
||||||
|
<div class="b b-l logo logo-default logo-nb proxima-nova logo-32 wnd-logo-with-text wnd-iar-1-1 b-ls-l" id="wnd_LogoBlock_193721444" data-wnd_mvc_type="wnd.fe.LogoBlock">
|
||||||
|
<div class="b-l-c logo-content">
|
||||||
|
<a class="b-l-link logo-link" href="/">
|
||||||
|
|
||||||
|
<div class="b-l-image logo-image">
|
||||||
|
<div class="b-l-image-w logo-image-cell">
|
||||||
|
<picture><source type="image/webp" srcset="https://59d3cce388.clvaw-cdnwnd.com/bd8e4bd40b5bb1412d0aee3843c4e306/200000384-5e4ab5e4ae/450/EQF_Logo%20%28orig.%29.webp?ph=59d3cce388 366w, https://59d3cce388.clvaw-cdnwnd.com/bd8e4bd40b5bb1412d0aee3843c4e306/200000384-5e4ab5e4ae/700/EQF_Logo%20%28orig.%29.webp?ph=59d3cce388 366w, https://59d3cce388.clvaw-cdnwnd.com/bd8e4bd40b5bb1412d0aee3843c4e306/200000384-5e4ab5e4ae/EQF_Logo%20%28orig.%29.webp?ph=59d3cce388 366w" sizes="(min-width: 600px) 450px, (min-width: 360px) calc(100vw * 0.8), 100vw" ><img src="https://59d3cce388.clvaw-cdnwnd.com/bd8e4bd40b5bb1412d0aee3843c4e306/200000003-2ad852ad86/EQF_Logo%20%28orig.%29.png?ph=59d3cce388" alt="" width="366" height="356" class="wnd-logo-img" ></picture>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="b-l-br logo-br"></div>
|
||||||
|
|
||||||
|
<div class="b-l-text logo-text-wrapper">
|
||||||
|
<div class="b-l-text-w logo-text">
|
||||||
|
<span class="b-l-text-c logo-text-cell">Equifarm, s.r.o.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="menu-slider">
|
||||||
|
<div id="menu-block">
|
||||||
|
<nav id="menu"><div class="menu-font menu-wrapper">
|
||||||
|
<a href="#" class="menu-close" rel="nofollow" title="Close Menu"></a>
|
||||||
|
<ul role="menubar" aria-label="Menu" class="level-1">
|
||||||
|
<li role="none" class="wnd-homepage">
|
||||||
|
<a class="menu-item" role="menuitem" href="/"><span class="menu-item-text">Úvod</span></a>
|
||||||
|
|
||||||
|
</li><li role="none" class="wnd-with-submenu">
|
||||||
|
<a class="menu-item" role="menuitem" href="/firemni-stravovani/"><span class="menu-item-text">Firemní stravování</span></a>
|
||||||
|
<ul role="menubar" aria-label="Menu" class="level-2">
|
||||||
|
<li role="none">
|
||||||
|
<a class="menu-item" role="menuitem" href="/specialiste-na-japonskou-kuchyni/"><span class="menu-item-text">Specialisté na japonskou kuchyni</span></a>
|
||||||
|
|
||||||
|
</li><li role="none">
|
||||||
|
<a class="menu-item" role="menuitem" href="/vlastni-cukrarska-vyroba/"><span class="menu-item-text">Vlastní cukrářská výroba</span></a>
|
||||||
|
|
||||||
|
</li><li role="none">
|
||||||
|
<a class="menu-item" role="menuitem" href="/reference/"><span class="menu-item-text">Reference</span></a>
|
||||||
|
|
||||||
|
</li><li role="none">
|
||||||
|
<a class="menu-item" role="menuitem" href="/kontakty2/"><span class="menu-item-text">Kontakty </span></a>
|
||||||
|
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li><li role="none">
|
||||||
|
<a class="menu-item" role="menuitem" href="/catering-a-eventy/"><span class="menu-item-text">Catering</span></a>
|
||||||
|
|
||||||
|
</li><li role="none" class="wnd-with-submenu">
|
||||||
|
<a class="menu-item" role="menuitem" href="/rekreacni-areal2/"><span class="menu-item-text">Rekreační areál</span></a>
|
||||||
|
<ul role="menubar" aria-label="Menu" class="level-2">
|
||||||
|
<li role="none">
|
||||||
|
<a class="menu-item" role="menuitem" href="/areal/"><span class="menu-item-text">Areál</span></a>
|
||||||
|
|
||||||
|
</li><li role="none">
|
||||||
|
<a class="menu-item" role="menuitem" href="/kontakty/"><span class="menu-item-text">Kontakty</span></a>
|
||||||
|
|
||||||
|
</li><li role="none">
|
||||||
|
<a class="menu-item" role="menuitem" href="/cenik/"><span class="menu-item-text">Ceník</span></a>
|
||||||
|
|
||||||
|
</li><li role="none">
|
||||||
|
<a class="menu-item" role="menuitem" href="/kalendar/"><span class="menu-item-text">Kalendář</span></a>
|
||||||
|
|
||||||
|
</li><li role="none">
|
||||||
|
<a class="menu-item" role="menuitem" href="/fotogalerie/"><span class="menu-item-text">Fotogalerie</span></a>
|
||||||
|
|
||||||
|
</li><li role="none">
|
||||||
|
<a class="menu-item" role="menuitem" href="/vylety-do-okoli/"><span class="menu-item-text">Výlety do okolí</span></a>
|
||||||
|
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li><li role="none" class="wnd-active-path wnd-with-submenu">
|
||||||
|
<a class="menu-item" role="menuitem" href="/techtower/"><span class="menu-item-text">TechTower</span></a>
|
||||||
|
<ul role="menubar" aria-label="Menu" class="level-2">
|
||||||
|
<li role="none">
|
||||||
|
<a class="menu-item" role="menuitem" href="/kavarna-techtower/"><span class="menu-item-text">SeedUp Space</span></a>
|
||||||
|
|
||||||
|
</li><li role="none" class="wnd-active wnd-active-path">
|
||||||
|
<a class="menu-item" role="menuitem" href="/restaurace-techtower/"><span class="menu-item-text">Restaurace </span></a>
|
||||||
|
|
||||||
|
</li><li role="none">
|
||||||
|
<a class="menu-item" role="menuitem" href="/coffee-breaks/"><span class="menu-item-text">Coffee breaks</span></a>
|
||||||
|
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li><li role="none">
|
||||||
|
<a class="menu-item" role="menuitem" href="/kontakt/"><span class="menu-item-text">Kontakt</span></a>
|
||||||
|
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<span class="more-text">Více</span>
|
||||||
|
</div></nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cart-and-mobile">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div id="menu-mobile">
|
||||||
|
<a href="#" id="menu-submit"><span></span>Menu</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="l-m cf">
|
||||||
|
<div class="sw cf">
|
||||||
|
<div class="sw-c cf"><section class="s s-hm s-hm-hidden wnd-h-hidden cf sc-w hn-default"><div></div></section><section class="s s-basic cf sc-w wnd-w-default wnd-s-normal wnd-h-auto">
|
||||||
|
<div class="s-w cf">
|
||||||
|
<div class="s-o s-fs cf">
|
||||||
|
<div class="s-bg cf">
|
||||||
|
<div class="s-bg-l">
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="s-c s-fs cf">
|
||||||
|
<div class="ez cf wnd-no-cols">
|
||||||
|
<div class="ez-c"><div class="b b-text cf">
|
||||||
|
<div class="b-c b-text-c b-s b-s-t60 b-s-b60 b-cs cf"><h1 class="wnd-align-center"><strong><font class="wsw-31">Restaurace </font></strong></h1>
|
||||||
|
</div>
|
||||||
|
</div><div class="b b-text cf">
|
||||||
|
<div class="b-c b-text-c b-s b-s-t60 b-s-b60 b-cs cf"></div>
|
||||||
|
</div><div class="b b-s b-hr-line line-auto">
|
||||||
|
<hr class="line-style line-color">
|
||||||
|
</div><div class="b b-text cf">
|
||||||
|
<div class="b-c b-text-c b-s b-s-t60 b-s-b60 b-cs cf"><h3 class="wnd-align-center"><font class="wsw-02"><p class=""><strong>Otevírací doba</strong><br></p><p class="">po – pá 10.00 - 14.00</p></font><p class=""><font class="wsw-41">obědy 10.00 - 13:45</font></p></h3></div>
|
||||||
|
</div><div class="b b-text cf">
|
||||||
|
<div class="b-c b-text-c b-s b-s-t60 b-s-b60 b-cs cf"><p class="wnd-align-center"><strong><font class="wsw-41 wnd-font-size-110">Jídelní lístek 5. 10.– 9. 10.</font></strong></p><p class="wnd-align-center"><strong>Pondělí</strong></p><p class="wnd-align-center">Mexická fazolačka s kukuřicí a masem 1 — 45 Kč</p><p class="wnd-align-center">Špagety s kuřecím masem ve smetanové omáčce, pórek, sýr 1,7 — 140 Kč</p><p class="wnd-align-center">Wrap se zeleninou a grilovaným halloumi sýrem 1,7</p><p class="wnd-align-center">Vepřové medailonky na zeleném pepři, fazolky se slaninou, šťouchané brambory 1,7</p><p class="wnd-align-center">Kuřecí paličky se sladkokyselou omáčkou, jasmínová rýže, barevný sezam</p><p class="wnd-align-center"><strong>Úterý</strong></p><p class="wnd-align-center">Rajčatová s cizrnou a bazalkou 1 — 45 Kč</p><p class="wnd-align-center">Maďarský hovězí guláš, houskový knedlík 1,3,7 — 140 Kč</p><p class="wnd-align-center">Tagliatelle s dýňovou omáčkou a semínky, trhaná burrata 1,7</p><p class="wnd-align-center">Dušená šunka od kosti s čerstvým křenem s jablky, hrubozrnná hořčice, podmáslový chléb 1,10</p><p class="wnd-align-center">Cordon bleu, hranolky, pikantní dip 1,3,7</p><p class="wnd-align-center"><strong>Středa</strong></p><p class="wnd-align-center">Uzený vývar s rýží a zeleninou 9 — 45 Kč</p><p class="wnd-align-center">Rozlítaný vepřový ptáček, rýže 1,3 — 140 Kč</p><p class="wnd-align-center">Halušky s pečeným ajvarem a feta sýrem, bylinkový olej 1,7</p><p class="wnd-align-center">Krůtí medailonky s grilovanou zeleninou, silná omáčka 1</p><p class="wnd-align-center">Smažený karbanátek, bramborové pyré, řepový salát s jablky 1,3,7</p><p class="wnd-align-center"><strong>Čtvrtek</strong></p><p class="wnd-align-center">Valašská kyselica s klobásou 1,7 — 45 Kč</p><p class="wnd-align-center">Rizoto z vepřového masa se zeleninou, sypané sýrem Gouda 1,7 — 140 Kč</p><p class="wnd-align-center">Žemlovka s jablky a tvarohem, cukr, máslo 1,3,7</p><p class="wnd-align-center">Zapečené bramborové plátky s kuřecím masem a smetanovo-máslovou omáčkou, mozzarella 1,3,7</p><p class="wnd-align-center">Hovězí roštěná na česneku s jasmínovou rýží 1</p><p class="wnd-align-center"><strong>Pátek</strong></p><p class="wnd-align-center">Hovězí vývar se zeleninou a masem, vaječné kapání 1,3,7,9 — 45 Kč</p><p class="wnd-align-center">Zapečené těstoviny s uzeným masem, okurka 1,3,7 — 140 Kč</p><p class="wnd-align-center">Houbový kuba, naše čalamáda 1</p><p class="wnd-align-center">Pečené kachní stehýnko s dušeným zelím, bramborové knedlíky s bylinkami a špekem 1,3,7</p><p class="wnd-align-center">Smažený sýr se šunkou v bulce, hranolky, tatarská omáčka 1,3,7</p><p class="wnd-align-center"><br></p><p class="wnd-align-center"><strong><font class="wsw-41 wnd-font-size-110">Následující týden</font></strong></p><p class="wnd-align-center"><strong>Pondělí</strong></p><p class="wnd-align-center">Hrášková s krutonem 1,7 — 45 Kč</p><p class="wnd-align-center">Čevabčiči, brambory, cibule, hořčice 1,3,7 — 140 Kč</p><p class="wnd-align-center">Sýrová pizza, čerstvá bazalka 1,7</p><p class="wnd-align-center">Vepřová panenka s žampionovou omáčkou, jasmínová rýže 1,7</p><p class="wnd-align-center">Pečený kuřecí gyros, hranolky, tzatziki 7</p><p class="wnd-align-center"><strong>Úterý</strong></p><p class="wnd-align-center">Telecí vývar se zeleninou a játrovými knedlíčky 1,3,7,9 — 45 Kč</p><p class="wnd-align-center">Francouzské brambory, okurka 1,3,7 — 140 Kč</p><p class="wnd-align-center">Halušky s brynzou a řepou, smažená cibulka 1,7</p><p class="wnd-align-center">Quesadilla s kuřecím masem a jalapeňos, červené fazole, salát coleslaw 1,3</p><p class="wnd-align-center">Pečená selečí roláda, baby mrkev na másle, bramborový knedlík 1,3,7</p><p class="wnd-align-center"><strong>Středa</strong></p><p class="wnd-align-center">Krémová slepičí polévka se zeleninou 1,3,7,9 — 45 Kč</p><p class="wnd-align-center">Játra po anglicku, opečené brambory 1 — 140 Kč</p><p class="wnd-align-center">Těstoviny Alfredo, pažitka 1,3,7</p><p class="wnd-align-center">Hovězí roštěná po cikánsku, jasmínová rýže 1,7</p><p class="wnd-align-center">Smažený sekaný řízek s cheddarem, bramborové pyré 1,3,7</p><p class="wnd-align-center"><strong>Čtvrtek</strong></p><p class="wnd-align-center">Hrachová s klobásou 1,7 — 45 Kč</p><p class="wnd-align-center">Houbová omáčka, hovězí maso, houskový knedlík 1,3,7 — 140 Kč</p><p class="wnd-align-center">Tvarohové knedlíky s ovocem, cukr, máslo, tvaroh 1,3,7</p><p class="wnd-align-center">Vepřové plátky na japonský způsob, jasmínová rýže, barevný sezam 1,6,11</p><p class="wnd-align-center">Steak z tuňákLine truncated
|
||||||
|
</div><div class="b b-text cf">
|
||||||
|
<div class="b-c b-text-c b-s b-s-t60 b-s-b60 b-cs cf"><p id="wnd_TextBlock_76217_inner_1" data-wnd_is_separable="1" data-wnd_separable_index="1" data-wnd_separable_id="wnd_TextBlock_76217"></p></div>
|
||||||
|
</div><div class="b b-text cf">
|
||||||
|
<div class="b-c b-text-c b-s b-s-t60 b-s-b60 b-cs cf"><p class="wnd-align-center"><a href="https://www.equifarm.cz" target="_blank">www.equifarm.cz</a></p><p class="wnd-align-center"><font class="wsw-31">techtower@equifarm.cz</font></p><p class="wnd-align-center">+420 608 180 311</p></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer class="l-f cf">
|
||||||
|
<div class="sw cf">
|
||||||
|
<div class="sw-c cf"><section data-wn-border-element="s-f-border" class="s s-f s-f-basic s-f-simple sc-w wnd-w-default wnd-s-higher">
|
||||||
|
<div class="s-w">
|
||||||
|
<div class="s-o">
|
||||||
|
<div class="s-bg">
|
||||||
|
<div class="s-bg-l">
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="s-c s-f-l-w s-f-border">
|
||||||
|
<div class="s-f-l b-s b-s-t0 b-s-b0">
|
||||||
|
<div class="s-f-l-c s-f-l-c-first">
|
||||||
|
<div class="s-f-cr"><span class="it b link">
|
||||||
|
<span class="it-c">© 2022 Equifarm, s.r.o. Všechna práva vyhrazena.</span>
|
||||||
|
</span></div>
|
||||||
|
<div class="s-f-sf">
|
||||||
|
<span class="sf b">
|
||||||
|
<span class="sf-content sf-c link">Vytvořeno službou <a href="https://www.webnode.cz?utm_source=text&utm_medium=footer&utm_campaign=free3&utm_content=wnd2" rel="nofollow noopener" target="_blank">Webnode</a></span>
|
||||||
|
</span>
|
||||||
|
<span class="sf-cbr link">
|
||||||
|
<a href="#" rel="nofollow">Cookies</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="s-f-l-c s-f-l-c-last">
|
||||||
|
<div class="s-f-lang lang-select cf">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="s-f-l-c s-f-l-c-currency">
|
||||||
|
<div class="s-f-ccy ccy-select cf">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section></div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<section class="cb cb-dark" id="cookiebar" style="display:none;" id="cookiebar" style="display:none;">
|
||||||
|
<div class="cb-bar cb-scrollable">
|
||||||
|
<div class="cb-bar-c">
|
||||||
|
<div class="cb-bar-text">Používáme cookies, abychom zajistili správné fungování a bezpečnost našich stránek. Tím vám můžeme zajistit tu nejlepší zkušenost při jejich návštěvě.</div>
|
||||||
|
<div class="cb-bar-buttons">
|
||||||
|
<button class="cb-button cb-close-basic" data-action="accept-necessary">
|
||||||
|
<span class="cb-button-content">Přijmout nezbytné</span>
|
||||||
|
</button>
|
||||||
|
<button class="cb-button cb-close-basic" data-action="accept-all">
|
||||||
|
<span class="cb-button-content">Přijmout vše</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cb-close cb-close-basic" data-action="accept-necessary"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cb-popup cb-light close">
|
||||||
|
<div class="cb-popup-c">
|
||||||
|
<div class="cb-popup-head">
|
||||||
|
<h3 class="cb-popup-title">Pokročilá nastavení</h3>
|
||||||
|
<p class="cb-popup-text">Zde můžete upravit své preference ohledně cookies. Následující kategorie můžete povolit či zakázat a svůj výběr uložit.</p>
|
||||||
|
</div>
|
||||||
|
<div class="cb-popup-options cb-scrollable">
|
||||||
|
<div class="cb-option">
|
||||||
|
<input class="cb-option-input" type="checkbox" id="necessary" name="necessary" readonly="readonly" disabled="disabled" checked="checked">
|
||||||
|
<div class="cb-option-checkbox"></div>
|
||||||
|
<label class="cb-option-label" for="necessary">Nezbytné krátkodobé cookies</label>
|
||||||
|
<div class="cb-option-text"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cb-popup-footer">
|
||||||
|
<button class="cb-button cb-save-popup" data-action="advanced-save">
|
||||||
|
<span class="cb-button-content">Uložit</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="cb-close cb-close-popup" data-action="close"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script src="https://duyn491kcolsw.cloudfront.net/files/35/35w/35wjn2.js?ph=59d3cce388" crossorigin="anonymous" type="module"></script><script>document.querySelector(".wnd-fe")&&[...document.querySelectorAll(".c")].forEach((e=>{const t=e.querySelector(".b-text:only-child");t&&""===t.querySelector(".b-text-c").innerText&&e.classList.add("column-empty")}))</script>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="https://duyn491kcolsw.cloudfront.net/client.fe/js.compiled/lang.cz.a9c1a985e49aad81.js?ph=59d3cce388" crossorigin="anonymous"></script><script src="https://duyn491kcolsw.cloudfront.net/client.fe/js.compiled/compiled.multi.64ecbadac2103be4.js?ph=59d3cce388" crossorigin="anonymous"></script><script>var wnd = wnd || {};wnd.$data = {"image_content_items":{"wnd_ThumbnailBlock_1":{"id":"wnd_ThumbnailBlock_1","type":"wnd.pc.ThumbnailBlock"},"wnd_HeaderSection_header_main_412360230":{"id":"wnd_HeaderSection_header_main_412360230","type":"wnd.pc.HeaderSection"},"wnd_ImageBlock_793396992":{"id":"wnd_ImageBlock_793396992","type":"wnd.pc.ImageBlock"},"wnd_Section_default_762442558":{"id":"wnd_Section_default_762442558","type":"wnd.pc.Section"},"wnd_LogoBlock_193721444":{"id":"wnd_LogoBlock_193721444","type":"wnd.pc.LogoBlock"},"wnd_FooterSection_footer_306617015":{"id":"wnd_FooterSection_footer_306617015","type":"wnd.pc.FooterSection"}},"svg_content_items":{"wnd_ImageBlock_793396992":{"id":"wnd_ImageBlock_793396992","type":"wnd.pc.ImageBlock"},"wnd_LogoBlock_193721444":{"id":"wnd_LogoBlock_193721444","type":"wnd.pc.LogoBlock"}},"content_items":[],"eshopSettings":{"ESHOP_SETTINGS_DISPLAY_PRICE_WITHOUT_VAT":false,"ESHOP_SETTINGS_DISPLAY_ADDITIONAL_VAT":false,"ESHOP_SETTINGS_DISPLAY_SHIPPING_COST":false},"project_info":{"isMultilanguage":false,"isMulticurrency":false,"eshop_tax_enabled":"1","country_code":"","contact_state":null,"eshop_tax_type":"VAT","eshop_discounts":false,"graphQLURL":"https:\/\/equifarm.cz\/servers\/graphql\/","iubendaSettings":{"cookieBarCode":"","cookiePolicyCode":"","privacyPolicyCode":"","termsAndConditionsCode":""}}};</script><script>wnd.$system = {"fileSystemType":"aws_s3","localFilesPath":"https:\/\/www.equifarm.cz\/_files\/","awsS3FilesPath":"https:\/\/59d3cce388.clvaw-cdnwnd.com\/bd8e4bd40b5bb1412d0aee3843c4e306\/","staticFiles":"https:\/\/duyn491kcolsw.cloudfront.net\/files","isCms":false,"staticCDNServers":["https:\/\/duyn491kcolsw.cloudfront.net\/"],"fileUploadAllowExtension":["jpg","jpeg","jfif","png","gif","bmp","ico","svg","webp","tiff","pdf","doc","docx","ppt","pptx","pps","ppsx","odt","xls","xlsx","txt","rtf","mp3","wma","wav","ogg","amr","flac","m4a","3gp","avi","wmv","mov","mpg","mkv","mp4","mpeg","m4v","swf","gpx","stl","csv","xml","txt","dxf","dwg","iges","igs","step","stp"],"maxUserFormFileLimit":4194304,"frontendLanguage":"cz","backendLanguage":"cz","frontendLanguageId":"1","page":{"id":200005119,"identifier":"restaurace-techtower","template":{"id":200000048,"styles":{"typography":"t-30_new","scheme":"cs-gray","additiveColor":"ac-none","acHeadings":false,"acSubheadings":false,"acIcons":true,"acOthers":false,"acMenu":false,"imageStyle":"img-d-n","imageHover":"img-h-z","imageTitle":"img-t-o","buttonStyle":"b-btn-sq","buttonSize":"b-btn-s-l","buttonDecoration":"b-btn-dn","buttonBorders":"b-btn-bw-1","lineStyle":"line-solid","eshopGridItemStyle":"b-e-ds","formStyle":"default","menuType":"","menuStyle":"menu-default-underline","lightboxStyle":"lbox-d","columnSpaces":"c-s-s","sectionWidth":"wnd-w-default","sectionSpace":"wnd-s-normal","layoutType":"l-default","layoutDecoration":"l-d-none","background":{"default":null},"backgroundSettings":{"default":""},"typoTitleSizes":"t-t-fs-l","typoTitleWeights":"t-t-fw-l","typoTitleSpacings":"t-t-sp-n","typoTitleDecorations":"t-t-d-n","typoHeadingSizes":"t-h-fs-m","typoHeadingWeights":"t-h-fw-l","typoHeadingSpacings":"t-h-sp-n","typoSubtitleSizes":"t-s-fs-m","typoSubtitleWeights":"t-s-fw-l","typoSubtitleSpacings":"t-s-sp-n","typoSubtitleDecorations":"t-s-d-n","typoParagraphSizes":"t-p-fs-s","typoParagraphWeights":"t-p-fw-m","typoParagraphSpacings":"t-p-sp-n","typoBlockquoteSizes":"t-bq-fs-s","typoBlockquoteWeights":"t-bq-fw-l","typoBlockquoteSpacings":"t-bq-sp-n","typoBlockquoteDecorations":"t-bq-d-q","buttonWeight":"t-btn-fw-l","menuWeight":"t-nav-fw-l","productWeight":"t-pd-fw-s","menuTextTransform":"t-nav-tt-u"}},"layout":"default","name":"Restaurace ","html_title":"","language":"cz","langId":1,"isHomepage":false,"meta_description":"","meta_keywords":"","header_code":"","footer_code":"","styles":{"pageBased":{"navline":{"style":{"sectionWidth":"wnd-w-wider","sectionHeight":"wnd-nh-m","sectionPosition":"wnd-na-c"}}}},"countFormsEntries":[]},"listingsPrefix":"\/l\/","productPrefix":"\/p\/","cartPrefix":"\/cart\/","checkoutPrefix":"\/checkout\/","searchPrefix":"\/search\/","isCheckout":false,"isEshop":false,"hasBlog":false,"isProductDetail":false,"isListingDetail":false,"listing_page":[],"hasEshopAnalytics":false,"gTagId":null,"gAdsId":null,"format":{"be":{"DATE_TIME":{"mask":"%d.%m.%Y %H:%M","regexp":"^(((0?[1-9]|[1,2][0-9]|3[0,1])\\.(0?[1-9]|1[0-2])\\.[0-9]{1,4})(( [0-1][0-9]| 2[0-3]):[0-5][0-9])?|(([0-9]{4}(0[1-9]|1[0-2])(0[1-9]|[1,2][0-9]|3[0,1])(0[0-9]|1[0-9]|2[0-3])[0-5][0-9][0-5][0-9])))?$"},"DATE":{"mask":"%d.%m.%Y","regexp":"^((0?[1-9]|[1,2][0-9]|3[0,1])\\.(0?[1-9]|1[0-2])\\.[0-9]{1,4})$"},"CURRENCY":{"mask":{"point":",","tLine truncated
|
||||||
|
events: {"error":{"name":"Error"},"publish":{"name":"Publish page"},"open_premium_popup":{"name":"Open premium popup"},"publish_from_premium_popup":{"name":"Publish from premium content popup"},"upgrade_your_plan":{"name":"Upgrade your plan"},"ml_lock_page":{"name":"Lock access to page"},"ml_unlock_page":{"name":"Unlock access to page"},"ml_start_add_page_member_area":{"name":"Start - add page Member Area"},"ml_end_add_page_member_area":{"name":"End - add page Member Area"},"ml_show_activation_popup":{"name":"Show activation popup"},"ml_activation":{"name":"Member Login Activation"},"ml_deactivation":{"name":"Member Login Deactivation"},"ml_enable_require_approval":{"name":"Enable approve registration manually"},"ml_disable_require_approval":{"name":"Disable approve registration manually"},"ml_fe_member_registration":{"name":"Member Registration on FE"},"ml_fe_member_login":{"name":"Login Member"},"ml_fe_member_recovery":{"name":"Sent recovery email"},"ml_fe_member_change_password":{"name":"Change Password"},"undoredo_click_undo":{"name":"Undo\/Redo - click Undo"},"undoredo_click_redo":{"name":"Undo\/Redo - click Redo"},"add_anchor":{"name":"Anchor adding completed"},"add_page":{"name":"Page adding completed"},"change_page_order":{"name":"Change of page order"},"delete_page":{"name":"Page deleted"},"background_options_change":{"name":"Background setting completed"},"add_content":{"name":"Content adding completed"},"delete_content":{"name":"Content deleting completed"},"change_text":{"name":"Editing of text completed"},"add_image":{"name":"Image adding completed"},"add_photo":{"name":"Adding photo to galery completed"},"change_logo":{"name":"Logo changing completed"},"open_pages":{"name":"Open Pages"},"show_page_cms":{"name":"Show Page - CMS"},"new_section":{"name":"New section completed"},"delete_section":{"name":"Section deleting completed"},"add_video":{"name":"Video adding completed"},"add_maps":{"name":"Maps adding completed"},"add_button":{"name":"Button adding completed"},"add_file":{"name":"File adding completed"},"add_hr":{"name":"Horizontal line adding completed"},"delete_cell":{"name":"Cell deleting completed"},"delete_microtemplate":{"name":"Microtemplate deleting completed"},"add_blog_page":{"name":"Blog page adding completed"},"new_blog_post":{"name":"New blog post created"},"new_blog_recent_posts":{"name":"Blog recent posts list created"},"e_show_products_popup":{"name":"Show products popup"},"e_show_add_product":{"name":"Show add product popup"},"e_show_edit_product":{"name":"Show edit product popup"},"e_show_collections_popup":{"name":"Show collections manager popup"},"e_show_eshop_settings":{"name":"Show eshop settings popup"},"e_add_product":{"name":"Add product"},"e_edit_product":{"name":"Edit product"},"e_remove_product":{"name":"Remove product"},"e_bulk_show":{"name":"Bulk show products"},"e_bulk_hide":{"name":"Bulk hide products"},"e_bulk_remove":{"name":"Bulk remove products"},"e_move_product":{"name":"Order products"},"e_add_collection":{"name":"Add collection"},"e_rename_collection":{"name":"Rename collection"},"e_remove_collection":{"name":"Remove collection"},"e_add_product_to_collection":{"name":"Assign product to collection"},"e_remove_product_from_collection":{"name":"Unassign product from collection"},"e_move_collection":{"name":"Order collections"},"e_add_products_page":{"name":"Add products page"},"e_add_procucts_section":{"name":"Add products list section"},"e_add_products_content":{"name":"Add products list content block"},"e_change_products_zone_style":{"name":"Change style in products list"},"e_change_products_zone_collection":{"name":"Change collection in products list"},"e_show_products_zone_collection_filter":{"name":"Show collection filter in products list"},"e_hide_products_zone_collection_filter":{"name":"Hide collection filter in products list"},"e_show_product_detail_page":{"name":"Show product detail page"},"e_add_to_cart":{"name":"Add product to cart"},"e_remove_from_cart":{"name":"Remove product from cart"},"e_checkout_step":{"name":"From cart to checkout"},"e_finish_checkout":{"name":"Finish order"},"welcome_window_play_video":{"name":"Welcome window - play video"},"e_import_file":{"name":"Eshop - products import - file info"},"e_import_upload_time":{"name":"Eshop - products import - upload file duration"},"e_import_result":{"name":"Eshop - products import result"},"e_import_error":{"name":"Eshop - products import - errors"},"e_promo_blogpost":{"name":"Eshop promo - blogpost"},"e_promo_activate":{"name":"Eshop promo - activate"},"e_settings_activate":{"name":"Online store - activate"},"e_promo_hide":{"name":"Eshop promo - close"},"e_activation_popup_step":{"name":"Eshop activation popup - step"},"e_activation_popup_activate":{"name":"Eshop activation popup - activate"},"e_activation_popup_hide":{"name":"Eshop activation popup - close"},"e_deactivation":{"name":"Eshop deactivation"},"e_reactivation":{"name":"Eshop reactivation"},"e_variant_create_first":Line truncated
|
||||||
|
data: {"user":{"u":0,"p":44019905,"lc":"CZ","t":"1"},"action":{"identifier":"","name":"","category":"project","platform":"WND2"},"browser":{"url":"https:\/\/www.equifarm.cz\/restaurace-techtower","ua":"axios\/1.20.0","referer_url":"","resolution":"","ip":"90.181.238.210"}},
|
||||||
|
urlPrefix: "https://events.webnode.com/projects/-/events/",
|
||||||
|
collection: "PROD",
|
||||||
|
}</script></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import bodyParser from 'body-parser';
|
||||||
|
import { generateToken } from '../auth';
|
||||||
|
import { resetMemoryStorage } from '../storage/memory';
|
||||||
|
import getStorage from '../storage';
|
||||||
|
import groupRouter from '../routes/groupRoutes';
|
||||||
|
import { DEFAULT_GROUP_LIMITS, getGroupLimits, resetGroupLimits } from '../limits';
|
||||||
|
|
||||||
|
const MAX_NOTE_LENGTH = DEFAULT_GROUP_LIMITS.maxNoteLength;
|
||||||
|
const MAX_SURCHARGE_TEXT_LENGTH = DEFAULT_GROUP_LIMITS.maxSurchargeTextLength;
|
||||||
|
/** Maximum částky v haléřích */
|
||||||
|
const MAX_AMOUNT = DEFAULT_GROUP_LIMITS.maxAmountKc * 100;
|
||||||
|
|
||||||
|
jest.mock('../websocket', () => ({ getWebsocket: () => ({ emit: jest.fn() }) }));
|
||||||
|
|
||||||
|
const CREATOR = 'kreator';
|
||||||
|
const TOKEN = `Bearer ${generateToken(CREATOR)}`;
|
||||||
|
|
||||||
|
function buildApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
app.use('/api/groups', groupRouter);
|
||||||
|
app.use((err: any, _req: any, res: any, _next: any) => {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Založí skupinu a vrátí její ID. */
|
||||||
|
async function createTestGroup(app: express.Express): Promise<string> {
|
||||||
|
const res = await request(app).post('/api/groups/create').set('Authorization', TOKEN).send({ name: 'Bistro' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
return res.body.groups[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetMemoryStorage();
|
||||||
|
resetGroupLimits();
|
||||||
|
await getStorage().setData('stores', [{ name: 'Bistro' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('limity vstupů skupinových objednávek', () => {
|
||||||
|
test('poznámka a popis příplatku mají maximální délku', async () => {
|
||||||
|
const app = buildApp();
|
||||||
|
const id = await createTestGroup(app);
|
||||||
|
const post = (body: object) => request(app).post('/api/groups/updateMember').set('Authorization', TOKEN).send({ id, login: CREATOR, ...body });
|
||||||
|
|
||||||
|
expect((await post({ note: 'x'.repeat(MAX_NOTE_LENGTH) })).status).toBe(200);
|
||||||
|
const tooLong = await post({ note: 'x'.repeat(MAX_NOTE_LENGTH + 1) });
|
||||||
|
expect(tooLong.status).toBe(400);
|
||||||
|
expect(tooLong.body.error).toContain(`${MAX_NOTE_LENGTH} znaků`);
|
||||||
|
expect((await post({ surchargeText: 'x'.repeat(MAX_SURCHARGE_TEXT_LENGTH + 1) })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('částka a příplatek mají maximální výši', async () => {
|
||||||
|
const app = buildApp();
|
||||||
|
const id = await createTestGroup(app);
|
||||||
|
const post = (body: object) => request(app).post('/api/groups/updateMember').set('Authorization', TOKEN).send({ id, login: CREATOR, ...body });
|
||||||
|
|
||||||
|
expect((await post({ amount: MAX_AMOUNT })).status).toBe(200);
|
||||||
|
const tooMuch = await post({ amount: MAX_AMOUNT + 1 });
|
||||||
|
expect(tooMuch.status).toBe(400);
|
||||||
|
expect(tooMuch.body.error).toContain('10000 Kč');
|
||||||
|
expect((await post({ surchargeAmount: MAX_AMOUNT + 1 })).status).toBe(400);
|
||||||
|
expect((await post({ amount: -1 })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('poplatky mají maximum a procentní sleva nesmí přesáhnout 100 %', async () => {
|
||||||
|
const app = buildApp();
|
||||||
|
const id = await createTestGroup(app);
|
||||||
|
const post = (body: object) => request(app).post('/api/groups/updateFees').set('Authorization', TOKEN).send({ id, ...body });
|
||||||
|
|
||||||
|
expect((await post({ shipping: MAX_AMOUNT + 1 })).status).toBe(400);
|
||||||
|
expect((await post({ discountType: 'percent', discountValue: 101 })).status).toBe(400);
|
||||||
|
expect((await post({ discountType: 'percent', discountValue: 100 })).status).toBe(200);
|
||||||
|
// Jen hodnota při již nastavené procentní slevě — pojistka v groups.ts
|
||||||
|
const onlyValue = await post({ discountValue: 150 });
|
||||||
|
expect(onlyValue.status).toBe(500);
|
||||||
|
expect(onlyValue.body.error).toContain('100 %');
|
||||||
|
expect((await post({ discountType: 'fixed', discountValue: MAX_AMOUNT + 1 })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('název skupiny a odkaz na sledování mají maximální délku', async () => {
|
||||||
|
const app = buildApp();
|
||||||
|
expect((await request(app).post('/api/groups/create').set('Authorization', TOKEN).send({ name: 'x'.repeat(101) })).status).toBe(400);
|
||||||
|
const id = await createTestGroup(app);
|
||||||
|
const res = await request(app).post('/api/groups/setTracking').set('Authorization', TOKEN).send({ id, shareUrl: 'https://x.cz/' + 'a'.repeat(600) });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('limity z proměnných prostředí', () => {
|
||||||
|
const ENV_KEYS = ['GROUP_MAX_NOTE_LENGTH', 'GROUP_MAX_AMOUNT_KC', 'GROUP_MAX_ITEMS'];
|
||||||
|
afterEach(() => {
|
||||||
|
ENV_KEYS.forEach(k => delete process.env[k]);
|
||||||
|
resetGroupLimits();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('env přepíše výchozí hodnoty a limity se uplatní v API', async () => {
|
||||||
|
process.env.GROUP_MAX_NOTE_LENGTH = '20';
|
||||||
|
process.env.GROUP_MAX_AMOUNT_KC = '500';
|
||||||
|
process.env.GROUP_MAX_ITEMS = '5';
|
||||||
|
resetGroupLimits();
|
||||||
|
expect(getGroupLimits()).toEqual({ ...DEFAULT_GROUP_LIMITS, maxNoteLength: 20, maxAmountKc: 500, maxItems: 5 });
|
||||||
|
|
||||||
|
const app = buildApp();
|
||||||
|
const id = await createTestGroup(app);
|
||||||
|
const post = (body: object) => request(app).post('/api/groups/updateMember').set('Authorization', TOKEN).send({ id, login: CREATOR, ...body });
|
||||||
|
expect((await post({ note: 'x'.repeat(21) })).body.error).toContain('20 znaků');
|
||||||
|
expect((await post({ amount: 50001 })).body.error).toContain('500 Kč');
|
||||||
|
expect((await post({ amount: 50000 })).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neplatná hodnota v env se ignoruje (zůstane výchozí)', () => {
|
||||||
|
const warn = jest.spyOn(console, 'warn').mockImplementation(() => { });
|
||||||
|
process.env.GROUP_MAX_NOTE_LENGTH = 'hodně';
|
||||||
|
process.env.GROUP_MAX_AMOUNT_KC = '-5';
|
||||||
|
process.env.GROUP_MAX_ITEMS = '999999';
|
||||||
|
resetGroupLimits();
|
||||||
|
expect(getGroupLimits()).toEqual(DEFAULT_GROUP_LIMITS);
|
||||||
|
expect(warn).toHaveBeenCalledTimes(3);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/** Ruční hodiny — časovače se spouští jen přes advance(). */
|
||||||
|
export function fakeClock(random: () => number = () => 0) {
|
||||||
|
let now = 1_000_000;
|
||||||
|
let timers: { at: number, fn: () => void, id: number }[] = [];
|
||||||
|
let nextId = 1;
|
||||||
|
return {
|
||||||
|
now: () => now,
|
||||||
|
random,
|
||||||
|
setTimeout: (fn: () => void, ms: number) => {
|
||||||
|
const id = nextId++;
|
||||||
|
timers.push({ at: now + ms, fn, id });
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
clearTimeout: (id: unknown) => { timers = timers.filter(t => t.id !== id); },
|
||||||
|
advance(ms: number) {
|
||||||
|
const target = now + ms;
|
||||||
|
for (;;) {
|
||||||
|
const due = timers.filter(t => t.at <= target).sort((a, b) => a.at - b.at)[0];
|
||||||
|
if (!due) break;
|
||||||
|
timers = timers.filter(t => t !== due);
|
||||||
|
now = due.at;
|
||||||
|
due.fn();
|
||||||
|
}
|
||||||
|
now = target;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -129,6 +129,43 @@ describe('TechTower parser – nový formát 07/2026', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TechTower parser – nový formát 10/2026', () => {
|
||||||
|
// Pondělí 5.10.2026, stránka obsahuje i menu na následující týden
|
||||||
|
const MONDAY_2026_10 = new Date('2026-10-05');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedAxios.get = jest.fn().mockResolvedValue({ data: loadFixture('techtower-2026-10.html') });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vrátí pole o délce 5 s jídly pro každý den', async () => {
|
||||||
|
const menu = await getMenuTechTower(MONDAY_2026_10);
|
||||||
|
expect(menu).toHaveLength(5);
|
||||||
|
for (const den of menu) {
|
||||||
|
expect(den).toHaveLength(5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nezahrne menu následujícího týdne', async () => {
|
||||||
|
const menu = await getMenuTechTower(MONDAY_2026_10);
|
||||||
|
expect(menu[0][0].name).toBe('Mexická fazolačka s kukuřicí a masem');
|
||||||
|
expect(menu.flat().some(f => f.name.startsWith('Hrášková'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('každý den začíná polévkou s cenou a alergeny', async () => {
|
||||||
|
const menu = await getMenuTechTower(MONDAY_2026_10);
|
||||||
|
for (const den of menu) {
|
||||||
|
expect(den[0].isSoup).toBe(true);
|
||||||
|
expect(den[0].price).toBe('45\xA0Kč');
|
||||||
|
expect(den[0].allergens?.length).toBeGreaterThan(0);
|
||||||
|
expect(den.slice(1).some(f => f.isSoup)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hodí StaleWeekError pro jiný týden', async () => {
|
||||||
|
await expect(getMenuTechTower(new Date('2026-10-12'))).rejects.toBeInstanceOf(StaleWeekError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('SenkSerikova parser', () => {
|
describe('SenkSerikova parser', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// SenkSerikova parsuje arraybuffer – musíme vrátit Buffer, ne string
|
// SenkSerikova parsuje arraybuffer – musíme vrátit Buffer, ne string
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import getStorage from '../storage';
|
||||||
|
import { resetMemoryStorage } from '../storage/memory';
|
||||||
|
import { updatePrice } from '../service';
|
||||||
|
import { ClientData } from '../../../types/gen/types.gen';
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
const USER = 'petr';
|
||||||
|
const DATE = new Date('2025-03-04');
|
||||||
|
const ISO_DATE = '2025-03-04';
|
||||||
|
|
||||||
|
/** Uloží volby uživatelů pro testovací den. */
|
||||||
|
async function saveChoices(choices: ClientData["choices"]) {
|
||||||
|
await storage.setData<Partial<ClientData>>(ISO_DATE, { choices });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMemoryStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updatePrice', () => {
|
||||||
|
test('nastaví a odstraní cenu u volby podniku', async () => {
|
||||||
|
await saveChoices({ SPSE: { [USER]: { note: 'polévka' } } });
|
||||||
|
|
||||||
|
let data = await updatePrice(USER, false, 9500, DATE);
|
||||||
|
expect(data.choices.SPSE?.[USER]).toMatchObject({ price: 9500, note: 'polévka' });
|
||||||
|
|
||||||
|
data = await updatePrice(USER, false, undefined, DATE);
|
||||||
|
expect(data.choices.SPSE?.[USER]?.price).toBeUndefined();
|
||||||
|
expect(data.choices.SPSE?.[USER]?.note).toBe('polévka');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(['OBJEDNAVAM', 'PIZZA', 'NEOBEDVAM', 'ROZHODUJI'])('odmítne cenu u volby %s', async (choice) => {
|
||||||
|
await saveChoices({ [choice]: { [USER]: {} } });
|
||||||
|
await expect(updatePrice(USER, false, 10000, DATE)).rejects.toThrow('K této volbě nelze zadat cenu');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odmítne cenu bez vybrané volby i zápornou cenu', async () => {
|
||||||
|
await saveChoices({ SPSE: { tomas: {} } });
|
||||||
|
await expect(updatePrice(USER, false, 10000, DATE)).rejects.toThrow('Cenu lze zadat jen k vybrané volbě');
|
||||||
|
await expect(updatePrice(USER, false, -1, DATE)).rejects.toThrow('Neplatná cena');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { resetMemoryStorage } from '../storage/memory';
|
||||||
|
import getStorage from '../storage';
|
||||||
|
import { diffMenus, fetchBoltMenu, fetchWoltMenu, getStoreMenu, getStoreMenuHistory, getStoreMenuStatus, parseBoltProviderId, parseWoltSlug, refreshStoreMenu, renameStoreMenus, resetProviderThrottle } from '../storeMenus';
|
||||||
|
|
||||||
|
jest.mock('axios');
|
||||||
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
|
|
||||||
|
const BOLT_URL = 'https://food.bolt.eu/cs-cz/427-plzen/p/27744-new-york-burger-chicken/';
|
||||||
|
const WOLT_URL = 'https://wolt.com/cs/cze/pilsen/restaurant/new-york-burger-chicken';
|
||||||
|
|
||||||
|
/** Odpověď Bolt getMenuCategories ve zkrácené podobě (plochá mapa položek). */
|
||||||
|
const boltResponse = () => ({
|
||||||
|
data: {
|
||||||
|
code: 0,
|
||||||
|
message: 'OK',
|
||||||
|
data: {
|
||||||
|
items: {
|
||||||
|
1: { type: 'menu', id: 1, parent_id: null },
|
||||||
|
10: { type: 'category', id: 10, parent_id: 1, index: 1, name: { value: 'Burgery 🍔' } },
|
||||||
|
11: { type: 'category', id: 11, parent_id: 1, index: 2, name: { value: 'Přílohy 🍟' } },
|
||||||
|
12: { type: 'category', id: 12, parent_id: 1, index: 0, name: { value: 'Polévky' } },
|
||||||
|
100: { type: 'dish', id: 100, parent_id: 10, index: 1, name: { value: 'Big Cheeseburger ' }, price: { value: 189 }, availability: 'in_stock' },
|
||||||
|
101: { type: 'dish', id: 101, parent_id: 10, index: 0, name: { value: 'Chicken Burger' }, price: { value: 169 }, availability: 'in_stock' },
|
||||||
|
102: { type: 'dish', id: 102, parent_id: 10, index: 2, name: { value: 'Vyprodaný burger' }, price: { value: 1 }, availability: 'out_of_stock' },
|
||||||
|
103: { type: 'dish', id: 103, parent_id: 11, index: 0, name: { value: 'Hranolky' }, price: { value: 59 }, availability: 'in_stock' },
|
||||||
|
104: { type: 'dish', id: 104, parent_id: 12, index: 0, name: { value: 'Gulášovka' }, price: { value: 79 }, availability: 'in_stock' },
|
||||||
|
13: { type: 'category', id: 13, parent_id: 1, index: 3, name: { value: 'Nealko' } },
|
||||||
|
105: { type: 'dish', id: 105, parent_id: 13, index: 0, name: { value: 'Red Bull 0.25' }, price: { value: 69 }, availability: 'in_stock' },
|
||||||
|
106: { type: 'dish', id: 106, parent_id: 10, index: 3, name: { value: 'Půlená pizza (příplatek)' }, price: { value: 20 }, availability: 'in_stock' },
|
||||||
|
107: { type: 'dish', id: 107, parent_id: 10, index: 4, name: { value: 'DRŠŤKOVÁ' }, price: { value: 89 }, availability: 'in_stock' },
|
||||||
|
200: { type: 'option_select', id: 200, parent_id: 100, name: { value: 'Extra sýr' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Odpověď Wolt assortment ve zkrácené podobě (ceny v haléřích). */
|
||||||
|
const woltResponse = () => ({
|
||||||
|
data: {
|
||||||
|
categories: [
|
||||||
|
{ name: '🥩 BEEF BURGER', item_ids: ['a', 'b'], subcategories: [] },
|
||||||
|
{ name: '🥤 Nápoje', item_ids: ['c'] },
|
||||||
|
{ name: 'Menu', item_ids: [], subcategories: [{ name: 'Tortilly', item_ids: ['d', 'e'] }] },
|
||||||
|
],
|
||||||
|
items: [
|
||||||
|
{ id: 'a', name: 'Cheeseburger', price: 18900, disabled_info: null },
|
||||||
|
{ id: 'b', name: 'Nedostupný burger', price: 100, disabled_info: { reason: 'sold out' } },
|
||||||
|
{ id: 'c', name: 'Cola', price: 4900, disabled_info: null },
|
||||||
|
{ id: 'd', name: 'Kuřecí tortilla', price: 15950, disabled_info: null },
|
||||||
|
{ id: 'e', name: 'Kuřecí polévka', price: 6900, disabled_info: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMemoryStorage();
|
||||||
|
resetProviderThrottle();
|
||||||
|
mockedAxios.get.mockReset();
|
||||||
|
mockedAxios.isAxiosError.mockImplementation((e: any) => !!e?.isAxiosError);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Chyba axiosu s HTTP 429 (omezení počtu dotazů). */
|
||||||
|
const tooManyRequests = (retryAfter?: string) =>
|
||||||
|
Object.assign(new Error('Request failed with status code 429'), {
|
||||||
|
isAxiosError: true,
|
||||||
|
response: { status: 429, headers: retryAfter ? { 'retry-after': retryAfter } : {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parsování URL', () => {
|
||||||
|
test('ID podniku z Bolt URL', () => {
|
||||||
|
expect(parseBoltProviderId(BOLT_URL)).toBe('27744');
|
||||||
|
expect(parseBoltProviderId(WOLT_URL)).toBeUndefined();
|
||||||
|
expect(parseBoltProviderId('nesmysl')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slug podniku z Wolt URL', () => {
|
||||||
|
expect(parseWoltSlug(WOLT_URL)).toBe('new-york-burger-chicken');
|
||||||
|
expect(parseWoltSlug(BOLT_URL)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchBoltMenu', () => {
|
||||||
|
test('vrátí dostupné položky dle pořadí v nabídce, hlavní jídla označí (ne přílohy, nápoje, příplatky, polévky)', async () => {
|
||||||
|
mockedAxios.get.mockResolvedValue(boltResponse());
|
||||||
|
const dishes = await fetchBoltMenu('27744');
|
||||||
|
expect(dishes).toEqual([
|
||||||
|
{ name: 'Gulášovka', price: 79, category: 'Polévky', isMain: false },
|
||||||
|
{ name: 'Chicken Burger', price: 169, category: 'Burgery 🍔', isMain: true },
|
||||||
|
{ name: 'Big Cheeseburger', price: 189, category: 'Burgery 🍔', isMain: true },
|
||||||
|
{ name: 'Půlená pizza (příplatek)', price: 20, category: 'Burgery 🍔', isMain: false },
|
||||||
|
{ name: 'DRŠŤKOVÁ', price: 89, category: 'Burgery 🍔', isMain: false },
|
||||||
|
{ name: 'Hranolky', price: 59, category: 'Přílohy 🍟', isMain: false },
|
||||||
|
{ name: 'Red Bull 0.25', price: 69, category: 'Nealko', isMain: false },
|
||||||
|
]);
|
||||||
|
expect(mockedAxios.get).toHaveBeenCalledWith(expect.stringContaining('getMenuCategories'),
|
||||||
|
expect.objectContaining({ params: expect.objectContaining({ provider_id: '27744' }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chybový kód Bolt API vyhodí výjimku', async () => {
|
||||||
|
mockedAxios.get.mockResolvedValue({ data: { code: 702, message: 'INVALID_REQUEST' } });
|
||||||
|
await expect(fetchBoltMenu('1')).rejects.toThrow('702');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchWoltMenu', () => {
|
||||||
|
test('vrátí dostupné položky včetně podkategorií, ceny v Kč, hlavní jídla označí', async () => {
|
||||||
|
mockedAxios.get.mockResolvedValue(woltResponse());
|
||||||
|
const dishes = await fetchWoltMenu('new-york-burger-chicken');
|
||||||
|
expect(dishes).toEqual([
|
||||||
|
{ name: 'Cheeseburger', price: 189, category: '🥩 BEEF BURGER', isMain: true },
|
||||||
|
{ name: 'Cola', price: 49, category: '🥤 Nápoje', isMain: false },
|
||||||
|
{ name: 'Kuřecí tortilla', price: 160, category: 'Menu / Tortilly', isMain: true },
|
||||||
|
{ name: 'Kuřecí polévka', price: 69, category: 'Menu / Tortilly', isMain: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const DAY = 24 * 60 * 60 * 1000;
|
||||||
|
const ADMIN_PW = 'testadmin';
|
||||||
|
|
||||||
|
describe('getStoreMenu', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
process.env.ADMIN_PASSWORD = ADMIN_PW;
|
||||||
|
await getStorage().setData('stores', [{ name: 'New York', urls: [BOLT_URL, WOLT_URL] }, { name: 'Bez URL' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
delete process.env.ADMIN_PASSWORD;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stáhne Bolt jen jednou — uložená nabídka nemá expiraci', async () => {
|
||||||
|
jest.useFakeTimers({ now: new Date('2026-09-01T10:00:00Z'), doNotFake: ['nextTick', 'setImmediate'] });
|
||||||
|
mockedAxios.get.mockResolvedValue(boltResponse());
|
||||||
|
const menu = await getStoreMenu('new york');
|
||||||
|
expect(menu.source).toBe('bolt');
|
||||||
|
expect(menu.dishes.filter(d => d.isMain)).toHaveLength(2);
|
||||||
|
jest.setSystemTime(new Date('2026-12-01T10:00:00Z'));
|
||||||
|
await getStoreMenu('New York');
|
||||||
|
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('při selhání Boltu použije Wolt', async () => {
|
||||||
|
mockedAxios.get
|
||||||
|
.mockRejectedValueOnce(new Error('timeout'))
|
||||||
|
.mockResolvedValueOnce(woltResponse());
|
||||||
|
const menu = await getStoreMenu('New York');
|
||||||
|
expect(menu.source).toBe('wolt');
|
||||||
|
expect(menu.dishes.filter(d => d.isMain).map(d => d.name)).toEqual(['Cheeseburger', 'Kuřecí tortilla']);
|
||||||
|
const [status] = await getStoreMenuStatus();
|
||||||
|
expect(status.providers).toEqual([
|
||||||
|
expect.objectContaining({ provider: 'bolt', state: 'failed', failures: 1, lastError: 'timeout' }),
|
||||||
|
expect.objectContaining({ provider: 'wolt', state: 'ok', dishCount: 2 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('po selhání zkouší nejdřív za 3 dny a po 2 neúspěších službu zablokuje', async () => {
|
||||||
|
jest.useFakeTimers({ now: new Date('2026-09-01T10:00:00Z'), doNotFake: ['nextTick', 'setImmediate'] });
|
||||||
|
await getStorage().setData('stores', [{ name: 'Jen Bolt', urls: [BOLT_URL] }]);
|
||||||
|
mockedAxios.get.mockRejectedValue(new Error('HTTP 500'));
|
||||||
|
|
||||||
|
await getStoreMenu('Jen Bolt');
|
||||||
|
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
|
||||||
|
jest.setSystemTime(Date.now() + 2 * DAY);
|
||||||
|
await getStoreMenu('Jen Bolt'); // příliš brzy — bez pokusu
|
||||||
|
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
|
||||||
|
jest.setSystemTime(Date.now() + 1 * DAY);
|
||||||
|
await getStoreMenu('Jen Bolt'); // druhý pokus → zablokováno
|
||||||
|
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
|
||||||
|
jest.setSystemTime(Date.now() + 30 * DAY);
|
||||||
|
const menu = await getStoreMenu('Jen Bolt');
|
||||||
|
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
|
||||||
|
expect(menu.source).toBe('none');
|
||||||
|
const [status] = await getStoreMenuStatus();
|
||||||
|
expect(status.providers[0]).toEqual(expect.objectContaining({ state: 'blocked', failures: 2, lastError: 'HTTP 500' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP 429 se nepočítá jako selhání a službu dočasně pozastaví', async () => {
|
||||||
|
jest.useFakeTimers({ now: new Date('2026-09-01T10:00:00Z'), doNotFake: ['nextTick', 'setImmediate'] });
|
||||||
|
await getStorage().setData('stores', [{ name: 'Jen Bolt', urls: [BOLT_URL] }, { name: 'Jiný Bolt', urls: ['https://food.bolt.eu/cs-cz/427-plzen/p/2-jiny/'] }]);
|
||||||
|
mockedAxios.get.mockRejectedValueOnce(tooManyRequests('120'));
|
||||||
|
|
||||||
|
await getStoreMenu('Jen Bolt');
|
||||||
|
// Během pauzy se na Bolt vůbec nedotazujeme — ani pro jiný obchod
|
||||||
|
await getStoreMenu('Jiný Bolt');
|
||||||
|
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
|
||||||
|
const status = await getStoreMenuStatus();
|
||||||
|
expect(status[0].providers[0]).toEqual(expect.objectContaining({ state: 'pending', failures: 0 }));
|
||||||
|
expect(status[0].providers[0].lastError).toContain('429');
|
||||||
|
|
||||||
|
// Po uplynutí Retry-After se hned zkusí znovu (žádný 3denní odklad)
|
||||||
|
jest.setSystemTime(Date.now() + 121 * 1000);
|
||||||
|
mockedAxios.get.mockResolvedValue(boltResponse());
|
||||||
|
const menu = await getStoreMenu('Jen Bolt');
|
||||||
|
expect(menu.source).toBe('bolt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ruční obnovení stáhne i zablokovanou službu a zablokování zruší', async () => {
|
||||||
|
await getStorage().setData('store_menus', {
|
||||||
|
'New York': { bolt: { url: BOLT_URL, failures: 2, blocked: true, lastError: 'x', lastAttemptAt: new Date().toISOString() } },
|
||||||
|
});
|
||||||
|
mockedAxios.get.mockResolvedValue(boltResponse());
|
||||||
|
const info = await refreshStoreMenu('new york', 'bolt', ADMIN_PW);
|
||||||
|
expect(info.providers[0]).toEqual(expect.objectContaining({ provider: 'bolt', state: 'ok', dishCount: 2, failures: 0 }));
|
||||||
|
expect(info.providers[0].lastError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neúspěšné ruční obnovení ponechá dříve staženou nabídku', async () => {
|
||||||
|
mockedAxios.get.mockResolvedValueOnce(boltResponse()).mockRejectedValueOnce(new Error('HTTP 403'));
|
||||||
|
await getStoreMenu('New York');
|
||||||
|
const info = await refreshStoreMenu('New York', 'bolt', ADMIN_PW);
|
||||||
|
expect(info.providers[0]).toEqual(expect.objectContaining({ state: 'ok', dishCount: 2, failures: 1, lastError: 'HTTP 403' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ruční obnovení vyžaduje heslo a URL služby', async () => {
|
||||||
|
await expect(refreshStoreMenu('New York', 'bolt', 'spatne')).rejects.toThrow('UNAUTHORIZED');
|
||||||
|
await expect(refreshStoreMenu('Bez URL', 'wolt', ADMIN_PW)).rejects.toThrow('Obchod nemá URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('změna URL služby zahodí uloženou nabídku', async () => {
|
||||||
|
await getStorage().setData('store_menus', {
|
||||||
|
'New York': { bolt: { url: 'https://food.bolt.eu/cs-cz/427-plzen/p/1-stary/', dishes: [{ name: 'Staré' }], failures: 0, blocked: false } },
|
||||||
|
});
|
||||||
|
mockedAxios.get.mockResolvedValue(boltResponse());
|
||||||
|
const menu = await getStoreMenu('New York');
|
||||||
|
expect(menu.dishes.filter(d => d.isMain).map(d => d.name)).toEqual(['Chicken Burger', 'Big Cheeseburger']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('přejmenování obchodu přenese uloženou nabídku', async () => {
|
||||||
|
await getStorage().setData('store_menus', { 'New York': { bolt: { url: BOLT_URL, dishes: [{ name: 'Burger' }], failures: 0, blocked: false } } });
|
||||||
|
await renameStoreMenus('new york', 'NY Burger');
|
||||||
|
expect(await getStorage().getData('store_menus')).toEqual({ 'NY Burger': expect.any(Object) });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bez stažitelné nabídky použije historii objednávek', async () => {
|
||||||
|
await getStorage().setData('2026-09-01_extra', {
|
||||||
|
groups: [{ id: 'g', name: 'Bez URL', creatorLogin: 'a', state: 'delivered', members: { a: { note: 'Kebab box' } } }],
|
||||||
|
});
|
||||||
|
const menu = await getStoreMenu('Bez URL');
|
||||||
|
expect(menu).toEqual({ store: 'Bez URL', source: 'history', dishes: [{ name: 'Kebab box', isMain: true }] });
|
||||||
|
expect(mockedAxios.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neznámý obchod vyhodí chybu', async () => {
|
||||||
|
await expect(getStoreMenu('Neexistuje')).rejects.toThrow('Obchod nebyl nalezen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('historie cen', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
process.env.ADMIN_PASSWORD = ADMIN_PW;
|
||||||
|
await getStorage().setData('stores', [{ name: 'New York', urls: [BOLT_URL] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.ADMIN_PASSWORD;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diffMenus najde změněné ceny, nové i odebrané položky', () => {
|
||||||
|
expect(diffMenus(
|
||||||
|
[{ name: 'Burger', price: 150 }, { name: 'Hranolky', price: 59 }, { name: 'Salát', price: 99 }],
|
||||||
|
[{ name: 'burger', price: 165 }, { name: 'Hranolky', price: 59 }, { name: 'Wrap', price: 129 }],
|
||||||
|
)).toEqual([
|
||||||
|
{ name: 'burger', before: 150, after: 165 },
|
||||||
|
{ name: 'Wrap', after: 129 },
|
||||||
|
{ name: 'Salát', before: 99 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('první stažení uloží výchozí stav, další jen změny (beze změn nic)', async () => {
|
||||||
|
const changed = boltResponse();
|
||||||
|
changed.data.data.items[100 as any].price = { value: 199 };
|
||||||
|
|
||||||
|
mockedAxios.get.mockResolvedValueOnce(boltResponse()).mockResolvedValueOnce(boltResponse()).mockResolvedValueOnce(changed);
|
||||||
|
await getStoreMenu('New York');
|
||||||
|
await refreshStoreMenu('New York', 'bolt', ADMIN_PW); // beze změny
|
||||||
|
await refreshStoreMenu('New York', 'bolt', ADMIN_PW); // Big Cheeseburger 189 → 199
|
||||||
|
|
||||||
|
const history = await getStoreMenuHistory('new york');
|
||||||
|
expect(history.store).toBe('New York');
|
||||||
|
const entries = history.providers[0].entries;
|
||||||
|
expect(entries).toHaveLength(2);
|
||||||
|
expect(entries[0].changes).toEqual([{ name: 'Big Cheeseburger', before: 189, after: 199 }]);
|
||||||
|
expect(entries[1]).toEqual(expect.objectContaining({ initial: true, itemCount: 7, changes: [] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neúspěšné stažení historii nemění', async () => {
|
||||||
|
mockedAxios.get.mockRejectedValue(new Error('HTTP 500'));
|
||||||
|
await getStoreMenu('New York');
|
||||||
|
expect((await getStoreMenuHistory('New York')).providers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nabídka stažená před zavedením historie se zobrazí jako výchozí verze', async () => {
|
||||||
|
await getStorage().setData('store_menus', {
|
||||||
|
'New York': { bolt: { url: BOLT_URL, dishes: [{ name: 'Burger', price: 150 }], failures: 0, blocked: false, lastSuccessAt: '2026-09-01T10:00:00.000Z' } },
|
||||||
|
});
|
||||||
|
const history = await getStoreMenuHistory('New York');
|
||||||
|
expect(history.providers).toEqual([
|
||||||
|
{ provider: 'bolt', entries: [{ at: '2026-09-01T10:00:00.000Z', initial: true, itemCount: 1, changes: [] }] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('první změna u nabídky stažené před zavedením historie zachová výchozí verzi', async () => {
|
||||||
|
await getStorage().setData('store_menus', {
|
||||||
|
'New York': { bolt: { url: BOLT_URL, dishes: [{ name: 'Chicken Burger', price: 150, isMain: true }], failures: 0, blocked: false, lastSuccessAt: '2026-09-01T10:00:00.000Z' } },
|
||||||
|
});
|
||||||
|
mockedAxios.get.mockResolvedValue(boltResponse());
|
||||||
|
await refreshStoreMenu('New York', 'bolt', ADMIN_PW);
|
||||||
|
const entries = (await getStoreMenuHistory('New York')).providers[0].entries;
|
||||||
|
expect(entries).toHaveLength(2);
|
||||||
|
expect(entries[0].changes).toContainEqual({ name: 'Chicken Burger', before: 150, after: 169 });
|
||||||
|
expect(entries[1]).toEqual({ at: '2026-09-01T10:00:00.000Z', initial: true, itemCount: 1, changes: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { resetMemoryStorage } from '../storage/memory';
|
import { resetMemoryStorage } from '../storage/memory';
|
||||||
import getStorage from '../storage';
|
import getStorage from '../storage';
|
||||||
import { getStores, addStore, updateStore, removeStore } from '../stores';
|
import { getStores, addStore, updateStore, removeStore, getStoreDishes } from '../stores';
|
||||||
|
|
||||||
const ADMIN_PW = 'testadmin';
|
const ADMIN_PW = 'testadmin';
|
||||||
|
|
||||||
@@ -213,3 +213,27 @@ describe('removeStore', () => {
|
|||||||
expect(names(stores)).toContain('McDonald\'s');
|
expect(names(stores)).toContain('McDonald\'s');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getStoreDishes', () => {
|
||||||
|
/** Uloží skupinové objednávky pro daný den ve formátu `YYYY-MM-DD_extra`. */
|
||||||
|
const saveGroups = (date: string, groups: { name: string, notes: string[] }[]) =>
|
||||||
|
getStorage().setData(`${date}_extra`, {
|
||||||
|
groups: groups.map((g, gi) => ({
|
||||||
|
id: `${date}-${gi}`, name: g.name, creatorLogin: 'a', state: 'delivered',
|
||||||
|
members: Object.fromEntries(g.notes.map((note, i) => [`user${i}`, { note }])),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vrátí jídla dle četnosti, bez duplicit, polévek a čísel položek', async () => {
|
||||||
|
await getStorage().setData('stores', [{ name: 'China Express' }, { name: 'Everest' }]);
|
||||||
|
await saveGroups('2026-09-01', [{ name: 'China Express', notes: ['28. Vepřové v pekingské omáčce', 'Kuře na medu', 'Polévka Pho'] }]);
|
||||||
|
await saveGroups('2026-09-02', [{ name: 'china express', notes: ['kuře na medu', ' ', '⬇️ (to samé)', 'Hovězí se zeleninou'] }]);
|
||||||
|
await saveGroups('2026-09-03', [{ name: 'Neznámý podnik', notes: ['Burger'] }]);
|
||||||
|
|
||||||
|
const result = await getStoreDishes();
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ store: 'China Express', dishes: ['Kuře na medu', 'Hovězí se zeleninou', 'Vepřové v pekingské omáčce'] },
|
||||||
|
{ store: 'Everest', dishes: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { getTrustedRemoteUser, isTrustedPeer, parseTrustedIps } from '../trustedHeaders';
|
||||||
|
|
||||||
|
describe('parseTrustedIps / isTrustedPeer', () => {
|
||||||
|
const trusted = parseTrustedIps('10.42.0.0/16, 127.0.0.1, ::1, fd00::/8');
|
||||||
|
|
||||||
|
test('rozsah CIDR i jednotlivé adresy (IPv4 i IPv6)', () => {
|
||||||
|
expect(isTrustedPeer('10.42.0.84', trusted)).toBe(true);
|
||||||
|
expect(isTrustedPeer('10.43.0.1', trusted)).toBe(false);
|
||||||
|
expect(isTrustedPeer('127.0.0.1', trusted)).toBe(true);
|
||||||
|
expect(isTrustedPeer('::1', trusted)).toBe(true);
|
||||||
|
expect(isTrustedPeer('fd12:3456::1', trusted)).toBe(true);
|
||||||
|
expect(isTrustedPeer('192.168.1.10', trusted)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('IPv4 mapovaná do IPv6 (jak ji hlásí Node) se porovnává jako IPv4', () => {
|
||||||
|
expect(isTrustedPeer('::ffff:10.42.0.84', trusted)).toBe(true);
|
||||||
|
expect(isTrustedPeer('::FFFF:8.8.8.8', trusted)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chybějící nebo nesmyslná adresa není důvěryhodná', () => {
|
||||||
|
expect(isTrustedPeer(undefined, trusted)).toBe(false);
|
||||||
|
expect(isTrustedPeer('nesmysl', trusted)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neplatná konfigurace vyhodí chybu (server nenaběhne)', () => {
|
||||||
|
expect(() => parseTrustedIps('10.0.0.0/33')).toThrow('neplatný rozsah');
|
||||||
|
expect(() => parseTrustedIps('proxy.local')).toThrow('neplatná adresa');
|
||||||
|
expect(() => parseTrustedIps('10.0.0.0/x')).toThrow('neplatný rozsah');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prázdný seznam nedůvěřuje nikomu', () => {
|
||||||
|
expect(isTrustedPeer('127.0.0.1', parseTrustedIps(''))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTrustedRemoteUser', () => {
|
||||||
|
/** Mini aplikace, která vrátí identitu z hlavičky (jako /api/whoami). */
|
||||||
|
function buildApp(trustedList: string) {
|
||||||
|
const trusted = parseTrustedIps(trustedList);
|
||||||
|
const app = express();
|
||||||
|
app.get('/whoami', (req, res) => res.send(getTrustedRemoteUser(req, 'remote-user', trusted) ?? ''));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('hlavička od důvěryhodné adresy se přijme (i s diakritikou v latin1)', async () => {
|
||||||
|
const res = await request(buildApp('127.0.0.1,::1,::ffff:127.0.0.1'))
|
||||||
|
.get('/whoami')
|
||||||
|
.set('remote-user', Buffer.from('Novák', 'utf8').toString('latin1'));
|
||||||
|
expect(res.text).toBe('Novák');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hlavička od nedůvěryhodné adresy se ignoruje — ani X-Forwarded-For nepomůže', async () => {
|
||||||
|
const warn = jest.spyOn(console, 'warn').mockImplementation(() => { });
|
||||||
|
const res = await request(buildApp('10.42.0.0/16'))
|
||||||
|
.get('/whoami')
|
||||||
|
.set('remote-user', 'utocnik')
|
||||||
|
.set('X-Forwarded-For', '10.42.0.84');
|
||||||
|
expect(res.text).toBe('');
|
||||||
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('nedůvěryhodné adresy'));
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -67,6 +67,14 @@ describe('getUserExportRows', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('u volby podniku vyplní uživatelem zadanou cenu', async () => {
|
||||||
|
await saveLunch('2025-03-04', { SPSE: { [USER]: { price: 9500 } } });
|
||||||
|
|
||||||
|
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toMatchObject({ type: 'SPŠE', amount: 9500 });
|
||||||
|
});
|
||||||
|
|
||||||
test('u volby "budu objednávat" sloučí záznam s objednávkovou skupinou včetně ceny po slevě', async () => {
|
test('u volby "budu objednávat" sloučí záznam s objednávkovou skupinou včetně ceny po slevě', async () => {
|
||||||
await saveLunch('2025-03-06', { OBJEDNAVAM: { [USER]: { note: 'platím kartou' } } });
|
await saveLunch('2025-03-06', { OBJEDNAVAM: { [USER]: { note: 'platím kartou' } } });
|
||||||
await saveExtra('2025-03-06', [{
|
await saveExtra('2025-03-06', [{
|
||||||
@@ -167,18 +175,31 @@ describe('getUserExportRows', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('člen skupiny, který si nic neobjednal, nemá vyplněnou částku', async () => {
|
test('skupinu, kde si člen nic neobjednal a nemá poznámku, vynechá', async () => {
|
||||||
|
await saveLunch('2025-03-12', { OBJEDNAVAM: { [USER]: {} } });
|
||||||
|
await saveExtra('2025-03-12', [
|
||||||
|
{ id: 'g3', name: 'KFC', creatorLogin: USER, state: GroupState.ORDERED, members: { [USER]: {}, [OTHER]: { amount: 15000 } } },
|
||||||
|
{ id: 'g4', name: 'Namaste', creatorLogin: USER, state: GroupState.ORDERED, members: { [USER]: { amount: 22000, note: 'Chicken Tikka' } } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
||||||
|
// Volba "budu objednávat" se sloučí až se skupinou, kde si uživatel reálně objednal
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toMatchObject({ type: 'Budu objednávat', store: 'Namaste', food: 'Chicken Tikka', amount: 22000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('člen skupiny, který si nic neobjednal, ale má poznámku, nemá vyplněnou částku', async () => {
|
||||||
await saveExtra('2025-03-12', [{
|
await saveExtra('2025-03-12', [{
|
||||||
id: 'g3',
|
id: 'g3',
|
||||||
name: 'KFC',
|
name: 'KFC',
|
||||||
creatorLogin: USER,
|
creatorLogin: USER,
|
||||||
state: GroupState.ORDERED,
|
state: GroupState.ORDERED,
|
||||||
members: { [USER]: {}, [OTHER]: { amount: 15000 } },
|
members: { [USER]: { note: 'beru si jen pití od kolegy' }, [OTHER]: { amount: 15000 } },
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
// Objednávající je uveden, ale částka zůstává prázdná — není co platit
|
// Záznam je uveden kvůli poznámce, ale částka zůstává prázdná — není co platit
|
||||||
expect(rows[0].orderedBy).toBe(USER);
|
expect(rows[0].orderedBy).toBe(USER);
|
||||||
expect(rows[0].amount).toBeUndefined();
|
expect(rows[0].amount).toBeUndefined();
|
||||||
expect(buildCsv(rows).split('\r\n')[1].split(';')[7]).toBe('');
|
expect(buildCsv(rows).split('\r\n')[1].split(';')[7]).toBe('');
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import net from "net";
|
||||||
|
import type { Request } from "express";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seznam adres, ze kterých smí přijít hlavička s identitou uživatele (přihlášení přes proxy).
|
||||||
|
* Podporuje jednotlivé adresy i rozsahy CIDR, IPv4 i IPv6 (např. "10.42.0.0/16,127.0.0.1,::1").
|
||||||
|
* Neplatná položka je chyba konfigurace — server kvůli ní nenaběhne (lepší než tiše důvěřovat).
|
||||||
|
*/
|
||||||
|
export function parseTrustedIps(list: string): net.BlockList {
|
||||||
|
const blockList = new net.BlockList();
|
||||||
|
for (const raw of list.split(',').map(s => s.trim()).filter(Boolean)) {
|
||||||
|
const [address, prefix] = raw.split('/');
|
||||||
|
const family = net.isIP(address);
|
||||||
|
if (!family) {
|
||||||
|
throw new Error(`HTTP_REMOTE_TRUSTED_IPS: neplatná adresa "${raw}"`);
|
||||||
|
}
|
||||||
|
const type = family === 4 ? 'ipv4' : 'ipv6';
|
||||||
|
if (prefix === undefined) {
|
||||||
|
blockList.addAddress(address, type);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bits = Number(prefix);
|
||||||
|
if (!Number.isInteger(bits) || bits < 0 || bits > (family === 4 ? 32 : 128)) {
|
||||||
|
throw new Error(`HTTP_REMOTE_TRUSTED_IPS: neplatný rozsah "${raw}"`);
|
||||||
|
}
|
||||||
|
blockList.addSubnet(address, bits, type);
|
||||||
|
}
|
||||||
|
return blockList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Je adresa přímého protějšku spojení mezi důvěryhodnými? IPv4 mapovaná do IPv6
|
||||||
|
* („::ffff:10.42.0.84", jak ji hlásí Node na dual-stack socketu) se porovnává jako IPv4.
|
||||||
|
*/
|
||||||
|
export function isTrustedPeer(address: string | undefined, trusted: net.BlockList): boolean {
|
||||||
|
if (!address) return false;
|
||||||
|
const mapped = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||||
|
const normalized = mapped ? mapped[1] : address;
|
||||||
|
const family = net.isIP(normalized);
|
||||||
|
if (!family) return false;
|
||||||
|
return trusted.check(normalized, family === 4 ? 'ipv4' : 'ipv6');
|
||||||
|
}
|
||||||
|
|
||||||
|
const warnedPeers = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí identitu uživatele z hlavičky proxy, ale jen pokud spojení přišlo z důvěryhodné adresy.
|
||||||
|
* Rozhoduje skutečná adresa TCP spojení (ne req.ip, které lze ovlivnit hlavičkou X-Forwarded-For).
|
||||||
|
* Hlavičku od nedůvěryhodné adresy ignoruje a jednou za běh ji zaloguje (odhalí chybnou konfiguraci).
|
||||||
|
*/
|
||||||
|
export function getTrustedRemoteUser(req: Request, headerName: string, trusted: net.BlockList): string | undefined {
|
||||||
|
const value = req.header(headerName);
|
||||||
|
if (!value) return undefined;
|
||||||
|
const peer = req.socket?.remoteAddress;
|
||||||
|
if (!isTrustedPeer(peer, trusted)) {
|
||||||
|
const key = peer ?? 'neznámá adresa';
|
||||||
|
if (!warnedPeers.has(key)) {
|
||||||
|
warnedPeers.add(key);
|
||||||
|
console.warn(`Přihlášení přes hlavičky: ignoruji hlavičku ${headerName} z nedůvěryhodné adresy ${key} (viz HTTP_REMOTE_TRUSTED_IPS)`);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Buffer.from(value, 'latin1').toString();
|
||||||
|
}
|
||||||
@@ -59,6 +59,11 @@ function isActiveMember(member: OrderGroupMember): boolean {
|
|||||||
return (member.amount ?? 0) + (member.surchargeAmount ?? 0) > 0;
|
return (member.amount ?? 0) + (member.surchargeAmount ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vrátí true, pokud má členství ve skupině do přehledu co přinést (položku nebo poznámku). */
|
||||||
|
function isRelevantMember(member: OrderGroupMember | undefined): boolean {
|
||||||
|
return !!member && (isActiveMember(member) || !!member.note?.trim().length);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vypočte částku člena skupiny po rozpočítání poplatků a slevy (v haléřích).
|
* Vypočte částku člena skupiny po rozpočítání poplatků a slevy (v haléřích).
|
||||||
* Poplatky i pevná sleva se dělí pouze mezi členy, kteří si něco objednali.
|
* Poplatky i pevná sleva se dělí pouze mezi členy, kteří si něco objednali.
|
||||||
@@ -152,7 +157,7 @@ export async function getUserExportRows(login: string, year: number, month: numb
|
|||||||
|
|
||||||
let food: string | undefined;
|
let food: string | undefined;
|
||||||
let note = userChoice.note;
|
let note = userChoice.note;
|
||||||
let amount: number | undefined;
|
let amount = userChoice.price;
|
||||||
if (locationKey === LunchChoice.PIZZA) {
|
if (locationKey === LunchChoice.PIZZA) {
|
||||||
const pizza = getPizzaText(lunchData, login);
|
const pizza = getPizzaText(lunchData, login);
|
||||||
food = pizza.food;
|
food = pizza.food;
|
||||||
@@ -177,8 +182,9 @@ export async function getUserExportRows(login: string, year: number, month: numb
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Objednávkové skupiny, ve kterých byl uživatel členem
|
// Objednávkové skupiny, ve kterých si uživatel něco objednal nebo k nim má poznámku.
|
||||||
const groups = (extraData?.groups ?? []).filter(group => !!group.members[login]);
|
// Zakladatel skupiny, který si z ní nic nevzal, v ní figuruje jen formálně — řádek by byl prázdný.
|
||||||
|
const groups = (extraData?.groups ?? []).filter(group => isRelevantMember(group.members[login]));
|
||||||
const groupRows: UserExportRow[] = [];
|
const groupRows: UserExportRow[] = [];
|
||||||
let mergedGroup = false;
|
let mergedGroup = false;
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DefaultEventsMap, Server } from "socket.io";
|
import { DefaultEventsMap, Server } from "socket.io";
|
||||||
import { createAdapter } from "@socket.io/redis-adapter";
|
import { createAdapter } from "@socket.io/redis-adapter";
|
||||||
import { createClient } from "redis";
|
import { createClient } from "redis";
|
||||||
|
import { registerDrawingGame } from "./drawingGame";
|
||||||
|
|
||||||
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
|
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
|
||||||
let pubClient: ReturnType<typeof createClient>;
|
let pubClient: ReturnType<typeof createClient>;
|
||||||
@@ -28,6 +29,7 @@ export const initWebsocket = (server: any) => {
|
|||||||
// console.log(`Client disconnected: ${socket.id}`);
|
// console.log(`Client disconnected: ${socket.id}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
registerDrawingGame(io);
|
||||||
return io;
|
return io;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ paths:
|
|||||||
$ref: "./paths/food/removeChoice.yml"
|
$ref: "./paths/food/removeChoice.yml"
|
||||||
/food/updateNote:
|
/food/updateNote:
|
||||||
$ref: "./paths/food/updateNote.yml"
|
$ref: "./paths/food/updateNote.yml"
|
||||||
|
/food/updatePrice:
|
||||||
|
$ref: "./paths/food/updatePrice.yml"
|
||||||
/food/removeChoices:
|
/food/removeChoices:
|
||||||
$ref: "./paths/food/removeChoices.yml"
|
$ref: "./paths/food/removeChoices.yml"
|
||||||
/food/changeDepartureTime:
|
/food/changeDepartureTime:
|
||||||
@@ -150,6 +152,14 @@ paths:
|
|||||||
# Správa obchodů (/api/stores)
|
# Správa obchodů (/api/stores)
|
||||||
/stores:
|
/stores:
|
||||||
$ref: "./paths/stores/listStores.yml"
|
$ref: "./paths/stores/listStores.yml"
|
||||||
|
/stores/menu:
|
||||||
|
$ref: "./paths/stores/storeMenu.yml"
|
||||||
|
/stores/menu/status:
|
||||||
|
$ref: "./paths/stores/storeMenuStatus.yml"
|
||||||
|
/stores/menu/refresh:
|
||||||
|
$ref: "./paths/stores/refreshStoreMenu.yml"
|
||||||
|
/stores/menu/history:
|
||||||
|
$ref: "./paths/stores/storeMenuHistory.yml"
|
||||||
/stores/add:
|
/stores/add:
|
||||||
$ref: "./paths/stores/addStore.yml"
|
$ref: "./paths/stores/addStore.yml"
|
||||||
/stores/update:
|
/stores/update:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
get:
|
get:
|
||||||
operationId: getConfig
|
operationId: getConfig
|
||||||
summary: Vrátí veřejnou runtime konfiguraci pro klienta (např. Sentry DSN). Nevyžaduje autentizaci.
|
summary: Vrátí veřejnou runtime konfiguraci pro klienta (Sentry DSN, limity objednávek, způsob přihlášení, zapnuté funkce, odkazy). Nevyžaduje autentizaci.
|
||||||
security: [] # Nevyžaduje autentizaci
|
security: [] # Nevyžaduje autentizaci
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -9,8 +9,36 @@ get:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [sentry]
|
required: [sentry, limits, auth, features, links]
|
||||||
properties:
|
properties:
|
||||||
|
auth:
|
||||||
|
type: object
|
||||||
|
required: [trustedHeaders]
|
||||||
|
properties:
|
||||||
|
trustedHeaders:
|
||||||
|
type: boolean
|
||||||
|
description: Přihlášení probíhá přes důvěryhodné hlavičky z proxy (HTTP_REMOTE_USER_ENABLED) — klient nezobrazuje formulář se jménem
|
||||||
|
features:
|
||||||
|
type: object
|
||||||
|
required: [storeAdmin]
|
||||||
|
properties:
|
||||||
|
storeAdmin:
|
||||||
|
type: boolean
|
||||||
|
description: Správa obchodů je zapnutá (server má nastavené ADMIN_PASSWORD)
|
||||||
|
links:
|
||||||
|
type: object
|
||||||
|
required: [lobbyUrl]
|
||||||
|
properties:
|
||||||
|
lobbyUrl:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Adresa aplikace Lobby s minihrami (LOBBY_URL), null = odkaz není nastaven
|
||||||
|
limits:
|
||||||
|
type: object
|
||||||
|
required: [group]
|
||||||
|
properties:
|
||||||
|
group:
|
||||||
|
$ref: "../../schemas/_index.yml#/GroupLimits"
|
||||||
sentry:
|
sentry:
|
||||||
type: object
|
type: object
|
||||||
required: [dsn, environment]
|
required: [dsn, environment]
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
post:
|
||||||
|
operationId: updatePrice
|
||||||
|
summary: Nastavení ceny oběda k volbě uživatele (pro přehled stravování). Lze jen u voleb podniků, ne u objednávání, Pizza day, "neobědvám" a "rozhoduji se".
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
dayIndex:
|
||||||
|
$ref: "../../schemas/_index.yml#/DayIndex"
|
||||||
|
price:
|
||||||
|
description: Cena v haléřích. Pokud není předána, cena se odstraní.
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
post:
|
||||||
|
operationId: refreshStoreMenu
|
||||||
|
summary: Ručně znovu stáhne nabídku obchodu od dané služby (vyžaduje admin heslo). Zruší případné zablokování automatického stahování.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- provider
|
||||||
|
- heslo
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
description: Název obchodu
|
||||||
|
type: string
|
||||||
|
provider:
|
||||||
|
$ref: "../../schemas/_index.yml#/StoreMenuProvider"
|
||||||
|
heslo:
|
||||||
|
description: Admin heslo (ADMIN_PASSWORD)
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktuální stav stahování nabídek obchodu (i při neúspěchu — chyba je v lastError)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/StoreScrapeInfo"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
get:
|
||||||
|
operationId: getStoreMenu
|
||||||
|
summary: Vrátí nabídku obchodu — staženou z Bolt Food/Wolt (hlavní jídla označena isMain), případně jídla z historie objednávek.
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: name
|
||||||
|
required: true
|
||||||
|
description: Název obchodu ze seznamu povolených
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Nabídka obchodu
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/StoreMenu"
|
||||||
|
"404":
|
||||||
|
description: Obchod nebyl nalezen
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
get:
|
||||||
|
operationId: getStoreMenuHistory
|
||||||
|
summary: Vrátí historii cen nabídky obchodu (verze z jednotlivých stažení z Bolt Food/Wolt).
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: name
|
||||||
|
required: true
|
||||||
|
description: Název obchodu
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Historie cen
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/StorePriceHistory"
|
||||||
|
"404":
|
||||||
|
description: Obchod nebyl nalezen
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
get:
|
||||||
|
operationId: getStoreMenuStatus
|
||||||
|
summary: Vrátí stav stahování nabídek (Bolt Food/Wolt) pro všechny obchody.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Stav stahování nabídek
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "../../schemas/_index.yml#/StoreScrapeInfo"
|
||||||
@@ -101,6 +101,9 @@ UserLunchChoice:
|
|||||||
note:
|
note:
|
||||||
description: Volitelná, veřejně viditelná uživatelská poznámka k vybrané volbě
|
description: Volitelná, veřejně viditelná uživatelská poznámka k vybrané volbě
|
||||||
type: string
|
type: string
|
||||||
|
price:
|
||||||
|
description: Uživatelem zadaná cena oběda v haléřích, promítá se do přehledu stravování
|
||||||
|
type: integer
|
||||||
isBuyer:
|
isBuyer:
|
||||||
description: Příznak, zda je tento uživatel objednatelem pro stav "Budu objednávat"
|
description: Příznak, zda je tento uživatel objednatelem pro stav "Budu objednávat"
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -933,6 +936,234 @@ Store:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
StoreMenuSource:
|
||||||
|
description: Zdroj nabídky obchodu pro kolo štěstí
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- bolt
|
||||||
|
- wolt
|
||||||
|
- history
|
||||||
|
- none
|
||||||
|
x-enum-varnames:
|
||||||
|
- BOLT
|
||||||
|
- WOLT
|
||||||
|
- HISTORY
|
||||||
|
- NONE
|
||||||
|
|
||||||
|
GroupLimits:
|
||||||
|
description: Limity skupinových objednávek (pojistky proti překlepům). Výchozí hodnoty lze přepsat env proměnnými GROUP_MAX_*.
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- maxNoteLength
|
||||||
|
- maxSurchargeTextLength
|
||||||
|
- maxNameLength
|
||||||
|
- maxUrlLength
|
||||||
|
- maxAmountKc
|
||||||
|
- maxItemQuantity
|
||||||
|
- maxItems
|
||||||
|
properties:
|
||||||
|
maxNoteLength:
|
||||||
|
description: Max. délka poznámky člena (znaky)
|
||||||
|
type: integer
|
||||||
|
maxSurchargeTextLength:
|
||||||
|
description: Max. délka popisu příplatku (znaky)
|
||||||
|
type: integer
|
||||||
|
maxNameLength:
|
||||||
|
description: Max. délka názvu skupiny (znaky)
|
||||||
|
type: integer
|
||||||
|
maxUrlLength:
|
||||||
|
description: Max. délka URL nabídky / sledování (znaky)
|
||||||
|
type: integer
|
||||||
|
maxAmountKc:
|
||||||
|
description: Max. výše jedné částky (útrata, příplatek, poplatky, pevná sleva) v Kč
|
||||||
|
type: integer
|
||||||
|
maxItemQuantity:
|
||||||
|
description: Max. počet kusů jedné položky při výběru z nabídky
|
||||||
|
type: integer
|
||||||
|
maxItems:
|
||||||
|
description: Max. celkový počet položek objednávky při výběru z nabídky
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
StoreMenuProvider:
|
||||||
|
description: Dovozová služba, ze které lze stáhnout nabídku podniku
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- bolt
|
||||||
|
- wolt
|
||||||
|
x-enum-varnames:
|
||||||
|
- BOLT
|
||||||
|
- WOLT
|
||||||
|
|
||||||
|
StoreScrapeState:
|
||||||
|
description: |
|
||||||
|
Stav stahování nabídky od dané služby:
|
||||||
|
ok = nabídka stažena, pending = zatím nezkoušeno, failed = selhalo (automaticky se ještě zkusí),
|
||||||
|
blocked = opakovaně selhalo, automaticky se už nezkouší (jen ruční obnovení)
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- ok
|
||||||
|
- pending
|
||||||
|
- failed
|
||||||
|
- blocked
|
||||||
|
x-enum-varnames:
|
||||||
|
- OK
|
||||||
|
- PENDING
|
||||||
|
- FAILED
|
||||||
|
- BLOCKED
|
||||||
|
|
||||||
|
StoreScrapeStatus:
|
||||||
|
description: Informace o stahování nabídky obchodu od jedné dovozové služby
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- provider
|
||||||
|
- url
|
||||||
|
- state
|
||||||
|
- dishCount
|
||||||
|
- failures
|
||||||
|
properties:
|
||||||
|
provider:
|
||||||
|
$ref: "#/StoreMenuProvider"
|
||||||
|
url:
|
||||||
|
description: URL podniku u dané služby
|
||||||
|
type: string
|
||||||
|
state:
|
||||||
|
$ref: "#/StoreScrapeState"
|
||||||
|
dishCount:
|
||||||
|
description: Počet uložených hlavních jídel
|
||||||
|
type: integer
|
||||||
|
failures:
|
||||||
|
description: Počet neúspěšných pokusů od posledního úspěchu
|
||||||
|
type: integer
|
||||||
|
lastSuccessAt:
|
||||||
|
description: Čas posledního úspěšného stažení (ISO)
|
||||||
|
type: string
|
||||||
|
lastAttemptAt:
|
||||||
|
description: Čas posledního pokusu (ISO)
|
||||||
|
type: string
|
||||||
|
lastError:
|
||||||
|
description: Chyba posledního neúspěšného pokusu
|
||||||
|
type: string
|
||||||
|
|
||||||
|
StoreScrapeInfo:
|
||||||
|
description: Stav stahování nabídek obchodu od všech podporovaných služeb
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- store
|
||||||
|
- providers
|
||||||
|
properties:
|
||||||
|
store:
|
||||||
|
type: string
|
||||||
|
providers:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/StoreScrapeStatus"
|
||||||
|
|
||||||
|
StorePriceChange:
|
||||||
|
description: Změna jedné položky nabídky mezi dvěma staženími (bez before = nová položka, bez after = odebraná)
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
before:
|
||||||
|
description: Předchozí cena v Kč
|
||||||
|
type: number
|
||||||
|
after:
|
||||||
|
description: Nová cena v Kč
|
||||||
|
type: number
|
||||||
|
|
||||||
|
StorePriceHistoryEntry:
|
||||||
|
description: Jedna verze nabídky — výchozí stav (initial) nebo změny oproti předchozímu stažení
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- at
|
||||||
|
- changes
|
||||||
|
properties:
|
||||||
|
at:
|
||||||
|
description: Čas stažení (ISO)
|
||||||
|
type: string
|
||||||
|
initial:
|
||||||
|
description: První stažená verze nabídky (výchozí stav, bez změn)
|
||||||
|
type: boolean
|
||||||
|
itemCount:
|
||||||
|
description: Počet položek v nabídce po tomto stažení
|
||||||
|
type: integer
|
||||||
|
changes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/StorePriceChange"
|
||||||
|
|
||||||
|
StorePriceHistory:
|
||||||
|
description: Historie cen nabídky obchodu dle dovozové služby (nejnovější první)
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- store
|
||||||
|
- providers
|
||||||
|
properties:
|
||||||
|
store:
|
||||||
|
type: string
|
||||||
|
providers:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- provider
|
||||||
|
- entries
|
||||||
|
properties:
|
||||||
|
provider:
|
||||||
|
$ref: "#/StoreMenuProvider"
|
||||||
|
entries:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/StorePriceHistoryEntry"
|
||||||
|
|
||||||
|
StoreMenuDish:
|
||||||
|
description: Jídlo z nabídky obchodu
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
description: Název jídla
|
||||||
|
type: string
|
||||||
|
price:
|
||||||
|
description: Cena v Kč (pokud je známa)
|
||||||
|
type: number
|
||||||
|
category:
|
||||||
|
description: Kategorie v nabídce podniku
|
||||||
|
type: string
|
||||||
|
isMain:
|
||||||
|
description: Hlavní jídlo (ne polévka, nápoj, příloha, omáčka...) — jen ta jdou do kola štěstí
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
StoreMenu:
|
||||||
|
description: Nabídka obchodu (kolo štěstí bere jen hlavní jídla, nápověda u objednávek celou nabídku)
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- store
|
||||||
|
- source
|
||||||
|
- dishes
|
||||||
|
properties:
|
||||||
|
store:
|
||||||
|
description: Název obchodu/restaurace
|
||||||
|
type: string
|
||||||
|
source:
|
||||||
|
$ref: "#/StoreMenuSource"
|
||||||
|
dishes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/StoreMenuDish"
|
||||||
|
|
||||||
TrackingProvider:
|
TrackingProvider:
|
||||||
description: Rozvozová služba, přes kterou lze sledovat stav objednávky
|
description: Rozvozová služba, přes kterou lze sledovat stav objednávky
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
Reference in new issue
Block a user