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
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
// ============================================
|
||||
// STYL "UMAMUSUME" (volitelný, výchozí je jednoduchý styl)
|
||||
// Inspirováno Umamusume: Pretty Derby — trávník dostihové dráhy, růžové
|
||||
// a nebesky modré akcenty, zlaté lemy, zaoblené tučné písmo a „tlačítka z her".
|
||||
// Aktivní, pokud má <html> atribut data-luncher-theme="umamusume".
|
||||
// Jednoduchý styl (data-luncher-theme="simple") zůstává beze změny v App.scss.
|
||||
// ============================================
|
||||
|
||||
$uma: 'html[data-luncher-theme="umamusume"]';
|
||||
|
||||
#{$uma} {
|
||||
--uma-font: 'M PLUS Rounded 1c', 'Nunito', 'Inter', -apple-system, sans-serif;
|
||||
--uma-turf: #5cc23d;
|
||||
--uma-turf-deep: #2e9a3a;
|
||||
--uma-turf-shadow: #1f7a2c;
|
||||
--uma-pink: #ff6fa3;
|
||||
--uma-pink-deep: #e84d86;
|
||||
--uma-sky: #43b8f0;
|
||||
--uma-gold: #ffc531;
|
||||
--uma-card-border: #c6ebb2;
|
||||
--uma-stripe: rgb(92 194 61 / 0.06);
|
||||
|
||||
--luncher-primary: var(--uma-turf-deep);
|
||||
--luncher-primary-hover: #268a31;
|
||||
--luncher-primary-light: #e7f8dc;
|
||||
--luncher-bg: #f4fbef;
|
||||
--luncher-bg-card: #ffffff;
|
||||
--luncher-bg-hover: #eef8e7;
|
||||
--luncher-text: #1d3322;
|
||||
--luncher-text-secondary: #4a6450;
|
||||
--luncher-text-muted: #8aa290;
|
||||
--luncher-border: #d7ecca;
|
||||
--luncher-border-light: #e6f4dc;
|
||||
--luncher-navbar-bg: var(--uma-turf-deep);
|
||||
--luncher-navbar-text: #ffffff;
|
||||
--luncher-action-icon: var(--uma-turf-deep);
|
||||
--luncher-buyer-icon: #f08c00;
|
||||
--luncher-success: var(--uma-turf-deep);
|
||||
--luncher-warning: #f08c00;
|
||||
--luncher-danger: #e8436a;
|
||||
--luncher-radius-sm: 10px;
|
||||
--luncher-radius: 14px;
|
||||
--luncher-radius-lg: 18px;
|
||||
--luncher-radius-xl: 22px;
|
||||
--luncher-shadow: 0 4px 0 rgb(46 154 58 / 0.12), 0 8px 18px -8px rgb(29 51 34 / 0.18);
|
||||
--luncher-shadow-lg: 0 6px 0 rgb(46 154 58 / 0.14), 0 14px 28px -10px rgb(29 51 34 / 0.25);
|
||||
|
||||
|
||||
&[data-bs-theme="dark"] {
|
||||
// Noční dostih pod reflektory
|
||||
--uma-turf: #58c93a;
|
||||
--uma-turf-deep: #3cae3f;
|
||||
--uma-turf-shadow: #1c5e25;
|
||||
--uma-card-border: #2c3a66;
|
||||
--uma-stripe: rgb(255 255 255 / 0.025);
|
||||
|
||||
--luncher-primary: #6fdc4a;
|
||||
--luncher-primary-hover: #8ae86a;
|
||||
--luncher-primary-light: #1d3a24;
|
||||
--luncher-bg: #10172b;
|
||||
--luncher-bg-card: #19223d;
|
||||
--luncher-bg-hover: #222d4f;
|
||||
--luncher-text: #f3f6ff;
|
||||
--luncher-text-secondary: #b8c2e0;
|
||||
--luncher-text-muted: #7d89ad;
|
||||
--luncher-border: #2c3a66;
|
||||
--luncher-border-light: #232e52;
|
||||
--luncher-navbar-bg: #1b6b2c;
|
||||
--luncher-action-icon: #6fdc4a;
|
||||
--luncher-buyer-icon: var(--uma-gold);
|
||||
--luncher-success: #6fdc4a;
|
||||
--luncher-warning: var(--uma-gold);
|
||||
--luncher-danger: #ff6f93;
|
||||
--luncher-shadow: 0 4px 0 rgb(0 0 0 / 0.35), 0 10px 20px -8px rgb(0 0 0 / 0.5);
|
||||
--luncher-shadow-lg: 0 6px 0 rgb(0 0 0 / 0.4), 0 16px 30px -10px rgb(0 0 0 / 0.6);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--uma-font);
|
||||
background-color: var(--luncher-bg);
|
||||
background-image: repeating-linear-gradient(135deg, var(--uma-stripe) 0 18px, transparent 18px 36px);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, .navbar-brand, .btn {
|
||||
font-family: var(--uma-font);
|
||||
}
|
||||
|
||||
// ---------- Navbar: zelený pruh se šikmými proužky a zlatým lemem ----------
|
||||
.navbar {
|
||||
background:
|
||||
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.10) 0 14px, transparent 14px 28px),
|
||||
linear-gradient(90deg, var(--uma-turf-deep), var(--uma-turf)) !important;
|
||||
border-bottom: 4px solid var(--uma-gold);
|
||||
box-shadow: 0 3px 0 rgb(0 0 0 / 0.08);
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 2px 0 var(--uma-turf-shadow), 0 0 12px rgb(255 255 255 / 0.25);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-left: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--uma-pink);
|
||||
box-shadow: 0 0 0 3px rgb(255 255 255 / 0.9);
|
||||
vertical-align: super;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
border: 2px solid var(--uma-card-border);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Titulek dne: šikmá stuha jako název dostihu ----------
|
||||
.day-navigator .title {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 6px 28px;
|
||||
font-weight: 800;
|
||||
font-size: 1.7rem;
|
||||
color: #fff !important;
|
||||
background: linear-gradient(90deg, var(--uma-pink-deep), var(--uma-pink));
|
||||
clip-path: polygon(14px 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
|
||||
text-shadow: 0 2px 0 rgb(0 0 0 / 0.15);
|
||||
|
||||
&.text-muted {
|
||||
background: linear-gradient(90deg, #8aa290, #a9bfae);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Karty podniků ----------
|
||||
.restaurant-card {
|
||||
border: 3px solid var(--uma-card-border);
|
||||
border-radius: var(--luncher-radius-lg);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--uma-turf);
|
||||
}
|
||||
|
||||
.restaurant-header {
|
||||
position: relative;
|
||||
background:
|
||||
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.12) 0 10px, transparent 10px 20px),
|
||||
linear-gradient(90deg, var(--uma-turf-deep), var(--uma-turf));
|
||||
border-bottom: 3px solid var(--uma-gold);
|
||||
|
||||
&:hover {
|
||||
background:
|
||||
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.12) 0 10px, transparent 10px 20px),
|
||||
linear-gradient(90deg, var(--uma-pink-deep), var(--uma-pink));
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: 800;
|
||||
text-shadow: 0 2px 0 rgb(0 0 0 / 0.18);
|
||||
}
|
||||
|
||||
small {
|
||||
color: rgb(255 255 255 / 0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.food-table tbody tr:hover > td {
|
||||
background: #fff0f6;
|
||||
}
|
||||
|
||||
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
|
||||
background: #2a2344;
|
||||
}
|
||||
|
||||
.food-price {
|
||||
color: var(--uma-pink-deep);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
&[data-bs-theme="dark"] .food-price {
|
||||
color: var(--uma-pink);
|
||||
}
|
||||
|
||||
// ---------- Panely a tabulka voleb ----------
|
||||
.choice-section,
|
||||
.choices-table,
|
||||
.pizza-section,
|
||||
.no-votes {
|
||||
border: 3px solid var(--uma-card-border);
|
||||
border-radius: var(--luncher-radius-lg);
|
||||
box-shadow: var(--luncher-shadow);
|
||||
}
|
||||
|
||||
// <table> s border-collapse: collapse ignoruje border-radius — kulaté rohy jen se „separate"
|
||||
.choices-table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.choices-table > tbody > tr > td:first-child {
|
||||
background: linear-gradient(90deg, #ffe3ee, #fff3f8);
|
||||
color: var(--uma-pink-deep);
|
||||
font-weight: 800;
|
||||
border-right: 3px solid var(--uma-card-border);
|
||||
}
|
||||
|
||||
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
|
||||
background: linear-gradient(90deg, #3b2446, #2a2140);
|
||||
color: var(--uma-pink);
|
||||
}
|
||||
|
||||
// Bootstrap karty (skupinové objednávky apod.) a seznamy — stejné zaoblení a lem jako ostatní panely
|
||||
.card {
|
||||
border: 3px solid var(--uma-card-border);
|
||||
border-radius: var(--luncher-radius-lg);
|
||||
box-shadow: var(--luncher-shadow);
|
||||
overflow: hidden;
|
||||
background: var(--luncher-bg-card);
|
||||
|
||||
// Rozbalovací výběr z nabídky nesmí karta oříznout
|
||||
&:has(.select-search-container) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
> .card-header {
|
||||
background: var(--luncher-primary-light);
|
||||
border-bottom: 3px solid var(--uma-card-border);
|
||||
|
||||
&:first-child {
|
||||
border-radius: calc(var(--luncher-radius-lg) - 3px) calc(var(--luncher-radius-lg) - 3px) 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Průhledná tabulka — rohy pak zaobluje karta sama (i když nesmí ořezávat obsah)
|
||||
.table {
|
||||
--bs-table-bg: transparent;
|
||||
margin-bottom: 0;
|
||||
|
||||
// Spodní linka posledního řádku by přečnívala přes zaoblené rohy
|
||||
> :last-child > tr:last-child > * {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-group {
|
||||
border-radius: var(--luncher-radius);
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--uma-card-border);
|
||||
|
||||
> .list-group-item {
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-select, .form-control,
|
||||
input[type="text"], input[type="email"], input[type="password"] {
|
||||
border-radius: 999px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
// ---------- Tlačítka „ze hry": pilulka s vystouplým spodním okrajem ----------
|
||||
.btn {
|
||||
border-radius: 999px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.2px;
|
||||
transition: transform 0.08s ease, box-shadow 0.08s ease, background 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: translateY(3px) !important;
|
||||
box-shadow: 0 1px 0 var(--uma-btn-shadow, transparent) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
--uma-btn-shadow: var(--uma-turf-shadow);
|
||||
background: linear-gradient(180deg, #7ad85a, var(--uma-turf-deep));
|
||||
box-shadow: 0 4px 0 var(--uma-turf-shadow);
|
||||
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(180deg, #8be26b, #35a940);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #b9cbbd;
|
||||
box-shadow: 0 4px 0 #97aa9b;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
--uma-btn-shadow: var(--uma-card-border);
|
||||
background: var(--luncher-bg-card);
|
||||
border: 2px solid var(--uma-turf);
|
||||
color: var(--luncher-primary);
|
||||
box-shadow: 0 4px 0 var(--uma-card-border);
|
||||
|
||||
&:hover {
|
||||
background: var(--luncher-primary-light);
|
||||
color: var(--luncher-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
--uma-btn-shadow: #c9d3cb;
|
||||
background: var(--luncher-bg-card);
|
||||
border: 2px solid var(--luncher-border);
|
||||
box-shadow: 0 4px 0 var(--luncher-border);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
--uma-btn-shadow: #b3264b;
|
||||
background: linear-gradient(180deg, #ff7f9d, var(--luncher-danger));
|
||||
box-shadow: 0 4px 0 #b3264b;
|
||||
}
|
||||
|
||||
.action-icon:hover {
|
||||
background: #ffe3ee;
|
||||
color: var(--uma-pink-deep);
|
||||
}
|
||||
|
||||
&[data-bs-theme="dark"] .action-icon:hover {
|
||||
background: #3b2446;
|
||||
color: var(--uma-pink);
|
||||
}
|
||||
|
||||
// ---------- Modaly ----------
|
||||
.modal-content {
|
||||
border: 3px solid var(--uma-card-border);
|
||||
border-radius: var(--luncher-radius-xl);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background:
|
||||
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.12) 0 12px, transparent 12px 24px),
|
||||
linear-gradient(90deg, var(--uma-turf-deep), var(--uma-turf));
|
||||
border-bottom: 3px solid var(--uma-gold);
|
||||
|
||||
.modal-title, .modal-title h2 {
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
text-shadow: 0 2px 0 rgb(0 0 0 / 0.18);
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
filter: invert(1) grayscale(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 4px solid var(--uma-gold);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user