import { useEffect, useState } from "react"; import { Modal, Button, Form, ListGroup, Alert, OverlayTrigger, Tooltip } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faTrashCan } from "@fortawesome/free-regular-svg-icons"; import { faArrowsRotate, faPen, faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons"; import { addStore, deleteStore, updateStore, getStoreMenuStatus, refreshStoreMenu, Store, StoreMenuProvider, StoreScrapeInfo, StoreScrapeStatus } from "../../../../types"; import { getHumanDateTime } from "../../Utils"; import PriceHistory from "../PriceHistory"; import { useAppConfig } from "../../hooks/useGroupLimits"; import { getStoreUrls, storeUrlLabel } from "../../utils/storeUrls"; type Props = { isOpen: boolean; onClose: () => void; stores: Store[]; onStoresChanged: (stores: Store[]) => void; }; /** Rozpracovaná editace obchodu — původní název slouží k jeho identifikaci na serveru. */ type EditState = { originalName: string; name: string; urls: string[]; }; const PROVIDER_LABELS: Record = { 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 : ['']; } export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly) { const [newName, setNewName] = useState(''); // Jeden podnik může být dostupný přes více dovozových služeb — proto seznam URL const [newUrls, setNewUrls] = useState(['']); const [edit, setEdit] = useState(null); const [heslo, setHeslo] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Server bez admin hesla — správa (přidání/úprava/odebrání/obnovení) nejde, zbývá jen přehled. // undefined = konfigurace nenačtena, chováme se jako dřív (správa zapnutá). const adminEnabled = useAppConfig().storeAdmin !== false; const [scrapeInfo, setScrapeInfo] = useState>({}); const [refreshing, setRefreshing] = useState(null); // Rozbalená historie cen („obchod|služba") const [historyOpen, setHistoryOpen] = useState(null); // Stav stahování nabídek (Bolt Food/Wolt) — při otevření a po změně seznamu obchodů useEffect(() => { if (!isOpen) return; getStoreMenuStatus().then(res => { setScrapeInfo(Object.fromEntries((res.data ?? []).map(i => [i.store, i]))); }).catch(() => { /* stav je jen informativní */ }); }, [isOpen, stores]); const handleRefreshMenu = async (name: string, provider: StoreMenuProvider) => { setError(null); setRefreshing(`${name}|${provider}`); try { const res = await refreshStoreMenu({ body: { name, provider, heslo } }); if (res.data) { setScrapeInfo(prev => ({ ...prev, [name]: res.data! })); } // Znovu načíst případně otevřenou historii (mohla přibýt nová verze) if (historyOpen === `${name}|${provider}`) { setHistoryOpen(null); setTimeout(() => setHistoryOpen(`${name}|${provider}`)); } } catch (e: any) { setError(e.message || 'Nastala chyba'); } finally { setRefreshing(null); } }; const addUrlRow = (urls: string[]) => [...urls, '']; const removeUrlRow = (urls: string[], index: number) => urls.length === 1 ? [''] : urls.filter((_, i) => i !== index); const setUrlAt = (urls: string[], index: number, value: string) => urls.map((u, i) => (i === index ? value : u)); /** Zpracuje odpověď API — vrací true při úspěchu. Chybu z API hlásí globální toaster. */ const applyResult = (res: { data?: unknown; error?: unknown }): boolean => { if (res.error) { return false; } if (res.data) { onStoresChanged(res.data as Store[]); return true; } return false; }; const handleAdd = async () => { if (!newName.trim()) return; setError(null); setLoading(true); try { const urls = newUrls.map(u => u.trim()).filter(Boolean); const res = await addStore({ body: { name: newName.trim(), urls: urls.length > 0 ? urls : undefined, heslo } }); if (applyResult(res)) { setNewName(''); setNewUrls(['']); } } catch (e: any) { setError(e.message || 'Nastala chyba'); } finally { setLoading(false); } }; const startEdit = (store: Store) => { setError(null); setEdit({ originalName: store.name, name: store.name, urls: toUrlInputs(store.urls ?? []) }); }; const handleSaveEdit = async () => { if (!edit || !edit.name.trim()) return; setError(null); setLoading(true); try { const res = await updateStore({ body: { name: edit.originalName, newName: edit.name.trim(), urls: edit.urls.map(u => u.trim()).filter(Boolean), heslo, }, }); if (applyResult(res)) { setEdit(null); } } catch (e: any) { setError(e.message || 'Nastala chyba'); } finally { setLoading(false); } }; const handleRemove = async (name: string) => { setError(null); setLoading(true); try { const res = await deleteStore({ body: { name, heslo } }); if (applyResult(res) && edit?.originalName === name) { setEdit(null); } } catch (e: any) { setError(e.message || 'Nastala chyba'); } finally { setLoading(false); } }; /** Řádky se vstupy pro URL — používá se pro přidání i pro editaci obchodu. */ const renderUrlInputs = (urls: string[], onChange: (urls: string[]) => void, onSubmit: () => void) => ( <> {urls.map((url, index) => (
onChange(setUrlAt(urls, index, e.target.value))} onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') onSubmit(); }} />
))} ); return (

Správa obchodů

{error && ( setError(null)} dismissible> {error} )} {!adminEnabled && ( Správa obchodů není na tomto serveru zapnutá (chybí ADMIN_PASSWORD). Seznam obchodů, stav nabídek a historii cen lze jen prohlížet. )} {adminEnabled && <> Admin heslo setHeslo(e.target.value)} onKeyDown={e => e.stopPropagation()} /> Potřeba pro přidání, úpravu a odebrání obchodu i pro obnovení nabídky ().
Přidat obchod
setNewName(e.target.value)} onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }} /> {renderUrlInputs(newUrls, setNewUrls, handleAdd)}
}
Aktuální seznam
{stores.length === 0 ? (

Žádné obchody v seznamu

) : ( {stores.map(s => { const urls = getStoreUrls(s); const isEditing = edit?.originalName === s.name; if (isEditing) { return ( setEdit({ ...edit, name: e.target.value })} onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveEdit(); }} /> {renderUrlInputs(edit.urls, next => setEdit({ ...edit, urls: next }), handleSaveEdit)}
); } return (
{s.name}
{urls.length > 0 && ( {urls.map(url => ( {storeUrlLabel(url)} ))} )} {scrapeInfo[s.name]?.providers.map(p => { const { text, className, title } = describeScrape(p); const key = `${s.name}|${p.provider}`; return (
Menu {PROVIDER_LABELS[p.provider]}: {text} {/* Zakázané tlačítko nezobrazí tooltip — obalení do span ho zobrazí i bez hesla */} {adminEnabled && {heslo ? `Znovu stáhnout nabídku z ${PROVIDER_LABELS[p.provider]}` : 'Pro obnovení nabídky zadejte nahoře admin heslo'} } > } {p.dishCount > 0 && ( )} {historyOpen === key && }
); })}
{adminEnabled &&
startEdit(s)} style={{ cursor: 'pointer' }} /> handleRemove(s.name)} style={{ cursor: 'pointer' }} />
}
); })}
)}
); }