Compare commits
46
Commits
6a12bdb179
..
master
| 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
|
||
|
|
51df4c812e
|
||
|
|
ed53a1477e
|
No files matched your search
+13
-10
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: "26"
|
||||
|
||||
- run: corepack enable
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: "26"
|
||||
|
||||
- run: corepack enable
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: "26"
|
||||
|
||||
- run: corepack enable
|
||||
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: "26"
|
||||
|
||||
- run: corepack enable
|
||||
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: "26"
|
||||
|
||||
- run: corepack enable
|
||||
|
||||
@@ -231,8 +231,7 @@ jobs:
|
||||
steps:
|
||||
- name: Send webhook
|
||||
env:
|
||||
DISCORD_WEBHOOK_ID: ${{ secrets.DISCORD_WEBHOOK_ID }}
|
||||
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
NTFY_URL: ${{ secrets.NTFY_URL }}
|
||||
BUILD_RESULT: ${{ needs.docker-build.result }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
@@ -250,12 +249,16 @@ jobs:
|
||||
fi
|
||||
FULL_MSG="$(printf '%s\n\nPipeline: %s\nPoslední commit: %sAutor: %s' \
|
||||
"$MSG" "$RUN_URL" "$COMMIT_MESSAGE" "$COMMIT_AUTHOR")"
|
||||
curl -s -X POST \
|
||||
"https://discord.com/api/webhooks/${DISCORD_WEBHOOK_ID}/${DISCORD_WEBHOOK_TOKEN}" \
|
||||
# Nevyplněné URL = daný kanál notifikací se přeskočí
|
||||
if [ -n "$DISCORD_WEBHOOK_URL" ]; then
|
||||
curl -sS -f -X POST "${DISCORD_WEBHOOK_URL}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "$(jq -n --arg content "$FULL_MSG" '{content: $content}')"
|
||||
curl -s -X POST "${NTFY_URL}" \
|
||||
fi
|
||||
if [ -n "$NTFY_URL" ]; then
|
||||
curl -sS -f -X POST "${NTFY_URL}" \
|
||||
-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
|
||||
.playwright-mcp
|
||||
.idea/
|
||||
**/.yarn/install-state.gz
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
ARG NODE_VERSION="node:22-alpine"
|
||||
ARG NODE_VERSION="node:26-alpine"
|
||||
|
||||
# ─── Builder ──────────────────────────────────────────────────────────────────
|
||||
FROM ${NODE_VERSION} AS builder
|
||||
|
||||
@@ -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`)
|
||||
- [ ] `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í
|
||||
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli
|
||||
- [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
|
||||
- [ ] Nemělo by se jít dostat na přihlašovací formulář (měla by tam být nanejvýš hláška nebo přesměrování)
|
||||
- [ ] Možnost úhrady celé útraty jednou osobou
|
||||
- [X] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli — kontroluje se skutečná adresa spojení (trustedHeaders.ts)
|
||||
- [X] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
|
||||
- [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í)
|
||||
- [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
|
||||
- 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
|
||||
- [ ] 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
|
||||
- [ ] 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í)
|
||||
- [ ] 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)
|
||||
- [ ] Umožnit zadat URL/tokeny uživatelem
|
||||
- [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
|
||||
- [ ] Zavést notifikace typu "Jdeme na oběd"
|
||||
- [ ] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
|
||||
- [ ] Vylepšit parsery restaurací
|
||||
- [ ] Sladovnická
|
||||
- [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru
|
||||
- [ ] U Motlíků
|
||||
- [ ] 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í)
|
||||
- [ ] 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)')
|
||||
- [ ] 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] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul
|
||||
- [X] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu
|
||||
- [X] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům
|
||||
- [X] Generování a zobrazení QR kódů ostatním strávníkům
|
||||
- [X] Umožnit u každého strávníka připočíst vlastní částku (např. za pití)
|
||||
- [X] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť)
|
||||
- [X] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
|
||||
- [X] Umožnit zadat URL/tokeny uživatelem — ntfy, Discord, MS Teams (Gotify jen globálně na serveru)
|
||||
- [X] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
|
||||
- [X] Zavést notifikace typu "Jdeme na oběd"
|
||||
- [X] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
|
||||
- [X] Vylepšit parsery restaurací
|
||||
- [X] Sladovnická
|
||||
- [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í
|
||||
- [X] U Motlíků — odpadá, podnik už v aplikaci není
|
||||
- [X] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.')
|
||||
- [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í)
|
||||
- [X] TechTower
|
||||
- [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
|
||||
- [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
|
||||
- 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 ( , )
|
||||
- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru)
|
||||
- [ ] Zavést složku /data
|
||||
- [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
|
||||
- [X] Zavést složku /data
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] 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í
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<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>
|
||||
<script>
|
||||
(function() {
|
||||
@@ -24,6 +27,9 @@
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
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) {
|
||||
// Fallback pokud localStorage není dostupný
|
||||
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 'react-select-search/style.css';
|
||||
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 { useSettings } from './context/settings';
|
||||
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 Loader from './components/Loader';
|
||||
import { getHumanDateTime, isInTheFuture } from './Utils';
|
||||
import NoteModal from './components/modals/NoteModal';
|
||||
import PriceModal from './components/modals/PriceModal';
|
||||
import { parsePriceCzk } from './utils/parsePrice';
|
||||
import PayForAllModal from './components/modals/PayForAllModal';
|
||||
import SpinWheelModal, { WheelPick } from './components/modals/SpinWheelModal';
|
||||
import EaterBadge from './components/EaterBadge';
|
||||
import PendingPayments from './components/PendingPayments';
|
||||
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 { getLunchChoiceName } from './enums';
|
||||
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 { CHOICES_WITHOUT_PRICE, getLunchChoiceName } from './enums';
|
||||
import { isArrowNavigationBlocked } from './utils/arrowNavigation';
|
||||
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
||||
// import './FallingLeaves.scss';
|
||||
|
||||
@@ -80,7 +90,9 @@ function App() {
|
||||
const [dayIndex, setDayIndex] = useState<number>();
|
||||
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
|
||||
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
|
||||
const [priceModalOpen, setPriceModalOpen] = useState<boolean>(false);
|
||||
const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null);
|
||||
const [spinWheelOpen, setSpinWheelOpen] = useState<boolean>(false);
|
||||
const [eggImage, setEggImage] = useState<Blob>();
|
||||
const eggRef = useRef<HTMLImageElement>(null);
|
||||
// Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu
|
||||
@@ -236,6 +248,7 @@ function App() {
|
||||
|
||||
// Navigace mezi dny pomocí klávesových šípek
|
||||
const handleKeyDown = useCallback((e: any) => {
|
||||
if (isArrowNavigationBlocked(e)) return;
|
||||
if (e.keyCode === 37 && dayIndex != null && dayIndex > 0) {
|
||||
handleDayChange(dayIndex - 1);
|
||||
} 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) => {
|
||||
if (auth?.login) {
|
||||
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) => {
|
||||
if (auth?.login && note) {
|
||||
await updateNote({ body: { note, dayIndex } });
|
||||
@@ -587,7 +625,7 @@ function App() {
|
||||
} else {
|
||||
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-header" style={{ cursor: canChangeChoice ? 'pointer' : 'default' }} onClick={() => doAddClickFoodChoice(location)}>
|
||||
<div className="restaurant-header-content">
|
||||
@@ -638,6 +676,15 @@ function App() {
|
||||
const noOrders = data?.pizzaDay?.orders?.length === 0;
|
||||
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 || {};
|
||||
|
||||
return (
|
||||
@@ -662,7 +709,7 @@ function App() {
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<Row className='food-tables'>
|
||||
<Row className='food-tables justify-content-center'>
|
||||
{Object.keys(Restaurant).map(key => {
|
||||
const locationKey = key as Restaurant;
|
||||
return food[locationKey] && renderFoodTable(locationKey, food[locationKey]);
|
||||
@@ -677,7 +724,11 @@ function App() {
|
||||
{Object.entries(LunchChoice)
|
||||
.filter(entry => {
|
||||
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>)}
|
||||
</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'>
|
||||
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
|
||||
</span>}
|
||||
<EaterBadge items={userChoices?.length ?? 0} />
|
||||
<strong>{login}</strong>
|
||||
{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>}
|
||||
{login === auth.login && userPayload.price != null && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>{userPayload.price / 100} Kč</small>}
|
||||
</div>
|
||||
<div className="user-actions">
|
||||
{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!);
|
||||
}} className='action-icon' icon={faComment} />
|
||||
</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'>
|
||||
<FontAwesomeIcon onClick={() => {
|
||||
setNoteModalOpen(true);
|
||||
@@ -931,7 +990,15 @@ function App() {
|
||||
leafVariants={LEAF_COLOR_THEMES.AUTUMN}
|
||||
/> */}
|
||||
<Footer />
|
||||
{spinWheelOpen && <SpinWheelModal isOpen onClose={() => setSpinWheelOpen(false)} menus={food} onPick={handleWheelPick} />}
|
||||
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
|
||||
<PriceModal
|
||||
isOpen={priceModalOpen}
|
||||
onClose={() => setPriceModalOpen(false)}
|
||||
price={ownChoice?.choice.price}
|
||||
suggestedPrice={ownSuggestedPrice}
|
||||
onSave={savePrice}
|
||||
/>
|
||||
{payForAllLocationKey && data && (
|
||||
<PayForAllModal
|
||||
isOpen
|
||||
|
||||
+22
-11
@@ -1,5 +1,5 @@
|
||||
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.scss";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
@@ -8,39 +8,50 @@ import StatsPage from "./pages/StatsPage";
|
||||
import OrderGroupsPage from "./pages/OrderGroupsPage";
|
||||
import SuggestionsPage from "./pages/SuggestionsPage";
|
||||
import App from "./App";
|
||||
import HellGirlFlames from "./components/HellGirlFlames";
|
||||
|
||||
export const STATS_URL = '/stats';
|
||||
export const OBJEDNANI_URL = '/objednani';
|
||||
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() {
|
||||
return (
|
||||
<ProvideSettings>
|
||||
<StyleThemeEffects />
|
||||
<Routes>
|
||||
<Route path={STATS_URL} element={<StatsPage />} />
|
||||
<Route path={NAVRHY_URL} element={
|
||||
<ProvideSettings>
|
||||
<SuggestionsPage />
|
||||
</ProvideSettings>
|
||||
} />
|
||||
<Route path={NAVRHY_URL} element={<SuggestionsPage />} />
|
||||
<Route path={OBJEDNANI_URL} element={
|
||||
<ProvideSettings>
|
||||
<SocketContext.Provider value={socket}>
|
||||
<OrderGroupsPage />
|
||||
<ToastContainer />
|
||||
</SocketContext.Provider>
|
||||
</ProvideSettings>
|
||||
} />
|
||||
<Route path="/" element={
|
||||
<ProvideSettings>
|
||||
<SocketContext.Provider value={socket}>
|
||||
<>
|
||||
<FlyingButterflies numButterflies={BUTTERFLY_PRESETS.NORMAL} />
|
||||
<Butterflies />
|
||||
<App />
|
||||
</>
|
||||
<ToastContainer />
|
||||
</SocketContext.Provider>
|
||||
</ProvideSettings>
|
||||
} />
|
||||
</Routes>
|
||||
</ProvideSettings>
|
||||
);
|
||||
}
|
||||
@@ -87,3 +87,13 @@
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.login-card > .hint {
|
||||
color: var(--luncher-text-muted);
|
||||
}
|
||||
|
||||
.login-error {
|
||||
color: var(--luncher-danger);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
+46
-14
@@ -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 { useAuth } from './context/auth';
|
||||
import { login } from '../../types';
|
||||
import { useAppConfig } from './hooks/useGroupLimits';
|
||||
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() {
|
||||
const auth = useAuth();
|
||||
const { loaded, trustedHeaders } = useAppConfig();
|
||||
const loginRef = useRef<HTMLInputElement>(null);
|
||||
const [trustedError, setTrustedError] = useState<string>();
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth && !auth.login) {
|
||||
// Vyzkoušíme přihlášení "naprázdno", pokud projde, přihlásili nás trusted headers
|
||||
if (!auth || auth.login || !loaded || trustedHeaders === false) return;
|
||||
// Přihlášení přes hlavičky z proxy (případně pokus „naprázdno", pokud nevíme, jak server přihlašuje)
|
||||
setTrustedError(undefined);
|
||||
login().then(response => {
|
||||
const token = response.data;
|
||||
if (token) {
|
||||
auth?.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string
|
||||
auth.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string
|
||||
} else if (trustedHeaders) {
|
||||
setTrustedError((response.error as any)?.error ?? 'Přihlášení přes proxy se nezdařilo.');
|
||||
}
|
||||
}).catch(error => {
|
||||
// nezajímá nás
|
||||
}).catch(() => {
|
||||
if (trustedHeaders) setTrustedError('Server je nedostupný. Zkuste to prosím za chvíli.');
|
||||
});
|
||||
}
|
||||
}, [auth]);
|
||||
}, [auth, loaded, trustedHeaders, attempt]);
|
||||
|
||||
const doLogin = useCallback(async () => {
|
||||
const length = loginRef?.current?.value.length && loginRef.current.value.replaceAll(/\s/g, '').length
|
||||
@@ -35,12 +43,38 @@ export default function Login() {
|
||||
}
|
||||
}, [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 (
|
||||
<div className='login-page'>
|
||||
<div className='login-card'>
|
||||
<h1 className='login-logo'>Luncher</h1>
|
||||
<p className='login-subtitle'>Aplikace pro profesionální management obědů</p>
|
||||
{header}
|
||||
{trustedError ? (
|
||||
<div className='login-form'>
|
||||
<p className='login-error'>{trustedError}</p>
|
||||
<Button onClick={() => setAttempt(a => a + 1)}>Zkusit znovu</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className='hint'>Přihlašuji…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='login-page'>
|
||||
<div className='login-card'>
|
||||
{header}
|
||||
<div className='login-form'>
|
||||
<div>
|
||||
<label htmlFor="login-input">Zobrazované jméno</label>
|
||||
@@ -66,5 +100,3 @@ export default function Login() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div>Neplatný stav</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 { useAuth } from "../context/auth";
|
||||
import SettingsModal from "./modals/SettingsModal";
|
||||
import DrawingGame from "./DrawingGame";
|
||||
import "./Header.scss";
|
||||
import { useMinigameSecret } from "../hooks/useMinigameSecret";
|
||||
import { useSettings, ThemePreference } from "../context/settings";
|
||||
import HuePicker from "./HuePicker";
|
||||
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
||||
@@ -39,6 +42,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
||||
|
||||
const effectiveDark = settings?.effectiveDark ?? false;
|
||||
const minigameSecret = useMinigameSecret("/");
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth?.login) return;
|
||||
@@ -77,6 +81,9 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
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 newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
|
||||
settings?.setThemePreference(newTheme);
|
||||
@@ -144,7 +151,9 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
}
|
||||
|
||||
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.Collapse id="basic-navbar-nav">
|
||||
<Nav className="nav">
|
||||
@@ -156,11 +165,11 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
>
|
||||
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
|
||||
</button>
|
||||
<HuePicker
|
||||
{isSimpleStyle && <HuePicker
|
||||
accentHue={settings?.accentHue ?? 142}
|
||||
isDark={effectiveDark}
|
||||
onChange={hue => settings?.setAccentHue(hue)}
|
||||
/>
|
||||
/>}
|
||||
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
||||
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</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>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
<DrawingGame />
|
||||
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
||||
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
||||
<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;
|
||||
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
|
||||
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;
|
||||
if (menu) {
|
||||
if (menu && choice.price == null) {
|
||||
for (const idx of selectedFoods) {
|
||||
const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
|
||||
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>
|
||||
|
||||
{!hasMenu && (
|
||||
{!hasMenu && !diners.some(d => d.baseAmount > 0) && (
|
||||
<Alert variant="info">
|
||||
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
|
||||
</Alert>
|
||||
@@ -219,7 +220,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
||||
<td>
|
||||
<small>
|
||||
{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>}
|
||||
</small>
|
||||
</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 { Modal, Button, Form } from "react-bootstrap"
|
||||
import { useSettings, ThemePreference } from "../../context/settings";
|
||||
import { Modal, Button, Form, Row, Col } from "react-bootstrap"
|
||||
import { useSettings, ThemePreference, StyleTheme, STYLE_THEMES } from "../../context/settings";
|
||||
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
|
||||
import { useAuth } from "../../context/auth";
|
||||
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 hideSoupsRef = useRef<HTMLInputElement>(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 boltDeliveredRef = useRef<HTMLInputElement>(null);
|
||||
@@ -75,6 +78,15 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
||||
}
|
||||
|
||||
// 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(
|
||||
bankAccountRef.current?.value,
|
||||
nameRef.current?.value,
|
||||
@@ -84,12 +96,21 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||
<Modal show={isOpen} onHide={onClose} size="xl">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Nastavení</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{/* Na širších obrazovkách rozdělení do dvou sloupců, na užších pod sebou */}
|
||||
<Row className="gx-5">
|
||||
<Col lg={6}>
|
||||
<h4>Vzhled</h4>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Styl</Form.Label>
|
||||
<Form.Select ref={styleThemeRef} defaultValue={settings?.styleTheme}>
|
||||
{STYLE_THEMES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Barevný motiv</Form.Label>
|
||||
<Form.Select ref={themeRef} defaultValue={settings?.themePreference}>
|
||||
@@ -98,6 +119,18 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
||||
<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 />
|
||||
|
||||
@@ -115,9 +148,57 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
||||
Experimentální funkce - zejména u TechTower bývá problém polévky spolehlivě rozeznat.
|
||||
</Form.Text>
|
||||
</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 />
|
||||
|
||||
<h4>Bankovní účet</h4>
|
||||
<p>
|
||||
Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.
|
||||
</p>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Číslo účtu</Form.Label>
|
||||
<Form.Control
|
||||
ref={bankAccountRef}
|
||||
type="text"
|
||||
placeholder="123456-1234567890/1234"
|
||||
defaultValue={settings?.bankAccount}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Název příjemce</Form.Label>
|
||||
<Form.Control
|
||||
ref={nameRef}
|
||||
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>
|
||||
</Col>
|
||||
<Col lg={6}>
|
||||
<hr className="d-lg-none" />
|
||||
|
||||
<h4>Notifikace</h4>
|
||||
<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.
|
||||
@@ -212,41 +293,8 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
||||
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.Text>
|
||||
</Form.Group>
|
||||
|
||||
<hr />
|
||||
|
||||
<h4>Bankovní účet</h4>
|
||||
<p>
|
||||
Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.
|
||||
</p>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Číslo účtu</Form.Label>
|
||||
<Form.Control
|
||||
ref={bankAccountRef}
|
||||
type="text"
|
||||
placeholder="123456-1234567890/1234"
|
||||
defaultValue={settings?.bankAccount}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Název příjemce</Form.Label>
|
||||
<Form.Control
|
||||
ref={nameRef}
|
||||
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>
|
||||
</Col>
|
||||
</Row>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<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 { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal, Button, Form, ListGroup, Alert, OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faPen, faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { addStore, deleteStore, updateStore, Store } from "../../../../types";
|
||||
import { faArrowsRotate, faPen, faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
@@ -20,6 +23,23 @@ type EditState = {
|
||||
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. */
|
||||
function toUrlInputs(urls: string[]): string[] {
|
||||
return urls.length > 0 ? urls : [''];
|
||||
@@ -33,6 +53,41 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
||||
const [heslo, setHeslo] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
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, ''];
|
||||
|
||||
@@ -147,7 +202,7 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose}>
|
||||
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Správa obchodů</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
@@ -158,6 +213,14 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
||||
</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.Label>Admin heslo</Form.Label>
|
||||
<Form.Control
|
||||
@@ -167,6 +230,9 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
||||
onChange={e => setHeslo(e.target.value)}
|
||||
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>
|
||||
|
||||
<hr />
|
||||
@@ -185,6 +251,7 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
||||
Přidat
|
||||
</Button>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<h6>Aktuální seznam</h6>
|
||||
{stores.length === 0 ? (
|
||||
@@ -244,8 +311,52 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
||||
))}
|
||||
</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 className="d-flex gap-3">
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{adminEnabled && <div className="d-flex gap-3">
|
||||
<FontAwesomeIcon
|
||||
icon={faPen}
|
||||
className="action-icon"
|
||||
@@ -260,7 +371,7 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
||||
onClick={() => handleRemove(s.name)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
</div>}
|
||||
</ListGroup.Item>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -6,9 +6,24 @@ const HIDE_SOUPS_KEY = 'hide_soups';
|
||||
const THEME_KEY = 'theme_preference';
|
||||
const ACCENT_HUE_KEY = 'accent_hue';
|
||||
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';
|
||||
|
||||
/** 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 = {
|
||||
bankAccount?: string,
|
||||
holderName?: string,
|
||||
@@ -16,11 +31,17 @@ export type SettingsContextProps = {
|
||||
themePreference: ThemePreference,
|
||||
accentHue: number,
|
||||
effectiveDark: boolean,
|
||||
styleTheme: StyleTheme,
|
||||
showButterflies: boolean,
|
||||
autoOrderChoice: boolean,
|
||||
setBankAccountNumber: (accountNumber?: string) => void,
|
||||
setBankAccountHolderName: (holderName?: string) => void,
|
||||
setHideSoupsOption: (hideSoups?: boolean) => void,
|
||||
setThemePreference: (theme: ThemePreference) => void,
|
||||
setAccentHue: (hue: number) => void,
|
||||
setStyleTheme: (theme: StyleTheme) => void,
|
||||
setShowButterflies: (show: boolean) => void,
|
||||
setAutoOrderChoice: (enabled: boolean) => void,
|
||||
}
|
||||
|
||||
type ContextProps = {
|
||||
@@ -50,6 +71,37 @@ function getInitialTheme(): ThemePreference {
|
||||
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 {
|
||||
try {
|
||||
const saved = localStorage.getItem(ACCENT_HUE_KEY);
|
||||
@@ -90,6 +142,12 @@ function adjustedL(hue: number, sat: number, targetL: number): number {
|
||||
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 {
|
||||
const sat = 70;
|
||||
const baseL = adjustedL(hue, sat, isDark ? 55 : 38);
|
||||
@@ -110,6 +168,9 @@ function useProvideSettings(): SettingsContextProps {
|
||||
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
|
||||
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
|
||||
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>(() => {
|
||||
try {
|
||||
const pref = localStorage.getItem(THEME_KEY) as ThemePreference | null;
|
||||
@@ -178,11 +239,29 @@ function useProvideSettings(): SettingsContextProps {
|
||||
}
|
||||
}, [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(() => {
|
||||
localStorage.setItem(ACCENT_HUE_KEY, String(accentHue));
|
||||
if (styleTheme === 'simple') {
|
||||
applyAccentColors(accentHue, effectiveDark);
|
||||
}, [accentHue, effectiveDark]);
|
||||
} else {
|
||||
clearAccentColors();
|
||||
}
|
||||
}, [accentHue, effectiveDark, styleTheme]);
|
||||
|
||||
function setBankAccountNumber(bankAccount?: string) {
|
||||
setBankAccount(bankAccount);
|
||||
@@ -204,6 +283,18 @@ function useProvideSettings(): SettingsContextProps {
|
||||
setHue(hue);
|
||||
}
|
||||
|
||||
function setStyleTheme(theme: StyleTheme) {
|
||||
setStyle(theme);
|
||||
}
|
||||
|
||||
function setShowButterflies(show: boolean) {
|
||||
setShowButterfliesState(show);
|
||||
}
|
||||
|
||||
function setAutoOrderChoice(enabled: boolean) {
|
||||
setAutoOrderChoiceState(enabled);
|
||||
}
|
||||
|
||||
return {
|
||||
bankAccount,
|
||||
holderName,
|
||||
@@ -216,5 +307,11 @@ function useProvideSettings(): SettingsContextProps {
|
||||
setHideSoupsOption,
|
||||
setThemePreference,
|
||||
setAccentHue,
|
||||
styleTheme,
|
||||
setStyleTheme,
|
||||
showButterflies,
|
||||
setShowButterflies,
|
||||
autoOrderChoice,
|
||||
setAutoOrderChoice,
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import socketio from "socket.io-client";
|
||||
let socketUrl;
|
||||
let socketPath;
|
||||
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;
|
||||
} else {
|
||||
socketUrl = `${globalThis.location.host}`;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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) {
|
||||
switch (restaurant) {
|
||||
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 { toast } from 'react-toastify';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { setAppConfig } from './utils/appConfig';
|
||||
|
||||
client.setConfig({
|
||||
auth: () => getToken(),
|
||||
@@ -17,15 +18,25 @@ client.setConfig({
|
||||
});
|
||||
|
||||
// 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 }) => {
|
||||
setAppConfig({
|
||||
loaded: true,
|
||||
trustedHeaders: data?.auth?.trustedHeaders,
|
||||
storeAdmin: data?.features?.storeAdmin,
|
||||
lobbyUrl: data?.links?.lobbyUrl ?? undefined,
|
||||
groupLimits: data?.limits?.group,
|
||||
});
|
||||
if (data?.sentry.dsn) {
|
||||
Sentry.init({
|
||||
dsn: data.sentry.dsn,
|
||||
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ě
|
||||
client.interceptors.response.use(async response => {
|
||||
|
||||
@@ -7,9 +7,12 @@ import DatePicker, { registerLocale } from 'react-datepicker';
|
||||
import { cs } from 'date-fns/locale';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import {
|
||||
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr, TrackingProvider,
|
||||
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setTracking, getOrderDates,
|
||||
ClientData, GroupState, LunchChoice, MealSlot, OrderGroup, OrderGroupMember, PendingQr, TrackingProvider,
|
||||
StoreMenuDish, getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setTracking, getOrderDates, getStoreMenu, addChoice,
|
||||
} 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 { getStoreUrls, isHttpUrl, storeUrlLabel } from '../utils/storeUrls';
|
||||
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 PendingPayments from '../components/PendingPayments';
|
||||
import OrderProgress, { PROVIDER_LABEL } from '../components/OrderProgress';
|
||||
import { isArrowNavigationBlocked } from '../utils/arrowNavigation';
|
||||
|
||||
const SLOT = MealSlot.EXTRA;
|
||||
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 [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | 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) => {
|
||||
try {
|
||||
@@ -219,9 +225,8 @@ export default function OrderGroupsPage() {
|
||||
|
||||
// Navigace mezi dny pomocí klávesových šipek (←/→), obdobně jako na hlavní stránce
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// Ignorujeme, pokud uživatel právě píše do formulářového pole
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
|
||||
// Ignorujeme, pokud uživatel právě píše do formulářového pole nebo je otevřený dialog (např. minihra)
|
||||
if (isArrowNavigationBlocked(e)) return;
|
||||
const currentIso = data?.isoDate;
|
||||
if (!currentIso) return;
|
||||
if (e.keyCode === 37) {
|
||||
@@ -256,6 +261,22 @@ export default function OrderGroupsPage() {
|
||||
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 () => {
|
||||
if (!newGroupName || !auth?.login) return;
|
||||
setCreating(true);
|
||||
@@ -266,13 +287,17 @@ export default function OrderGroupsPage() {
|
||||
if (ok) {
|
||||
setNewGroupName('');
|
||||
setNewGroupUrl('');
|
||||
await switchChoiceToOrdering();
|
||||
}
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
|
||||
const handleJoin = (groupId: string) =>
|
||||
refresh(() => addGroupMember({ body: { id: groupId } }));
|
||||
const handleJoin = async (groupId: string) => {
|
||||
if (await refresh(() => addGroupMember({ body: { id: groupId } }))) {
|
||||
await switchChoiceToOrdering();
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleLock = (group: OrderGroup) => {
|
||||
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; });
|
||||
};
|
||||
|
||||
// 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 key = `${groupId}:${login}`;
|
||||
const note = editNotes[key] ?? '';
|
||||
@@ -630,6 +698,7 @@ export default function OrderGroupsPage() {
|
||||
<th style={{ width: 180 }}>Částka (bez slev)</th>
|
||||
<th style={{ width: 220 }}>Příplatek</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: 40 }}></th>
|
||||
</tr>
|
||||
@@ -646,6 +715,7 @@ export default function OrderGroupsPage() {
|
||||
<tr key={memberLogin}>
|
||||
<td>
|
||||
<span className="user-info">
|
||||
<EaterBadge items={countNoteItems(member.note, getGroupMenu(group) ?? [])} />
|
||||
<strong>{memberLogin}</strong>
|
||||
{memberLogin === group.creatorLogin && (
|
||||
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
|
||||
@@ -744,6 +814,19 @@ export default function OrderGroupsPage() {
|
||||
</span>
|
||||
)}
|
||||
</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">
|
||||
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
|
||||
{memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'}
|
||||
@@ -775,7 +858,7 @@ export default function OrderGroupsPage() {
|
||||
return groupTotal > 0 ? (
|
||||
<tfoot>
|
||||
<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></td>
|
||||
</tr>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { getLunchChoiceName } from "../enums";
|
||||
import './StatsPage.scss';
|
||||
import { isArrowNavigationBlocked } from '../utils/arrowNavigation';
|
||||
|
||||
const CHART_WIDTH = 1400;
|
||||
const CHART_HEIGHT = 700;
|
||||
@@ -131,6 +132,7 @@ export default function StatsPage() {
|
||||
}, [dateRange]);
|
||||
|
||||
const handleKeyDown = useCallback((e: any) => {
|
||||
if (isArrowNavigationBlocked(e)) return;
|
||||
if (e.keyCode === 37) {
|
||||
handlePreviousWeek();
|
||||
} 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;
|
||||
}
|
||||
+160
-156
@@ -518,9 +518,9 @@
|
||||
"@fortawesome/fontawesome-common-types" "7.3.1"
|
||||
|
||||
"@fortawesome/react-fontawesome@^3.1.0":
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-3.4.0.tgz#17b989390349ded9214ffb35ad279da7c9a728ba"
|
||||
integrity sha512-EgY5CXzzm6WGnUB40j9qNajYmoJHC8TUV/rvpcmHUoPZmQyRlh8WuRaxaerjMq61NZAz0vS+MOoJaPbZWRDibw==
|
||||
version "3.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-3.5.0.tgz#ce8152e556809326e1253682088bfdd116a453bd"
|
||||
integrity sha512-63mlRr6fiBbJ0wjr1Cf6dsDGtP2lNvk9lnatKgxs/fIkhslsZT291hIUzJkuUkI9yr69ZvWnWfgb2qXm4QyVaA==
|
||||
|
||||
"@jest/diff-sequences@30.0.1":
|
||||
version "30.0.1"
|
||||
@@ -609,94 +609,88 @@
|
||||
"@jridgewell/resolve-uri" "^3.1.0"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
||||
|
||||
"@parcel/watcher-android-arm64@2.5.6":
|
||||
version "2.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz#5f32e0dba356f4ac9a11068d2a5c134ca3ba6564"
|
||||
integrity sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==
|
||||
"@parcel/watcher-android-arm64@2.6.0":
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz#99aaa3223d43807c9340af439cad7e9b6d26ada6"
|
||||
integrity sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==
|
||||
|
||||
"@parcel/watcher-darwin-arm64@2.5.6":
|
||||
version "2.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz#88d3e720b59b1eceffce98dac46d7c40e8be5e8e"
|
||||
integrity sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==
|
||||
"@parcel/watcher-darwin-arm64@2.6.0":
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz#024496e586b4744f09ce532bbe89fe38ef02a64e"
|
||||
integrity sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==
|
||||
|
||||
"@parcel/watcher-darwin-x64@2.5.6":
|
||||
version "2.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz#bf05d76a78bc15974f15ec3671848698b0838063"
|
||||
integrity sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==
|
||||
"@parcel/watcher-darwin-x64@2.6.0":
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz#a4621df1359a93d39a332d9bab5ff09016a0608f"
|
||||
integrity sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==
|
||||
|
||||
"@parcel/watcher-freebsd-x64@2.5.6":
|
||||
version "2.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz#8bc26e9848e7303ac82922a5ae1b1ef1bdb48a53"
|
||||
integrity sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==
|
||||
"@parcel/watcher-freebsd-x64@2.6.0":
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz#7f565ed1a5b3a5e604e6a4799121518265d62a3d"
|
||||
integrity sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==
|
||||
|
||||
"@parcel/watcher-linux-arm-glibc@2.5.6":
|
||||
version "2.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz#1328fee1deb0c2d7865079ef53a2ba4cc2f8b40a"
|
||||
integrity sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==
|
||||
"@parcel/watcher-linux-arm-glibc@2.6.0":
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz#ad7d3825e67b81999165da42593022045abc0889"
|
||||
integrity sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==
|
||||
|
||||
"@parcel/watcher-linux-arm-musl@2.5.6":
|
||||
version "2.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz#bad0f45cb3e2157746db8b9d22db6a125711f152"
|
||||
integrity sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==
|
||||
"@parcel/watcher-linux-arm-musl@2.6.0":
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz#fe7d1cccb2c483215c090e938cf5cf404d2f9a8c"
|
||||
integrity sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==
|
||||
|
||||
"@parcel/watcher-linux-arm64-glibc@2.5.6":
|
||||
version "2.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz#b75913fbd501d9523c5f35d420957bf7d0204809"
|
||||
integrity sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==
|
||||
"@parcel/watcher-linux-arm64-glibc@2.6.0":
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz#7e239dcb4646c4c79f006a7131a48238249530da"
|
||||
integrity sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==
|
||||
|
||||
"@parcel/watcher-linux-arm64-musl@2.5.6":
|
||||
version "2.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz#da5621a6a576070c8c0de60dea8b46dc9c3827d4"
|
||||
integrity sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==
|
||||
"@parcel/watcher-linux-arm64-musl@2.6.0":
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz#c58b8d9c6d8d81594be00dd83aab741c1aaf7e0e"
|
||||
integrity sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==
|
||||
|
||||
"@parcel/watcher-linux-x64-glibc@2.5.6":
|
||||
version "2.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz#ce437accdc4b30f93a090b4a221fd95cd9b89639"
|
||||
integrity sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==
|
||||
"@parcel/watcher-linux-x64-glibc@2.6.0":
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz#5184fa9a770478d86e56875f4ee163a0abdc8791"
|
||||
integrity sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==
|
||||
|
||||
"@parcel/watcher-linux-x64-musl@2.5.6":
|
||||
version "2.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz#02400c54b4a67efcc7e2327b249711920ac969e2"
|
||||
integrity sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==
|
||||
"@parcel/watcher-linux-x64-musl@2.6.0":
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz#2d1c55aa7246cbc7670e2612058a8a542c9cf246"
|
||||
integrity sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==
|
||||
|
||||
"@parcel/watcher-win32-arm64@2.5.6":
|
||||
version "2.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz#caae3d3c7583ca0a7171e6bd142c34d20ea1691e"
|
||||
integrity sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==
|
||||
"@parcel/watcher-win32-arm64@2.6.0":
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz#15e09432040fee9e2213aa9c10ed589012526def"
|
||||
integrity sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==
|
||||
|
||||
"@parcel/watcher-win32-ia32@2.5.6":
|
||||
version "2.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz#9ac922550896dfe47bfc5ae3be4f1bcaf8155d6d"
|
||||
integrity sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==
|
||||
|
||||
"@parcel/watcher-win32-x64@2.5.6":
|
||||
version "2.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz#73fdafba2e21c448f0e456bbe13178d8fe11739d"
|
||||
integrity sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==
|
||||
"@parcel/watcher-win32-x64@2.6.0":
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz#9bee199a2a4accd557b451ac2c1c793f305ae012"
|
||||
integrity sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==
|
||||
|
||||
"@parcel/watcher@^2.4.1":
|
||||
version "2.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.5.6.tgz#3f932828c894f06d0ad9cfefade1756ecc6ef1f1"
|
||||
integrity sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.6.0.tgz#99661f6220070b76a766aba6b7e313a087a1be4f"
|
||||
integrity sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==
|
||||
dependencies:
|
||||
detect-libc "^2.0.3"
|
||||
is-glob "^4.0.3"
|
||||
node-addon-api "^7.0.0"
|
||||
picomatch "^4.0.3"
|
||||
picomatch "^4.0.4"
|
||||
optionalDependencies:
|
||||
"@parcel/watcher-android-arm64" "2.5.6"
|
||||
"@parcel/watcher-darwin-arm64" "2.5.6"
|
||||
"@parcel/watcher-darwin-x64" "2.5.6"
|
||||
"@parcel/watcher-freebsd-x64" "2.5.6"
|
||||
"@parcel/watcher-linux-arm-glibc" "2.5.6"
|
||||
"@parcel/watcher-linux-arm-musl" "2.5.6"
|
||||
"@parcel/watcher-linux-arm64-glibc" "2.5.6"
|
||||
"@parcel/watcher-linux-arm64-musl" "2.5.6"
|
||||
"@parcel/watcher-linux-x64-glibc" "2.5.6"
|
||||
"@parcel/watcher-linux-x64-musl" "2.5.6"
|
||||
"@parcel/watcher-win32-arm64" "2.5.6"
|
||||
"@parcel/watcher-win32-ia32" "2.5.6"
|
||||
"@parcel/watcher-win32-x64" "2.5.6"
|
||||
"@parcel/watcher-android-arm64" "2.6.0"
|
||||
"@parcel/watcher-darwin-arm64" "2.6.0"
|
||||
"@parcel/watcher-darwin-x64" "2.6.0"
|
||||
"@parcel/watcher-freebsd-x64" "2.6.0"
|
||||
"@parcel/watcher-linux-arm-glibc" "2.6.0"
|
||||
"@parcel/watcher-linux-arm-musl" "2.6.0"
|
||||
"@parcel/watcher-linux-arm64-glibc" "2.6.0"
|
||||
"@parcel/watcher-linux-arm64-musl" "2.6.0"
|
||||
"@parcel/watcher-linux-x64-glibc" "2.6.0"
|
||||
"@parcel/watcher-linux-x64-musl" "2.6.0"
|
||||
"@parcel/watcher-win32-arm64" "2.6.0"
|
||||
"@parcel/watcher-win32-x64" "2.6.0"
|
||||
|
||||
"@popperjs/core@^2.11.8":
|
||||
version "2.11.8"
|
||||
@@ -881,69 +875,69 @@
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz#3671ce3f9b928d5c01f879792d5c0b60ae14d4ad"
|
||||
integrity sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==
|
||||
|
||||
"@sentry/browser-utils@10.66.0":
|
||||
version "10.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/browser-utils/-/browser-utils-10.66.0.tgz#6f617bd14c1a4f46b27b598ddcbfec7176ca4cb6"
|
||||
integrity sha512-sHuALvJMMEilUz84F1ZOQuDoZ3MhjwUHWHkXcElcVMrDIlnTO7Ra0E6Kh0e8JyJaLBMk4Sy9srKSqH9zimQVTQ==
|
||||
"@sentry/browser-utils@10.73.0":
|
||||
version "10.73.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/browser-utils/-/browser-utils-10.73.0.tgz#7349f5414f1663c005cca69db48cef1d63b3b1e2"
|
||||
integrity sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==
|
||||
dependencies:
|
||||
"@sentry/conventions" "^0.16.0"
|
||||
"@sentry/core" "10.66.0"
|
||||
"@sentry/core" "10.73.0"
|
||||
|
||||
"@sentry/browser@10.66.0":
|
||||
version "10.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.66.0.tgz#62663d6a552e11ade2e2e86754cf1f95dadc5790"
|
||||
integrity sha512-MaPoBqKvI7O3UhexSJ/KO/o6T4Tr3A+vQWLZYTos0mxd59jVKMKbbJ6LxM/0uWQ5JAO2XYC/kCMRvk6VqQ5QZw==
|
||||
"@sentry/browser@10.73.0":
|
||||
version "10.73.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.73.0.tgz#22c3636dd170479a58c9c81507bab72f88e94233"
|
||||
integrity sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==
|
||||
dependencies:
|
||||
"@sentry/browser-utils" "10.66.0"
|
||||
"@sentry/browser-utils" "10.73.0"
|
||||
"@sentry/conventions" "^0.16.0"
|
||||
"@sentry/core" "10.66.0"
|
||||
"@sentry/feedback" "10.66.0"
|
||||
"@sentry/replay" "10.66.0"
|
||||
"@sentry/replay-canvas" "10.66.0"
|
||||
"@sentry/core" "10.73.0"
|
||||
"@sentry/feedback" "10.73.0"
|
||||
"@sentry/replay" "10.73.0"
|
||||
"@sentry/replay-canvas" "10.73.0"
|
||||
|
||||
"@sentry/conventions@^0.16.0":
|
||||
version "0.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/conventions/-/conventions-0.16.0.tgz#3b58d15714cf44dca1518496c00749eec5525009"
|
||||
integrity sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==
|
||||
|
||||
"@sentry/core@10.66.0":
|
||||
version "10.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.66.0.tgz#5473d3129b3ce26454ffb70f2e8a9af904ad0c30"
|
||||
integrity sha512-9UbgSvds7bMJsP561eWmeyMLcfOmnwxtnx2QuW3yLobzP2Ob7CyJCOzP4tGzlTAGDrzShkFEZhiyuBUKiEK2oQ==
|
||||
"@sentry/core@10.73.0":
|
||||
version "10.73.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.73.0.tgz#464593d1a2cec6655da681911a2d60d26033e4ab"
|
||||
integrity sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==
|
||||
dependencies:
|
||||
"@sentry/conventions" "^0.16.0"
|
||||
|
||||
"@sentry/feedback@10.66.0":
|
||||
version "10.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/feedback/-/feedback-10.66.0.tgz#d9a388e470f4657f97bfb03c8c71421a9625c2b5"
|
||||
integrity sha512-fr69K76Gz7RRyfMerChdNjWIeQdFh+k21GJcmPCqk43dscUChOsLc3cYLS5cK7iZ/XiUmH9lVzf7EYL2kf2qsA==
|
||||
"@sentry/feedback@10.73.0":
|
||||
version "10.73.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/feedback/-/feedback-10.73.0.tgz#e086f789cf7f1f671d6a1ef08e80b2ef6db655a4"
|
||||
integrity sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==
|
||||
dependencies:
|
||||
"@sentry/core" "10.66.0"
|
||||
"@sentry/core" "10.73.0"
|
||||
|
||||
"@sentry/react@^10.65.0":
|
||||
version "10.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-10.66.0.tgz#167c6e2496b127c1e3aac6526a4b3f519931f8b6"
|
||||
integrity sha512-aodV9C2NnaZBqJvaBceIIaVyMVEisO38EWtH5xL7IMbD6QSmBYsuNU7yYzRcdOj/+99FxJYRPaqwzGnIWnNTHw==
|
||||
version "10.73.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-10.73.0.tgz#03dbb3fb70ea15dd0c7161d307afca37ab24c7da"
|
||||
integrity sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==
|
||||
dependencies:
|
||||
"@sentry/browser" "10.66.0"
|
||||
"@sentry/browser" "10.73.0"
|
||||
"@sentry/conventions" "^0.16.0"
|
||||
"@sentry/core" "10.66.0"
|
||||
"@sentry/core" "10.73.0"
|
||||
|
||||
"@sentry/replay-canvas@10.66.0":
|
||||
version "10.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/replay-canvas/-/replay-canvas-10.66.0.tgz#f04f1ae17f195d92a32a25491d2cb86c25ae25a0"
|
||||
integrity sha512-z7fPWEIfAJMJySKXFIqXAzFpkHjp13WGMAmB9+hlHhi9+gjAO8owD9R4XTd86lBswveGwMHKvIx5lFKNVqDcow==
|
||||
"@sentry/replay-canvas@10.73.0":
|
||||
version "10.73.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/replay-canvas/-/replay-canvas-10.73.0.tgz#32fe5a7120d05c97fd9828ac15f09d37e1848f76"
|
||||
integrity sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==
|
||||
dependencies:
|
||||
"@sentry/core" "10.66.0"
|
||||
"@sentry/replay" "10.66.0"
|
||||
"@sentry/core" "10.73.0"
|
||||
"@sentry/replay" "10.73.0"
|
||||
|
||||
"@sentry/replay@10.66.0":
|
||||
version "10.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-10.66.0.tgz#9ef6eb7ef47470f1f738f3843099ff9d1095db91"
|
||||
integrity sha512-Djb4FxQa9bF8z3PoCO00Nw1u+6hma2YuI8JkMoE+F14KWRfCZKQ28sScsvk+OokbXgjtuIsxDEZj/qFGY0w01w==
|
||||
"@sentry/replay@10.73.0":
|
||||
version "10.73.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-10.73.0.tgz#25d53335e3dee9324c0b17511506ea4be17ac6dd"
|
||||
integrity sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==
|
||||
dependencies:
|
||||
"@sentry/browser-utils" "10.66.0"
|
||||
"@sentry/core" "10.66.0"
|
||||
"@sentry/browser-utils" "10.73.0"
|
||||
"@sentry/core" "10.73.0"
|
||||
|
||||
"@sinclair/typebox@^0.34.0":
|
||||
version "0.34.41"
|
||||
@@ -1040,9 +1034,9 @@
|
||||
"@types/d3-time" "*"
|
||||
|
||||
"@types/d3-shape@^3.1.0":
|
||||
version "3.1.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3"
|
||||
integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.2.0.tgz#66ff342011dc243c6c20e6b899523d148aca412d"
|
||||
integrity sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==
|
||||
dependencies:
|
||||
"@types/d3-path" "*"
|
||||
|
||||
@@ -1108,9 +1102,9 @@
|
||||
integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==
|
||||
|
||||
"@types/react-dom@^19.2.2":
|
||||
version "19.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c"
|
||||
integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==
|
||||
version "19.2.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.7.tgz#eba78fc6201e564986ec40d56c86c8434593cace"
|
||||
integrity sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==
|
||||
|
||||
"@types/react-transition-group@^4.4.6":
|
||||
version "4.4.12"
|
||||
@@ -1125,9 +1119,9 @@
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@types/react@^19.2.2":
|
||||
version "19.2.17"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.17.tgz#dccac365baa0f1734ec270ff4b51c89465e8dc7f"
|
||||
integrity sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==
|
||||
version "19.2.18"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.18.tgz#eb0b6a1fb635d1a9692d5f84a3495bd8ad153707"
|
||||
integrity sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==
|
||||
dependencies:
|
||||
csstype "^3.2.2"
|
||||
|
||||
@@ -1407,9 +1401,9 @@ engine.io-parser@~5.2.1:
|
||||
integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==
|
||||
|
||||
es-toolkit@^1.39.3:
|
||||
version "1.49.0"
|
||||
resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.49.0.tgz#93c5b031865792fc03cbf5bd20c132a4f976a52a"
|
||||
integrity sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==
|
||||
version "1.52.0"
|
||||
resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.52.0.tgz#71eaf1a8b18834ef77637eccbb885ba4c03cd6dd"
|
||||
integrity sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==
|
||||
|
||||
"esbuild@^0.27.0 || ^0.28.0":
|
||||
version "0.28.1"
|
||||
@@ -1518,9 +1512,9 @@ has-flag@^4.0.0:
|
||||
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
|
||||
|
||||
immer@^11.0.0, immer@^11.1.8:
|
||||
version "11.1.11"
|
||||
resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.11.tgz#bbf825a333ae1b16fd450d8da5f61d54de6a553d"
|
||||
integrity sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==
|
||||
version "11.1.18"
|
||||
resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.18.tgz#87d9bced1e25157dc23bced66811de89460ec8f3"
|
||||
integrity sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==
|
||||
|
||||
immutable@^5.1.5:
|
||||
version "5.1.9"
|
||||
@@ -1694,7 +1688,12 @@ picomatch@^4.0.2:
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
|
||||
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
|
||||
|
||||
picomatch@^4.0.3, picomatch@^4.0.4:
|
||||
picomatch@^4.0.3:
|
||||
version "4.0.7"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f"
|
||||
integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==
|
||||
|
||||
picomatch@^4.0.4:
|
||||
version "4.0.5"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab"
|
||||
integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==
|
||||
@@ -1709,9 +1708,9 @@ postcss@^8.5.6:
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
prettier@^3.6.2:
|
||||
version "3.9.5"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.5.tgz#4fec97736e33b9d0b620b48914fe93b530e835ad"
|
||||
integrity sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==
|
||||
version "3.9.6"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.6.tgz#b3ea5146515d40fc53f18aa63f74dfab1e10dbf6"
|
||||
integrity sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==
|
||||
|
||||
pretty-format@30.2.0, pretty-format@^30.0.0:
|
||||
version "30.2.0"
|
||||
@@ -1768,9 +1767,9 @@ react-datepicker@^9.1.0:
|
||||
date-fns "^4.1.0"
|
||||
|
||||
react-dom@^19.2.0:
|
||||
version "19.2.7"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.7.tgz#0450dc9ae9ddbff76ef196401cd8b8c7fb466ccc"
|
||||
integrity sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==
|
||||
version "19.2.8"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.8.tgz#3b46b9eeda877cdff2cf13d2770fff4ae36c2ec2"
|
||||
integrity sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==
|
||||
dependencies:
|
||||
scheduler "^0.27.0"
|
||||
|
||||
@@ -1825,16 +1824,16 @@ react-refresh@^0.18.0:
|
||||
integrity sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==
|
||||
|
||||
react-router-dom@^7.9.5:
|
||||
version "7.18.1"
|
||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.18.1.tgz#0d1b138e291393059ad481c3e10e366385a978a4"
|
||||
integrity sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==
|
||||
version "7.18.3"
|
||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.18.3.tgz#0688c91b4b376c8b0dcaf0a0064975d52a32e0f7"
|
||||
integrity sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==
|
||||
dependencies:
|
||||
react-router "7.18.1"
|
||||
react-router "7.18.3"
|
||||
|
||||
react-router@7.18.1, react-router@^7.9.5:
|
||||
version "7.18.1"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.18.1.tgz#61259d1594b95c1ace299ee4c57453570f0c22f1"
|
||||
integrity sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==
|
||||
react-router@7.18.3, react-router@^7.9.5:
|
||||
version "7.18.3"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.18.3.tgz#2a3257aa7c5edd5a71f878063e4c7f3fcfc4b76a"
|
||||
integrity sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==
|
||||
dependencies:
|
||||
cookie "^1.0.1"
|
||||
set-cookie-parser "^2.6.0"
|
||||
@@ -1874,19 +1873,19 @@ react-transition-group@^4.4.5:
|
||||
prop-types "^15.6.2"
|
||||
|
||||
react@^19.2.0:
|
||||
version "19.2.7"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.2.7.tgz#1f47a1bfc06f8ec885752c6f4af14369a9f8260b"
|
||||
integrity sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==
|
||||
version "19.2.8"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.2.8.tgz#a80663dbb58d69c6fe3fd291d3cb324e8a7dff2d"
|
||||
integrity sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==
|
||||
|
||||
readdirp@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-5.0.0.tgz#fbf1f71a727891d685bb1786f9ba74084f6e2f91"
|
||||
integrity sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-5.1.1.tgz#520bca06f9d1ae1b96cc0800dbe84b983d19422c"
|
||||
integrity sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==
|
||||
|
||||
recharts@^3.4.1:
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/recharts/-/recharts-3.9.2.tgz#ccdadd197335102a91bd64d51fafc6c9a5b69ebb"
|
||||
integrity sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==
|
||||
version "3.10.1"
|
||||
resolved "https://registry.yarnpkg.com/recharts/-/recharts-3.10.1.tgz#e6f52c2604a38f728b6d47f95881595fa6604c6c"
|
||||
integrity sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==
|
||||
dependencies:
|
||||
"@reduxjs/toolkit" "^1.9.0 || 2.x.x"
|
||||
clsx "^2.1.1"
|
||||
@@ -1915,11 +1914,16 @@ regenerator-runtime@^0.14.0:
|
||||
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f"
|
||||
integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==
|
||||
|
||||
reselect@5.2.0, reselect@^5.1.0:
|
||||
reselect@5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.2.0.tgz#f380ef7664332d26ea06c1cba04bdbbdcaa955f1"
|
||||
integrity sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==
|
||||
|
||||
reselect@^5.1.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.3.0.tgz#0a3e3ed4436bdf2ab7c5e0f392dab2c062595d61"
|
||||
integrity sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==
|
||||
|
||||
rollup@^4.43.0:
|
||||
version "4.62.2"
|
||||
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.62.2.tgz#d90fc4cb811f071303c890b779595634f35f9541"
|
||||
@@ -1955,9 +1959,9 @@ rollup@^4.43.0:
|
||||
fsevents "~2.3.2"
|
||||
|
||||
sass@^1.93.3:
|
||||
version "1.101.0"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.101.0.tgz#c2db5bbf2f956be7277f6223b899d0d4be3c899b"
|
||||
integrity sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==
|
||||
version "1.104.0"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.104.0.tgz#40a6dfc42f8cc469f7f2c10b40c6a0b8577895ac"
|
||||
integrity sha512-btHMApW2bgolvClhRW8AlQJzgI9lUB3pPSofBQQT+E46GWvf9o0TVQ13SYv5riWZVFyPN+JNz3TKW9XhBlc10w==
|
||||
dependencies:
|
||||
chokidar "^5.0.0"
|
||||
immutable "^5.1.5"
|
||||
|
||||
+15
-20
@@ -1,36 +1,31 @@
|
||||
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('uživatel se přihlásí formulářem a uvidí obsah aplikace', async ({ page }) => {
|
||||
// Server běží s HTTP_REMOTE_USER_ENABLED=true, takže POST /api/login vždy vyžaduje
|
||||
// hlavičku remote-user. Zachytíme požadavky z formuláře (mají tělo s polem login)
|
||||
// a přidáme hlavičku; požadavek auto-loginu (bez těla) projde bez hlavičky a selže,
|
||||
// čímž formulář zůstane viditelný.
|
||||
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 (přihlašuje proxy), takže formulář se jménem
|
||||
// nemá smysl. Bez hlavičky remote-user se zobrazí hláška s tlačítkem „Zkusit znovu".
|
||||
let sendHeader = false;
|
||||
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({
|
||||
headers: login
|
||||
? { ...route.request().headers(), 'remote-user': login }
|
||||
headers: sendHeader
|
||||
? { ...route.request().headers(), 'remote-user': 'testuser' }
|
||||
: route.request().headers(),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Formulář musí být viditelný – auto-login selhal (nepřišla hlavička)
|
||||
const loginInput = page.locator('#login-input');
|
||||
await expect(loginInput).toBeVisible({ timeout: 10_000 });
|
||||
const retry = page.getByRole('button', { name: 'Zkusit znovu' });
|
||||
await expect(retry).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
|
||||
await loginInput.fill('testuser');
|
||||
await loginInput.press('Enter');
|
||||
|
||||
// Po přihlášení musí zmizet login formulář
|
||||
await expect(loginInput).not.toBeVisible({ timeout: 10_000 });
|
||||
// Proxy teď identitu pošle — opakování přihlásí
|
||||
sendHeader = true;
|
||||
await retry.click();
|
||||
await expect(retry).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// JWT musí být uloženo v localStorage jako 3-dílný 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 });
|
||||
});
|
||||
|
||||
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
|
||||
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 }) => {
|
||||
|
||||
@@ -10,3 +10,4 @@ data:
|
||||
REDIS_PORT: "6379"
|
||||
PORT: "3001"
|
||||
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.
|
||||
# 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
|
||||
|
||||
# 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).
|
||||
# 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.
|
||||
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
|
||||
# ADMIN_PASSWORD=
|
||||
@@ -64,3 +71,19 @@
|
||||
# SENTRY_CLIENT_DSN=https://...@sentry.example.com/2
|
||||
# Interní logy Sentry SDK do konzole — pro ladění, když eventy nedorazí do Sentry.
|
||||
# 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 (discountType !== undefined) group.discountType = (discountType as any) || 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);
|
||||
}
|
||||
|
||||
|
||||
+48
-9
@@ -29,6 +29,9 @@ import changelogRoutes from "./routes/changelogRoutes";
|
||||
import groupRoutes from "./routes/groupRoutes";
|
||||
import storeRoutes from "./routes/storeRoutes";
|
||||
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';
|
||||
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_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
|
||||
let trustedProxies = parseTrustedIps('');
|
||||
if (HTTP_REMOTE_USER_ENABLED) {
|
||||
if (!process.env.HTTP_REMOTE_TRUSTED_IPS) {
|
||||
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
|
||||
}
|
||||
const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim());
|
||||
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.');
|
||||
}
|
||||
|
||||
@@ -135,6 +141,18 @@ app.get("/api/config", (_req, res) => {
|
||||
dsn: process.env.SENTRY_CLIENT_DSN ?? process.env.SENTRY_DSN ?? null,
|
||||
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"]
|
||||
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) => {
|
||||
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) {
|
||||
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
|
||||
res.status(200).json(generateToken(remoteUser, true));
|
||||
} 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 {
|
||||
if (!req.body?.login || req.body.login.trim().length === 0) {
|
||||
@@ -200,22 +219,42 @@ app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
|
||||
} 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
app.use("/api/", (req, res, next) => {
|
||||
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') {
|
||||
delete req.headers["cookie"]
|
||||
console.log(req.headers)
|
||||
}
|
||||
if (remoteUser && remoteUser.length > 0) {
|
||||
const remoteName = Buffer.from(remoteUser, 'latin1').toString();
|
||||
if (ENVIRONMENT !== "production") {
|
||||
if (remoteName && ENVIRONMENT !== "production") {
|
||||
console.log("Tvuj username: %s.", remoteName);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!req.headers.authorization) {
|
||||
return res.status(401).json({ error: 'Nebyl předán autentizační token' });
|
||||
}
|
||||
|
||||
@@ -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šč',
|
||||
'slepičí s ',
|
||||
'zeleninová s ',
|
||||
'zeleninový krém',
|
||||
'hovězí s ',
|
||||
'kachní kaldoun',
|
||||
'dršťková',
|
||||
'žampionový krém',
|
||||
'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'];
|
||||
|
||||
@@ -328,6 +339,13 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
|
||||
if (!font) {
|
||||
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[][] = [];
|
||||
|
||||
@@ -365,7 +383,11 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
|
||||
if (fifthTry) {
|
||||
let parsing = false;
|
||||
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) => {
|
||||
const pHtml = $(p).html() ?? '';
|
||||
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);
|
||||
for (const text of lines) {
|
||||
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 (result[DAYS_IN_WEEK.indexOf(lower)]) {
|
||||
// Opakující se den = začátek dalšího týdne
|
||||
return false;
|
||||
}
|
||||
currentDayIndex = DAYS_IN_WEEK.indexOf(lower);
|
||||
parsing = true;
|
||||
result[currentDayIndex] ??= [];
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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 getStorage from "../storage";
|
||||
import { getWebsocket } from "../websocket";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
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 { createGroup } from "../groups";
|
||||
import {
|
||||
@@ -36,7 +36,7 @@ const LUNCH_CHOICES = [
|
||||
'OBJEDNAVAM',
|
||||
'NEOBEDVAM',
|
||||
'ROZHODUJI',
|
||||
];
|
||||
].filter(choice => !isRestaurantDisabled(choice as Restaurant));
|
||||
|
||||
// Náhodné názvy jídel do poznámek mock objednávek
|
||||
const MOCK_ORDER_FOODS = [
|
||||
@@ -72,12 +72,7 @@ const MAX_ORDER_MEMBER_COUNT = 50;
|
||||
const MAX_ORDER_AMOUNT = 100000;
|
||||
|
||||
// Restaurace s menu
|
||||
const RESTAURANTS_WITH_MENU = [
|
||||
'SLADOVNICKA',
|
||||
'TECHTOWER',
|
||||
'ZASTAVKAUMICHALA',
|
||||
'SENKSERIKOVA',
|
||||
];
|
||||
const RESTAURANTS_WITH_MENU = getActiveRestaurants();
|
||||
|
||||
/**
|
||||
* 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
|
||||
if (RESTAURANTS_WITH_MENU.includes(choice) && menus[choice]?.length) {
|
||||
if (RESTAURANTS_WITH_MENU.includes(choice as Restaurant) && menus[choice]?.length) {
|
||||
const foods = menus[choice];
|
||||
// Vybereme náhodné jídlo (ne polévku)
|
||||
const mainFoods = foods.filter((f: any) => !f.isSoup);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import express, { Request, Response } from "express";
|
||||
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 { getWebsocket } from "../websocket";
|
||||
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
|
||||
@@ -54,7 +54,7 @@ function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean {
|
||||
* @param req request
|
||||
* @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) {
|
||||
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) }
|
||||
});
|
||||
|
||||
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) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
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..." });
|
||||
}
|
||||
try {
|
||||
// Pro všechny restaurace refreshni menu na aktuální týden
|
||||
const restaurants = ["SLADOVNICKA", "TECHTOWER", "ZASTAVKAUMICHALA", "SENKSERIKOVA"] as const;
|
||||
// Pro všechny aktivní restaurace refreshni menu na aktuální týden
|
||||
const restaurants = getActiveRestaurants();
|
||||
const firstDay = getFirstWorkDayOfWeek(getToday());
|
||||
const results: Record<string, any> = {};
|
||||
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 { GroupState } from "../../../types/gen/types.gen";
|
||||
import { checkOrderTracking } from "../orderTracking";
|
||||
import { getGroupLimits } from "../limits";
|
||||
|
||||
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) {
|
||||
getWebsocket().emit("message", data);
|
||||
}
|
||||
@@ -25,7 +36,11 @@ router.post("/create", async (req: Request, res, next) => {
|
||||
if (!name || typeof name !== 'string') {
|
||||
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' });
|
||||
}
|
||||
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' });
|
||||
const patch: Record<string, any> = {};
|
||||
if (amount !== undefined) {
|
||||
if (!Number.isInteger(amount) || amount < 0) {
|
||||
return res.status(400).json({ error: 'Neplatná částka' });
|
||||
}
|
||||
const error = invalidAmount(amount, 'Částka');
|
||||
if (error) return res.status(400).json({ error });
|
||||
patch.amount = amount;
|
||||
}
|
||||
if (note !== undefined) {
|
||||
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;
|
||||
}
|
||||
if (surchargeText !== undefined) {
|
||||
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;
|
||||
}
|
||||
if (surchargeAmount !== undefined) {
|
||||
if (!Number.isInteger(surchargeAmount) || surchargeAmount < 0) {
|
||||
return res.status(400).json({ error: 'Neplatná výše příplatku' });
|
||||
}
|
||||
const error = invalidAmount(surchargeAmount, 'Příplatek');
|
||||
if (error) return res.status(400).json({ error });
|
||||
patch.surchargeAmount = surchargeAmount;
|
||||
}
|
||||
try {
|
||||
@@ -124,21 +145,26 @@ router.post("/updateFees", async (req: Request, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {};
|
||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||
if (fees !== undefined && (!Number.isInteger(fees) || fees < 0)) {
|
||||
return res.status(400).json({ error: 'Neplatná výše poplatků' });
|
||||
}
|
||||
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' });
|
||||
for (const [value, label] of [[fees, 'Poplatky'], [shipping, 'Doprava'], [tip, 'Spropitné']] as const) {
|
||||
const error = value !== undefined ? invalidAmount(value, label) : undefined;
|
||||
if (error) return res.status(400).json({ error });
|
||||
}
|
||||
if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) {
|
||||
return res.status(400).json({ error: 'Neplatný typ slevy' });
|
||||
}
|
||||
if (discountValue !== undefined && (!Number.isInteger(discountValue) || discountValue < 0)) {
|
||||
if (discountValue !== undefined) {
|
||||
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 {
|
||||
const data = await updateGroupFees(login, id, fees, shipping, tip, discountType, discountValue);
|
||||
broadcastExtra(data);
|
||||
@@ -168,7 +194,7 @@ router.post("/setTracking", async (req: Request, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, shareUrl } = req.body ?? {};
|
||||
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' });
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import express from "express";
|
||||
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();
|
||||
|
||||
@@ -10,6 +12,69 @@ router.get("/", async (_req, res, next) => {
|
||||
} 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) => {
|
||||
const { name, heslo, urls } = req.body ?? {};
|
||||
if (!name || typeof name !== 'string') {
|
||||
@@ -48,6 +113,12 @@ router.post("/update", async (req, res, next) => {
|
||||
}
|
||||
try {
|
||||
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);
|
||||
} catch (e: any) {
|
||||
if (e.message === 'UNAUTHORIZED') {
|
||||
@@ -67,6 +138,7 @@ router.post("/delete", async (req, res, next) => {
|
||||
}
|
||||
try {
|
||||
const stores = await removeStore(name, heslo);
|
||||
await renameStoreMenus(name.trim());
|
||||
res.status(200).json(stores);
|
||||
} catch (e: any) {
|
||||
if (e.message === 'UNAUTHORIZED') {
|
||||
|
||||
+59
-7
@@ -9,6 +9,24 @@ import { ClientData, DepartureTime, LunchChoice, LunchChoices, MealSlot, PizzaDa
|
||||
const storage = getStorage();
|
||||
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 {
|
||||
const base = formatDate(date);
|
||||
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) {
|
||||
clientData.stores = await getStores();
|
||||
} else {
|
||||
clientData.menus = {
|
||||
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
|
||||
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
|
||||
TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
|
||||
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
|
||||
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
|
||||
// Dočasně vyřazené podniky se do odpovědi vůbec nezahrnují (klient je pak nezobrazí)
|
||||
const menus: RestaurantDayMenuMap = {};
|
||||
for (const restaurant of getActiveRestaurants()) {
|
||||
menus[restaurant] = await getRestaurantMenu(restaurant, date);
|
||||
}
|
||||
clientData.menus = menus;
|
||||
// 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).
|
||||
if (pruneStaleFoodChoices(clientData.choices, clientData.menus)) {
|
||||
@@ -236,7 +253,8 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for
|
||||
const usedDate = date ?? getToday();
|
||||
const dayOfWeekIndex = getDayOfWeekIndex(usedDate);
|
||||
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 {
|
||||
lastUpdate: now,
|
||||
closed: true,
|
||||
@@ -468,6 +486,9 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
|
||||
await initIfNeeded(usedDate, slot);
|
||||
let data = await getClientData(usedDate, slot);
|
||||
validateTrusted(data, login, trusted);
|
||||
if (isRestaurantDisabled(locationKey)) {
|
||||
throw new Error(`Podnik ${locationKey} je dočasně uzavřen`);
|
||||
}
|
||||
await validateFoodIndex(locationKey, foodIndex, date);
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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";
|
||||
|
||||
const storage = getStorage();
|
||||
@@ -151,3 +151,59 @@ export async function removeStore(name: string, heslo: string): Promise<Store[]>
|
||||
await storage.setData(STORES_KEY, 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 () => {
|
||||
await seed(USER, { caught: 0, coins: 10, goldenCaught: 0 });
|
||||
const stats = await defeatThief(USER);
|
||||
const now = 4_000_000;
|
||||
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.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', () => {
|
||||
beforeEach(() => {
|
||||
// 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 getStorage from '../storage';
|
||||
import { getStores, addStore, updateStore, removeStore } from '../stores';
|
||||
import { getStores, addStore, updateStore, removeStore, getStoreDishes } from '../stores';
|
||||
|
||||
const ADMIN_PW = 'testadmin';
|
||||
|
||||
@@ -213,3 +213,27 @@ describe('removeStore', () => {
|
||||
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 () => {
|
||||
await saveLunch('2025-03-06', { OBJEDNAVAM: { [USER]: { note: 'platím kartou' } } });
|
||||
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', [{
|
||||
id: 'g3',
|
||||
name: 'KFC',
|
||||
creatorLogin: USER,
|
||||
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);
|
||||
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].amount).toBeUndefined();
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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).
|
||||
* 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 note = userChoice.note;
|
||||
let amount: number | undefined;
|
||||
let amount = userChoice.price;
|
||||
if (locationKey === LunchChoice.PIZZA) {
|
||||
const pizza = getPizzaText(lunchData, login);
|
||||
food = pizza.food;
|
||||
@@ -177,8 +182,9 @@ export async function getUserExportRows(login: string, year: number, month: numb
|
||||
break;
|
||||
}
|
||||
|
||||
// Objednávkové skupiny, ve kterých byl uživatel členem
|
||||
const groups = (extraData?.groups ?? []).filter(group => !!group.members[login]);
|
||||
// Objednávkové skupiny, ve kterých si uživatel něco objednal nebo k nim má poznámku.
|
||||
// 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[] = [];
|
||||
let mergedGroup = false;
|
||||
for (const group of groups) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DefaultEventsMap, Server } from "socket.io";
|
||||
import { createAdapter } from "@socket.io/redis-adapter";
|
||||
import { createClient } from "redis";
|
||||
import { registerDrawingGame } from "./drawingGame";
|
||||
|
||||
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
|
||||
let pubClient: ReturnType<typeof createClient>;
|
||||
@@ -28,6 +29,7 @@ export const initWebsocket = (server: any) => {
|
||||
// console.log(`Client disconnected: ${socket.id}`);
|
||||
});
|
||||
});
|
||||
registerDrawingGame(io);
|
||||
return io;
|
||||
};
|
||||
|
||||
|
||||
+727
-664
File diff suppressed because it is too large.
Load diff
@@ -24,6 +24,8 @@ paths:
|
||||
$ref: "./paths/food/removeChoice.yml"
|
||||
/food/updateNote:
|
||||
$ref: "./paths/food/updateNote.yml"
|
||||
/food/updatePrice:
|
||||
$ref: "./paths/food/updatePrice.yml"
|
||||
/food/removeChoices:
|
||||
$ref: "./paths/food/removeChoices.yml"
|
||||
/food/changeDepartureTime:
|
||||
@@ -150,6 +152,14 @@ paths:
|
||||
# Správa obchodů (/api/stores)
|
||||
/stores:
|
||||
$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:
|
||||
$ref: "./paths/stores/addStore.yml"
|
||||
/stores/update:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
get:
|
||||
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
|
||||
responses:
|
||||
"200":
|
||||
@@ -9,8 +9,36 @@ get:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [sentry]
|
||||
required: [sentry, limits, auth, features, links]
|
||||
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:
|
||||
type: object
|
||||
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:
|
||||
description: Volitelná, veřejně viditelná uživatelská poznámka k vybrané volbě
|
||||
type: string
|
||||
price:
|
||||
description: Uživatelem zadaná cena oběda v haléřích, promítá se do přehledu stravování
|
||||
type: integer
|
||||
isBuyer:
|
||||
description: Příznak, zda je tento uživatel objednatelem pro stav "Budu objednávat"
|
||||
type: boolean
|
||||
@@ -933,6 +936,234 @@ Store:
|
||||
items:
|
||||
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:
|
||||
description: Rozvozová služba, přes kterou lze sledovat stav objednávky
|
||||
type: string
|
||||
|
||||
Reference in new issue
Block a user