import { useEffect, useMemo, useRef, useState } from "react"; import { Button, ButtonGroup, Form, Modal, Spinner } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBookOpen, faRotateLeft, faXmark } from "@fortawesome/free-solid-svg-icons"; import { MealGroup, MealTemplate, RecipeFlavor, getMealGroups } from "../../../../types"; import { RECIPE_FLAVOR_NAMES, StyleTheme, WHEEL_POOL_NAMES, WheelPool } from "../../enums"; import { useSettings } from "../../context/settings"; import { getMealKey, normalizeSearch } from "../../Utils"; import SpinWheel, { SEGMENT_COLORS } from "../SpinWheel"; import WheelConfetti from "../WheelConfetti"; import WheelRecipe from "../WheelRecipe"; import "./SpinWheelModal.scss"; type Props = { isOpen: boolean, onClose: () => void, }; /** Točit má smysl až od dvou možností — jediná zbylá se vybere rovnou. */ const MIN_SPIN_ITEMS = 2; type RecipeGroup = MealGroup & { recipe: MealTemplate }; /** Výseč kola — jídlo, nebo u všech jídel nejdřív zdroj, ze kterého se pak losuje. */ type WheelItem = { key: string, label: string, note?: string, group?: MealGroup, }; /** Klíče zdrojů se nesmí potkat s klíči jídel — vyřazené možnosti obou kol leží v jedné množině. */ const SOURCE_KEY_PREFIX = 'zdroj:'; const getSourceKey = (group: MealGroup): string => `${SOURCE_KEY_PREFIX}${normalizeSearch(group.source ?? '')}`; /** Zdroje jídel pro první kolo, každý jednou, seřazené podle abecedy; jídla bez zdroje jako „Bez zdroje". */ const listSources = (groupList: MealGroup[]): WheelItem[] => { const sourceMap = new Map(); for (const group of groupList) { const key = getSourceKey(group); const current = sourceMap.get(key); if (current) current.count++; else sourceMap.set(key, { label: group.source?.trim() || 'Bez zdroje', count: 1 }); } return [...sourceMap.entries()] .map(([key, { label, count }]) => ({ key, label, note: `${count}×` })) .sort((a, b) => a.label.localeCompare(b.label, 'cs')); }; const createMealItem = (group: MealGroup, withSource: boolean): WheelItem => ({ key: getMealKey(group), label: group.name, note: withSource ? group.source : undefined, group }); const getResultCaption = (sourceStage: boolean, onlyOne: boolean): string => { if (sourceStage) return onlyOne ? 'Zbývá jediný zdroj' : 'Padl zdroj'; return onlyOne ? 'Zbývá jediná možnost' : 'Padlo'; }; const hasRecipe = (group: MealGroup): group is RecipeGroup => !!group.recipe; /** Kryptograficky náhodný index v rozsahu 0..n-1 (modulo zkreslení u malých n nevadí). */ const getRandomIndex = (count: number): number => { const buffer = new Uint32Array(1); crypto.getRandomValues(buffer); return buffer[0] % count; }; /** Typy receptů pro výběr, každý jednou (porovnává se bez diakritiky), seřazené podle abecedy. */ const listCategories = (groupList: RecipeGroup[]): { value: string, label: string }[] => { const categoryMap = new Map(); for (const group of groupList) { const category = group.recipe.category?.trim(); if (category && !categoryMap.has(normalizeSearch(category))) categoryMap.set(normalizeSearch(category), category); } return [...categoryMap.entries()] .map(([value, label]) => ({ value, label })) .sort((a, b) => a.label.localeCompare(b.label, 'cs')); }; /** * Kolo štěstí pro nerozhodnuté, převzaté z Luncheru. Losuje buď ze všech jídel * (i objednávaných — pak stačí, že kolo rozhodne), nebo jen z receptů, volitelně * zúžených chutí a typem; padlý recept jde rovnou otevřít a vařit podle něj. * Všech jídel jsou stovky, takže se u nich nejdřív vybere (nebo vylosuje) zdroj * a teprve z jeho jídel se točí. Možnosti lze z kola průběžně vyřazovat. */ const SpinWheelModal = ({ isOpen, onClose }: Readonly) => { const [groupList, setGroupList] = useState(); const [pool, setPool] = useState(WheelPool.VSE); const [flavor, setFlavor] = useState(); const [category, setCategory] = useState(''); const [sourceKey, setSourceKey] = useState(); const [excludedKeys, setExcludedKeys] = useState>(new Set()); const [picked, setPicked] = useState(); const [recipeShown, setRecipeShown] = useState(false); // Recept otevřený z vazby padlého receptu (příloha, podobný) const [linkedKey, setLinkedKey] = useState(); const [rotation, setRotation] = useState(0); const [spinning, setSpinning] = useState(false); const [respinRequested, setRespinRequested] = useState(false); const [confettiKey, setConfettiKey] = useState(0); const pendingIndex = useRef(undefined); const theme = useSettings()?.styleTheme ?? StyleTheme.JEDNODUCHY; useEffect(() => { if (isOpen && !groupList) { getMealGroups().then(response => setGroupList(response.data ?? [])); } }, [isOpen, groupList]); const flavorRecipeList = useMemo( () => (groupList ?? []).filter(hasRecipe).filter(group => !flavor || group.recipe.flavor === flavor), [groupList, flavor], ); const categoryList = useMemo(() => listCategories(flavorRecipeList), [flavorRecipeList]); const sourceList = useMemo(() => listSources(groupList ?? []), [groupList]); const sourceStage = pool === WheelPool.VSE && !sourceKey; const candidateList = useMemo(() => { if (pool === WheelPool.VSE) { if (!sourceKey) return sourceList; return (groupList ?? []) .filter(group => getSourceKey(group) === sourceKey) .map(group => createMealItem(group, false)); } return flavorRecipeList .filter(group => !category || normalizeSearch(group.recipe.category ?? '') === category) .map(group => createMealItem(group, true)); }, [pool, sourceKey, sourceList, groupList, flavorRecipeList, category]); const wheelList = useMemo( () => candidateList.filter(item => !excludedKeys.has(item.key)), [candidateList, excludedKeys], ); const excludedCount = candidateList.length - wheelList.length; const onlyOne = wheelList.length === 1; const showRecipe = (shown: boolean) => { setRecipeShown(shown); setLinkedKey(undefined); }; const clearResult = () => { setPicked(undefined); showRecipe(false); }; const changePool = (value: WheelPool) => { setPool(value); clearResult(); }; const changeFlavor = (value?: RecipeFlavor) => { setFlavor(value); // Typ, který s novou chutí žádný recept nemá, by kolo vyprázdnil const stillExists = !category || (groupList ?? []).filter(hasRecipe).some(group => (!value || group.recipe.flavor === value) && normalizeSearch(group.recipe.category ?? '') === category); if (!stillExists) setCategory(''); clearResult(); }; const changeSource = (value?: string) => { setSourceKey(value); clearResult(); }; // Zbyla jediná možnost — není co losovat, vybere se rovnou useEffect(() => { if (spinning || !onlyOne) return; if (picked?.key !== wheelList[0].key) setPicked(wheelList[0]); }, [onlyOne, spinning, wheelList, picked]); const spin = () => { const count = wheelList.length; if (count < MIN_SPIN_ITEMS || spinning) return; const index = getRandomIndex(count); const angle = 360 / count; const jitter = (Math.random() - 0.5) * angle * 0.6; const target = 360 - (index * angle + angle / 2) + jitter; const turns = 5 + getRandomIndex(3); pendingIndex.current = index; clearResult(); setSpinning(true); setRotation(current => current - (current % 360) + turns * 360 + ((target % 360) + 360) % 360); }; const handleSpinEnd = () => { const index = pendingIndex.current; if (index == null) return; pendingIndex.current = undefined; setSpinning(false); setPicked(wheelList[index]); // Padlý zdroj je jen mezikrok, oslavuje se až jídlo if (!sourceStage) setConfettiKey(key => key + 1); }; const removeOption = (item: WheelItem) => { if (spinning) return; setExcludedKeys(current => new Set(current).add(item.key)); if (picked?.key === item.key) clearResult(); }; const removeResultAndRespin = () => { if (!picked) return; removeOption(picked); setRespinRequested(true); }; // Po vyřazení se kolo překreslí s menším počtem výsečí — roztočí se až nad novými daty useEffect(() => { if (respinRequested) { setRespinRequested(false); spin(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [respinRequested]); const restoreAll = () => { const candidateKeys = new Set(candidateList.map(item => item.key)); setExcludedKeys(current => new Set([...current].filter(key => !candidateKeys.has(key)))); }; const pickedRecipe = picked?.group && hasRecipe(picked.group) ? picked.group : undefined; const recipeGroupList = (groupList ?? []).filter(hasRecipe); const shownRecipe = recipeShown ? recipeGroupList.find(group => getMealKey(group) === linkedKey) ?? pickedRecipe : undefined; const pickedSource = sourceStage ? picked : undefined; const renderWheel = () => { if (!groupList) return
; if (!wheelList.length) { return (
{candidateList.length ? 'Na kole nezbyla žádná možnost.' : pool === WheelPool.RECEPTY ? 'Takový recept zatím nemáte.' : 'Zatím nemáte zapsané žádné jídlo.'}
); } return item.label)} rotation={rotation} spinning={spinning} onSpinEnd={() => handleSpinEnd()} />; }; return ( {shownRecipe ? shownRecipe.name : 'Kolo štěstí: co dnes?'} {shownRecipe ? ( group.recipe)} onOpenRecipe={recipe => setLinkedKey(getMealKey(recipe))} /> ) : ( <>
{Object.values(WheelPool).map(value => ( ))} {pool === WheelPool.VSE && ( changeSource(event.target.value || undefined)} > {sourceList.map(item => )} )} {pool === WheelPool.RECEPTY && ( <> {[undefined, ...Object.values(RecipeFlavor)].map(value => ( ))} { setCategory(event.target.value); clearResult(); }} > {categoryList.map(item => )} )}
{renderWheel()}
{picked && ( <> {getResultCaption(sourceStage, onlyOne)}: {picked.label} {picked.group?.source && ({picked.group.source})} )}
{pickedSource && !spinning && ( )} {picked && !spinning && wheelList.length > 1 && ( )}
{onlyOne && ( Na kole musí být aspoň 2 možnosti — vraťte nějaké vyřazené, nebo berte tuto. )}
Na kole ({wheelList.length}) {excludedCount > 0 && ( )}
    {wheelList.map((item, index) => (
  • {item.label} {item.group?.recipe && } {item.note && {item.note}}
  • ))}
)}
{recipeShown && ( )} {!recipeShown && pickedRecipe && !spinning && ( )}
); }; export default SpinWheelModal;