feat: spin the wheel, objednavani scrape menu, historie cen
CI / Generate TypeScript types (push) Successful in 11s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 39s
CI / Playwright E2E tests (push) Successful in 1m52s
CI / Build and push Docker image (push) Successful in 51s
CI / Notify (push) Successful in 2s

This commit is contained in:
2026-09-23 15:41:15 +02:00
parent 682deaf20b
commit e281615cc3
35 changed files with 3979 additions and 15 deletions
+5
View File
@@ -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&display=swap" rel="stylesheet" />
<title>Luncher</title>
<script>
(function() {
@@ -24,6 +27,8 @@
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-bs-theme', theme);
var style = localStorage.getItem('style_theme');
document.documentElement.setAttribute('data-luncher-theme', style === 'umamusume' ? 'umamusume' : 'simple');
} catch (e) {
// Fallback pokud localStorage není dostupný
document.documentElement.setAttribute('data-bs-theme', 'light');
+28 -1
View File
@@ -10,15 +10,18 @@ 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 { 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, 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 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';
@@ -81,6 +84,7 @@ function App() {
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
const [noteModalOpen, setNoteModalOpen] = 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
@@ -408,6 +412,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 } });
@@ -745,6 +767,7 @@ 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>}
@@ -768,6 +791,9 @@ 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 && <span title='Upravit poznámku'>
<FontAwesomeIcon onClick={() => {
setNoteModalOpen(true);
@@ -931,6 +957,7 @@ 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} />
{payForAllLocationKey && data && (
<PayForAllModal
+27
View File
@@ -0,0 +1,27 @@
// 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;
}
}
// 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;
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import { 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' },
];
type Props = {
/** Počet jídel/položek, které si strávník vybral či objednal */
items: number,
};
/** Odznak s postavou Umamusume u strávníků s více jídly — jen ve stylu Umamusume. */
export default function EaterBadge({ items }: Readonly<Props>) {
const isUma = useSettings()?.styleTheme === 'umamusume';
const [characters, setCharacters] = useState<UmaCharacter[]>([]);
useEffect(() => {
if (isUma) loadUmaCharacters().then(setCharacters);
}, [isUma]);
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>
);
}
+4 -2
View File
@@ -77,6 +77,8 @@ export default function Header({ choices, dayIndex }: Props) {
setQrModalOpen(true);
}
const isUmamusume = settings?.styleTheme === 'umamusume';
const toggleTheme = () => {
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
settings?.setThemePreference(newTheme);
@@ -156,11 +158,11 @@ export default function Header({ choices, dayIndex }: Props) {
>
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
</button>
<HuePicker
{!isUmamusume && <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>
+75
View File
@@ -0,0 +1,75 @@
// 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;
}
.select-search-group-header {
padding: 4px 12px;
font-size: 0.7rem;
}
}
.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;
}
}
+93
View File
@@ -0,0 +1,93 @@
import { useMemo } from "react";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from "react-select-search";
import { StoreMenuDish } from "../../../types";
import { matchNoteToMenu } from "../utils/menuMatch";
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,
};
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 }: Readonly<Props>) {
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, []);
groups.get(category)!.push({ name: formatDish(d), value: String(index) });
});
return [...groups.entries()].map(([name, items]) => ({ name, type: 'group', items }));
}, [menu]);
const match = useMemo(() => menu ? matchNoteToMenu(note, menu) : undefined, [menu, note]);
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 && (
<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);
}}
/>
)}
{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>
);
}
+33
View File
@@ -0,0 +1,33 @@
.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;
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useState } from "react";
import { getStoreMenuHistory, StoreMenuProvider, StorePriceChange, StorePriceHistoryEntry } from "../../../types";
import { getHumanDateTime } from "../Utils";
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í ({entry.itemCount} 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} — {entry.changes.length} 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>
);
}
/** 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[]>();
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>;
return (
<div className="price-history">
{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>
);
}
+163
View File
@@ -0,0 +1,163 @@
.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); }
}
+178
View File
@@ -0,0 +1,178 @@
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 DURATION_MS = 5200;
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;
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]);
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>}
{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>
)}
</div>,
document.body,
);
}
+11 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { Modal, Button, Form } from "react-bootstrap"
import { useSettings, ThemePreference } from "../../context/settings";
import { useSettings, ThemePreference, StyleTheme, STYLE_THEMES } from "../../context/settings";
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
import { useAuth } from "../../context/auth";
import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder";
@@ -19,6 +19,7 @@ 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 reminderTimeRef = useRef<HTMLInputElement>(null);
const boltDeliveredRef = useRef<HTMLInputElement>(null);
@@ -75,6 +76,9 @@ 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);
}
onSave(
bankAccountRef.current?.value,
nameRef.current?.value,
@@ -90,6 +94,12 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
</Modal.Header>
<Modal.Body>
<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}>
@@ -0,0 +1,546 @@
// Barvy výsečí — jednoduchý styl odvozuje odstíny od primární barvy,
// styl Umamusume je přebíjí vlastní paletou (viz níže)
:root {
--wheel-c0: var(--luncher-primary);
--wheel-c1: #0ea5e9;
--wheel-c2: #f59e0b;
--wheel-c3: #8b5cf6;
--wheel-c4: #ec4899;
--wheel-c5: #14b8a6;
--wheel-label: #ffffff;
--wheel-rim: var(--luncher-text);
--wheel-hub: var(--luncher-bg-card);
}
.spin-wheel-modal {
.spin-steps {
display: flex;
gap: 8px;
margin-bottom: 12px;
font-size: 0.85rem;
font-weight: 700;
span {
padding: 4px 12px;
border-radius: 999px;
background: var(--luncher-bg-hover);
color: var(--luncher-text-muted);
&.active {
background: var(--luncher-primary);
color: #fff;
}
&.done {
background: var(--luncher-primary-light);
color: var(--luncher-primary);
}
}
}
.spin-source-info {
align-self: center;
margin-left: 4px;
color: var(--luncher-text-muted);
font-weight: 600;
}
.spin-steps {
flex-wrap: wrap;
}
// Přepínač zdroje jako segmentovaná „pilulka"
.spin-source {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 999px;
background: var(--luncher-bg-hover);
> .btn {
padding: 6px 16px;
font-size: 0.9rem;
border: none !important;
border-radius: 999px !important;
box-shadow: none !important;
margin: 0 !important;
transform: none !important;
}
> .btn-outline-primary {
background: transparent;
color: var(--luncher-text-secondary);
&:hover {
background: var(--luncher-bg-card);
color: var(--luncher-primary);
}
}
}
.spin-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 240px;
gap: 20px;
align-items: start;
@media (max-width: 768px) {
grid-template-columns: minmax(0, 1fr);
}
}
.spin-wheel-column {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
min-width: 0;
}
.spin-empty {
width: 100%;
max-width: 380px;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
border: 2px dashed var(--luncher-border);
border-radius: 50%;
color: var(--luncher-text-muted);
}
.spin-result {
min-height: 1.6em;
font-size: 1.15rem;
text-align: center;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.3s ease, transform 0.3s ease;
&.visible {
opacity: 1;
transform: none;
}
small {
color: var(--luncher-text-muted);
}
}
.spin-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.spin-button {
min-width: 160px;
}
.spin-options {
border: 1px solid var(--luncher-border);
border-radius: var(--luncher-radius);
background: var(--luncher-bg);
max-height: 440px;
display: flex;
flex-direction: column;
min-width: 0;
ul {
list-style: none;
margin: 0;
padding: 4px;
overflow-y: auto;
}
li {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: var(--luncher-radius-sm);
font-size: 0.85rem;
&:hover {
background: var(--luncher-bg-hover);
}
}
}
.spin-options-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border-bottom: 1px solid var(--luncher-border);
font-weight: 700;
font-size: 0.85rem;
}
.spin-restore {
background: none;
border: none;
color: var(--luncher-primary);
font-size: 0.8rem;
font-weight: 600;
padding: 0;
}
.spin-dot {
flex: none;
width: 10px;
height: 10px;
border-radius: 50%;
}
.spin-option-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.spin-option-kind {
color: var(--luncher-text-muted);
font-size: 0.7rem;
}
.spin-remove {
flex: none;
background: none;
border: none;
color: var(--luncher-text-muted);
padding: 2px 6px;
border-radius: 4px;
&:hover:not(:disabled) {
color: var(--luncher-danger);
background: var(--luncher-bg-hover);
}
}
}
.spin-wheel {
position: relative;
width: 100%;
max-width: 380px;
aspect-ratio: 1;
}
.spin-wheel-svg {
width: 100%;
height: 100%;
display: block;
filter: drop-shadow(0 6px 12px rgb(0 0 0 / 0.18));
}
.spin-wheel-pointer {
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
z-index: 2;
width: 0;
height: 0;
border-left: 14px solid transparent;
border-right: 14px solid transparent;
border-top: 26px solid var(--luncher-danger);
filter: drop-shadow(0 2px 2px rgb(0 0 0 / 0.3));
}
.spin-wheel-rim {
fill: var(--wheel-rim);
}
.spin-wheel-hub {
fill: var(--wheel-hub);
stroke: var(--wheel-rim);
stroke-width: 4;
}
.spin-seg {
stroke: var(--wheel-hub);
stroke-width: 1.5;
}
.spin-seg-label {
fill: var(--wheel-label);
font-weight: 700;
pointer-events: none;
paint-order: stroke;
stroke: rgb(0 0 0 / 0.25);
stroke-width: 2px;
}
@for $i from 0 through 5 {
.spin-seg-#{$i} {
fill: var(--wheel-c#{$i});
background: var(--wheel-c#{$i});
}
}
@media (prefers-reduced-motion: reduce) {
.spin-wheel-svg {
transition-duration: 0.6s !important;
}
}
.spin-wheel-modal .modal-header {
gap: 12px;
flex-wrap: wrap;
.modal-title {
flex: 1;
min-width: 0;
h2 {
margin: 0;
}
}
}
// ============================================
// STYL UMAMUSUME kola štěstí (dle stylu zvoleného v nastavení).
// Trávník dostihové dráhy, růžové a nebeské akcenty, zlaté lemy,
// zaoblené tučné písmo a „herní" tlačítka s vystouplým spodkem.
// ============================================
.spin-wheel-modal.spin-theme-umamusume {
--uma-font: 'M PLUS Rounded 1c', 'Nunito', 'Inter', sans-serif;
--uma-turf: #5cc23d;
--uma-turf-deep: #2e9a3a;
--uma-turf-shadow: #1f7a2c;
--uma-pink: #ff6fa3;
--uma-pink-deep: #e84d86;
--uma-gold: #ffc531;
--uma-panel: #f4fbef;
--uma-border: #c6ebb2;
--uma-text: #1d3322;
--wheel-c0: var(--uma-turf);
--wheel-c1: var(--uma-pink);
--wheel-c2: #43b8f0;
--wheel-c3: #f2a91a;
--wheel-c4: #a078f0;
--wheel-c5: #ff8a3d;
--wheel-rim: var(--uma-gold);
--wheel-hub: #ffffff;
font-family: var(--uma-font);
.modal-content {
border: 4px solid var(--uma-border);
border-radius: 22px;
overflow: hidden;
background-color: #fff;
background-image: repeating-linear-gradient(135deg, rgb(92 194 61 / 0.06) 0 16px, transparent 16px 32px);
color: var(--uma-text);
}
.modal-header {
background:
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.13) 0 12px, transparent 12px 24px),
linear-gradient(90deg, var(--uma-turf-deep), var(--uma-turf));
border-bottom: 4px solid var(--uma-gold);
h2 {
color: #fff;
font-family: var(--uma-font);
font-weight: 800;
text-shadow: 0 2px 0 var(--uma-turf-shadow);
}
.btn-close {
filter: invert(1) grayscale(1) brightness(2);
opacity: 0.9;
}
}
.modal-footer {
border-top: 3px dashed var(--uma-border);
}
.spin-steps span {
font-family: var(--uma-font);
background: #e7f3e0;
color: #7c9a82;
clip-path: polygon(8px 0, 100% 0, calc(100% - 8px) 100%, 0 100%);
border-radius: 0;
padding: 4px 18px;
&.active {
background: linear-gradient(90deg, var(--uma-pink-deep), var(--uma-pink));
color: #fff;
}
&.done {
background: var(--uma-turf);
color: #fff;
}
}
.btn {
font-family: var(--uma-font);
font-weight: 800;
border-radius: 999px;
transition: transform 0.08s ease, box-shadow 0.08s ease, background 0.2s ease;
&:active:not(:disabled) {
transform: translateY(3px);
box-shadow: 0 1px 0 var(--uma-btn-shadow, transparent);
}
}
.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);
color: #fff;
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
&:disabled {
background: #b9cbbd;
box-shadow: 0 4px 0 #97aa9b;
}
}
.spin-button {
--uma-btn-shadow: #b8335f;
background: linear-gradient(180deg, #ff8db5, var(--uma-pink-deep));
box-shadow: 0 5px 0 #b8335f;
font-size: 1.15rem;
padding: 12px 36px;
}
.btn-outline-primary {
--uma-btn-shadow: var(--uma-border);
background: #fff;
border: 2px solid var(--uma-turf);
color: var(--uma-turf-deep);
box-shadow: 0 4px 0 var(--uma-border);
&:hover {
background: #eaf8e2;
color: var(--uma-turf-deep);
}
}
.btn-secondary {
--uma-btn-shadow: #d4ddd6;
background: #fff;
border: 2px solid #d4ddd6;
color: var(--uma-text);
box-shadow: 0 4px 0 #d4ddd6;
}
.spin-source {
background: #e7f3e0;
> .btn-primary {
background: linear-gradient(180deg, #7ad85a, var(--uma-turf-deep));
box-shadow: 0 2px 0 var(--uma-turf-shadow) !important;
}
> .btn-outline-primary {
background: transparent;
color: var(--uma-turf-deep);
&:hover {
background: #fff;
}
}
}
.spin-result {
font-weight: 800;
strong {
color: var(--uma-pink-deep);
}
}
.spin-options {
background: var(--uma-panel);
border: 3px solid var(--uma-border);
border-radius: 16px;
li:hover {
background: #fff0f6;
}
}
.spin-options-header {
border-bottom: 3px solid var(--uma-border);
color: var(--uma-turf-deep);
}
.spin-empty {
border-color: var(--uma-border);
}
.spin-wheel-pointer {
border-top-color: var(--uma-pink-deep);
}
.spin-seg {
stroke: #fff;
stroke-width: 2.5;
}
.spin-seg-label {
font-family: var(--uma-font);
font-weight: 800;
}
}
// Tmavý režim — noční dostih pod reflektory
[data-bs-theme="dark"] .spin-wheel-modal.spin-theme-umamusume {
--uma-panel: #19223d;
--uma-border: #2c3a66;
--uma-text: #f3f6ff;
--wheel-hub: #19223d;
.modal-content {
background-color: #10172b;
background-image: repeating-linear-gradient(135deg, rgb(255 255 255 / 0.03) 0 16px, transparent 16px 32px);
}
.spin-steps span:not(.active):not(.done) {
background: #222d4f;
color: #7d89ad;
}
.btn-outline-primary, .btn-secondary {
background: #19223d;
color: #f3f6ff;
}
.spin-source {
background: #222d4f;
> .btn-outline-primary {
background: transparent;
color: #b8c2e0;
&:hover {
background: #19223d;
color: #fff;
}
}
}
.spin-options li:hover {
background: #2a2344;
}
}
// Ikona kola se při najetí pootočí
.spin-wheel-icon {
transition: transform 0.4s ease !important;
&:hover {
transform: rotate(180deg) scale(1.15) !important;
}
}
@@ -0,0 +1,393 @@
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;
/** 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);
};
const spin = () => {
const n = wheelItems.length;
if (n === 0 || 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'
? <>Padlo: <strong>{place!.label}</strong> <small>({place!.kind === 'menu' ? 'dnešní menu' : 'rozvoz'})</small></>
: <>Padlo: <strong>{dish!.name}</strong>{dish!.price && <small> ({dish!.price})</small>}</>)}
</div>
<div className="spin-actions">
<Button onClick={spin} disabled={spinning || wheelItems.length === 0} className="spin-button">
{spinning ? 'Točí se…' : result ? '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>
</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,11 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { Modal, Button, Form, ListGroup, Alert } 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 { getStoreUrls, storeUrlLabel } from "../../utils/storeUrls";
type Props = {
@@ -20,6 +22,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 +52,38 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
const [heslo, setHeslo] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
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 +198,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>
@@ -244,6 +295,35 @@ 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>
<Button
variant="link"
size="sm"
className="p-0"
disabled={!heslo || refreshing != null}
title={heslo ? `Znovu stáhnout nabídku z ${PROVIDER_LABELS[p.provider]}` : 'Pro obnovení zadejte admin heslo'}
onClick={() => handleRefreshMenu(s.name, p.provider)}
>
<FontAwesomeIcon icon={faArrowsRotate} spin={refreshing === key} />
</Button>
{p.dishCount > 0 && (
<Button variant="link" size="sm" className="p-0 text-nowrap"
onClick={() => setHistoryOpen(historyOpen === key ? null : key)}>
{historyOpen === key ? 'skrýt historii' : 'historie cen'}
</Button>
)}
</small>
{historyOpen === key && <PriceHistory store={s.name} provider={p.provider} />}
</div>
);
})}
</div>
<div className="d-flex gap-3">
<FontAwesomeIcon
+48 -3
View File
@@ -6,9 +6,18 @@ 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';
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';
export const STYLE_THEMES: { value: StyleTheme, label: string }[] = [
{ value: 'simple', label: 'Jednoduchý' },
{ value: 'umamusume', label: 'Umamusume' },
];
export type SettingsContextProps = {
bankAccount?: string,
holderName?: string,
@@ -16,11 +25,13 @@ export type SettingsContextProps = {
themePreference: ThemePreference,
accentHue: number,
effectiveDark: boolean,
styleTheme: StyleTheme,
setBankAccountNumber: (accountNumber?: string) => void,
setBankAccountHolderName: (holderName?: string) => void,
setHideSoupsOption: (hideSoups?: boolean) => void,
setThemePreference: (theme: ThemePreference) => void,
setAccentHue: (hue: number) => void,
setStyleTheme: (theme: StyleTheme) => void,
}
type ContextProps = {
@@ -50,6 +61,17 @@ function getInitialTheme(): ThemePreference {
return 'system';
}
function getInitialStyleTheme(): StyleTheme {
try {
if (localStorage.getItem(STYLE_THEME_KEY) === 'umamusume') {
return 'umamusume';
}
} catch {
// localStorage nedostupný
}
return 'simple';
}
function getInitialAccentHue(): number {
try {
const saved = localStorage.getItem(ACCENT_HUE_KEY);
@@ -90,6 +112,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 +138,7 @@ 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 [effectiveDark, setEffectiveDark] = useState<boolean>(() => {
try {
const pref = localStorage.getItem(THEME_KEY) as ThemePreference | null;
@@ -178,11 +207,21 @@ 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]);
// Aplikuje accent barvy při změně hue nebo přepnutí světlý/tmavý.
// Styl Umamusume má vlastní pevnou paletu, accent barvu proto uplatňuje jen jednoduchý styl.
useEffect(() => {
localStorage.setItem(ACCENT_HUE_KEY, String(accentHue));
applyAccentColors(accentHue, effectiveDark);
}, [accentHue, effectiveDark]);
if (styleTheme === 'simple') {
applyAccentColors(accentHue, effectiveDark);
} else {
clearAccentColors();
}
}, [accentHue, effectiveDark, styleTheme]);
function setBankAccountNumber(bankAccount?: string) {
setBankAccount(bankAccount);
@@ -204,6 +243,10 @@ function useProvideSettings(): SettingsContextProps {
setHue(hue);
}
function setStyleTheme(theme: StyleTheme) {
setStyle(theme);
}
return {
bankAccount,
holderName,
@@ -216,5 +259,7 @@ function useProvideSettings(): SettingsContextProps {
setHideSoupsOption,
setThemePreference,
setAccentHue,
styleTheme,
setStyleTheme,
}
}
+50 -2
View File
@@ -8,8 +8,11 @@ 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,
StoreMenuDish, getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setTracking, getOrderDates, getStoreMenu,
} from '../../../types';
import MenuHintCell from '../components/MenuHintCell';
import EaterBadge from '../components/EaterBadge';
import { countNoteItems } 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';
@@ -147,6 +150,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 {
@@ -308,6 +313,35 @@ 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 a její cenu přičte k částce. */
const handlePickDish = (group: OrderGroup, login: string, member: OrderGroupMember, dish: StoreMenuDish) => {
const note = member.note?.trim() ? `${member.note.trim()} + ${dish.name}` : dish.name;
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 }) } }));
};
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 +664,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 +681,7 @@ export default function OrderGroupsPage() {
<tr key={memberLogin}>
<td>
<span className="user-info">
<EaterBadge items={countNoteItems(member.note)} />
<strong>{memberLogin}</strong>
{memberLogin === group.creatorLogin && (
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
@@ -744,6 +780,18 @@ 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)}
/>
</td>
) : null}
<td className="text-end">
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
{memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'}
@@ -775,7 +823,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>
+371
View File
@@ -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);
}
}
+75
View File
@@ -0,0 +1,75 @@
import type { StoreMenuDish } from "../../../types";
/** 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 whole = dishes.find(d => d.norm === normalizeDishName(note));
const parts: MatchedPart[] = whole
? [{ text: note.trim(), quantity: 1, dish: whole.dish }]
: note.split('+').map(p => p.trim()).filter(Boolean).map(text => {
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): number {
if (!note?.trim()) return 0;
return note.split('+').map(p => p.trim()).filter(Boolean)
.reduce((sum, part) => sum + parseQuantity(part).quantity, 0);
}
+23
View File
@@ -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;
}
+5
View File
@@ -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"
]
+347
View File
@@ -0,0 +1,347 @@
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',
],
},
];
/** 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).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) }),
}));
}
/**
* 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;
}
+20
View File
@@ -29,6 +29,7 @@ import changelogRoutes from "./routes/changelogRoutes";
import groupRoutes from "./routes/groupRoutes";
import storeRoutes from "./routes/storeRoutes";
import butterflyRoutes from "./routes/butterflyRoutes";
import { getConfettiCharacters, getConfettiImage } from "./confetti";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
@@ -200,6 +201,25 @@ 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/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) => {
+72
View File
@@ -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') {
+505
View File
@@ -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
View File
@@ -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;
}
+39
View File
@@ -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);
});
+315
View File
@@ -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: [] });
});
});
+25 -1
View File
@@ -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: [] },
]);
});
});
+8
View File
@@ -150,6 +150,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:
+29
View File
@@ -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"
+19
View File
@@ -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
+19
View File
@@ -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
+12
View File
@@ -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"
+193
View File
@@ -933,6 +933,199 @@ 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
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