diff --git a/client/src/components/menu/PreconDeckModal.tsx b/client/src/components/menu/PreconDeckModal.tsx index e9d9dccae3..988778fc46 100644 --- a/client/src/components/menu/PreconDeckModal.tsx +++ b/client/src/components/menu/PreconDeckModal.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { isCommanderPreconDeck, useDecks, type DeckEntry } from "../../hooks/useDecks"; import { preconExists, savePreconDeck } from "../../services/preconDecks"; import { menuButtonClass } from "./buttonStyles"; -import { SelectField } from "../ui/SelectField"; +import { MenuSelect } from "../ui/MenuSelect"; interface PreconDeckModalProps { open: boolean; @@ -41,7 +41,7 @@ function coverageTone(pct: number): string { export function PreconDeckModal({ open, onClose, onImported }: PreconDeckModalProps) { const { t } = useTranslation("menu"); - const decks = useDecks(); + const { decks, status } = useDecks(); const [query, setQuery] = useState(""); const [typeFilter, setTypeFilter] = useState(ALL_TYPES); // Multi-select state. Keyed by deck id (filename stem) so it survives the @@ -91,6 +91,30 @@ export function PreconDeckModal({ open, onClose, onImported }: PreconDeckModalPr .slice(0, MAX_RESULTS); }, [decks, query, typeFilter]); + const typeOptions = useMemo( + () => + Array.from(typeCounts.entries()) + .filter(([, n]) => n > 0) + .sort((a, b) => a[0].localeCompare(b[0])), + [typeCounts], + ); + + const typeFilterItems = useMemo( + () => [ + { value: ALL_TYPES, label: t("precon.allTypes", { count: totalMatches }) }, + ...typeOptions.map(([type, n]) => ({ value: type, label: `${type} (${n})` })), + ], + [t, totalMatches, typeOptions], + ); + + const typeFilterLabel = useMemo(() => { + if (typeFilter === ALL_TYPES) { + return t("precon.allTypes", { count: totalMatches }); + } + const match = typeOptions.find(([type]) => type === typeFilter); + return match ? `${match[0]} (${match[1]})` : typeFilter; + }, [typeFilter, totalMatches, typeOptions, t]); + if (!open) return null; const handlePick = (deck: DeckEntry) => { @@ -163,10 +187,6 @@ export function PreconDeckModal({ open, onClose, onImported }: PreconDeckModalPr } }; - const typeOptions = Array.from(typeCounts.entries()) - .filter(([, n]) => n > 0) - .sort((a, b) => a[0].localeCompare(b[0])); - return (
- setTypeFilter(e.target.value)} - className="rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none" - > - - {typeOptions.map(([type, n]) => ( - - ))} - +
- {!decks ? ( + {status === "loading" ? (
{t("precon.loadingCatalog")}
+ ) : status === "error" ? ( +
{t("precon.loadFailed")}
) : filtered.length === 0 ? (
{t("precon.noMatch")}
) : ( diff --git a/client/src/components/menu/__tests__/MyDecks.test.tsx b/client/src/components/menu/__tests__/MyDecks.test.tsx index 11583766d1..c67869ee24 100644 --- a/client/src/components/menu/__tests__/MyDecks.test.tsx +++ b/client/src/components/menu/__tests__/MyDecks.test.tsx @@ -31,7 +31,7 @@ vi.mock("../../../services/deckCompatibility", () => ({ vi.mock("../../../hooks/useDecks", () => ({ loadPreconDeckMap: vi.fn(), isCommanderPreconDeck: (deck: { type: string }) => deck.type === "Commander Deck", - useDecks: vi.fn(() => null), + useDecks: vi.fn(() => ({ decks: null, status: "loading" as const })), })); function saveDeck(name: string, deck: ParsedDeck): void { diff --git a/client/src/hooks/useDecks.ts b/client/src/hooks/useDecks.ts index b68a0e73a2..ba1e36c5dc 100644 --- a/client/src/hooks/useDecks.ts +++ b/client/src/hooks/useDecks.ts @@ -43,20 +43,36 @@ export function loadPreconDeckMap(): Promise { return fetchPromise; } +export type UseDecksStatus = "loading" | "success" | "error"; + +export interface UseDecksResult { + /** Catalog entries keyed by MTGJSON filename stem; `null` until the first fetch settles. */ + decks: DeckMap | null; + status: UseDecksStatus; +} + /** * Returns the preconstructed deck catalog keyed by deck id (MTGJSON filename * stem, e.g. `RedDeckB_10E`). Includes every deck above MIN_DECK_CARDS — each * entry carries a `coveragePct`, so consumers (e.g. the precon picker) can * apply their own coverage-floor filter rather than dropping decks at build - * time. `null` while loading or on fetch failure. + * time. */ -export function useDecks(): DeckMap | null { +export function useDecks(): UseDecksResult { const [decks, setDecks] = useState(cached); + const [status, setStatus] = useState(cached ? "success" : "loading"); useEffect(() => { if (cached) return; - loadPreconDeckMap().then((d) => { if (d) setDecks(d); }); + loadPreconDeckMap().then((d) => { + if (d) { + setDecks(d); + setStatus("success"); + return; + } + setStatus("error"); + }); }, []); - return decks; + return { decks, status }; } diff --git a/client/src/i18n/locales/de/menu.json b/client/src/i18n/locales/de/menu.json index d35f43723e..39c77e68f3 100644 --- a/client/src/i18n/locales/de/menu.json +++ b/client/src/i18n/locales/de/menu.json @@ -173,7 +173,9 @@ "totalCount": "· {{count}} insgesamt", "searchPlaceholder": "Nach Name, Set-Code oder Typ suchen…", "allTypes": "Alle ({{count}})", + "typeFilter": "Decktyp", "loadingCatalog": "Deckkatalog wird geladen…", + "loadFailed": "Der Katalog vorkonstruierter Decks konnte nicht geladen werden. Verbindung prüfen und erneut versuchen.", "noMatch": "Keine Decks passen.", "selectDeck": "{{name}} auswählen", "cardCount_one": "{{count}} Karte", diff --git a/client/src/i18n/locales/en/menu.json b/client/src/i18n/locales/en/menu.json index 4426222661..5f114ca791 100644 --- a/client/src/i18n/locales/en/menu.json +++ b/client/src/i18n/locales/en/menu.json @@ -173,7 +173,9 @@ "totalCount": "· {{count}} total", "searchPlaceholder": "Search by name, set code, or type…", "allTypes": "All ({{count}})", + "typeFilter": "Deck type", "loadingCatalog": "Loading deck catalog…", + "loadFailed": "Could not load the preconstructed deck catalog. Check your connection and try again.", "noMatch": "No decks match.", "selectDeck": "Select {{name}}", "cardCount_one": "{{count}} card", diff --git a/client/src/i18n/locales/es/menu.json b/client/src/i18n/locales/es/menu.json index ddc620cf5d..ded03357dd 100644 --- a/client/src/i18n/locales/es/menu.json +++ b/client/src/i18n/locales/es/menu.json @@ -173,7 +173,9 @@ "totalCount": "· {{count}} en total", "searchPlaceholder": "Buscar por nombre, código de colección o tipo…", "allTypes": "Todos ({{count}})", + "typeFilter": "Tipo de mazo", "loadingCatalog": "Cargando catálogo de mazos…", + "loadFailed": "No se pudo cargar el catálogo de mazos preconstruidos. Comprueba tu conexión e inténtalo de nuevo.", "noMatch": "Ningún mazo coincide.", "selectDeck": "Seleccionar {{name}}", "cardCount_one": "{{count}} carta", diff --git a/client/src/i18n/locales/fr/menu.json b/client/src/i18n/locales/fr/menu.json index 41f70eaf02..323f62534d 100644 --- a/client/src/i18n/locales/fr/menu.json +++ b/client/src/i18n/locales/fr/menu.json @@ -173,7 +173,9 @@ "totalCount": "· {{count}} au total", "searchPlaceholder": "Rechercher par nom, code d'extension ou type…", "allTypes": "Tous ({{count}})", + "typeFilter": "Type de deck", "loadingCatalog": "Chargement du catalogue de decks…", + "loadFailed": "Impossible de charger le catalogue de decks préconstruits. Vérifiez votre connexion et réessayez.", "noMatch": "Aucun deck ne correspond.", "selectDeck": "Sélectionner {{name}}", "cardCount_one": "{{count}} carte", diff --git a/client/src/i18n/locales/it/menu.json b/client/src/i18n/locales/it/menu.json index 90dbba4709..a9f1f3957b 100644 --- a/client/src/i18n/locales/it/menu.json +++ b/client/src/i18n/locales/it/menu.json @@ -173,7 +173,9 @@ "totalCount": "· {{count}} totali", "searchPlaceholder": "Cerca per nome, codice set o tipo…", "allTypes": "Tutti ({{count}})", + "typeFilter": "Tipo di mazzo", "loadingCatalog": "Caricamento catalogo mazzi…", + "loadFailed": "Impossibile caricare il catalogo dei mazzi precostruiti. Controlla la connessione e riprova.", "noMatch": "Nessun mazzo corrisponde.", "selectDeck": "Seleziona {{name}}", "cardCount_one": "{{count}} carta", diff --git a/client/src/i18n/locales/pl/menu.json b/client/src/i18n/locales/pl/menu.json index 0645ff6fc7..beea5aed6c 100644 --- a/client/src/i18n/locales/pl/menu.json +++ b/client/src/i18n/locales/pl/menu.json @@ -173,7 +173,9 @@ "totalCount": "· {{count}} łącznie", "searchPlaceholder": "Szukaj według nazwy, kodu dodatku lub typu…", "allTypes": "Wszystkie ({{count}})", + "typeFilter": "Typ talii", "loadingCatalog": "Wczytywanie katalogu talii…", + "loadFailed": "Nie udało się wczytać katalogu gotowych talii. Sprawdź połączenie i spróbuj ponownie.", "noMatch": "Brak pasujących talii.", "selectDeck": "Wybierz {{name}}", "cardCount_one": "{{count}} karta", diff --git a/client/src/i18n/locales/pt/menu.json b/client/src/i18n/locales/pt/menu.json index 8f742296b8..7c66653595 100644 --- a/client/src/i18n/locales/pt/menu.json +++ b/client/src/i18n/locales/pt/menu.json @@ -173,7 +173,9 @@ "totalCount": "· {{count}} no total", "searchPlaceholder": "Buscar por nome, código de coleção ou tipo…", "allTypes": "Todos ({{count}})", + "typeFilter": "Tipo de deck", "loadingCatalog": "Carregando catálogo de decks…", + "loadFailed": "Não foi possível carregar o catálogo de decks pré-construídos. Verifique sua conexão e tente novamente.", "noMatch": "Nenhum deck corresponde.", "selectDeck": "Selecionar {{name}}", "cardCount_one": "{{count}} carta", diff --git a/client/src/pages/__tests__/GameSetupPage.test.tsx b/client/src/pages/__tests__/GameSetupPage.test.tsx index 1f9932428e..010e198969 100644 --- a/client/src/pages/__tests__/GameSetupPage.test.tsx +++ b/client/src/pages/__tests__/GameSetupPage.test.tsx @@ -83,7 +83,7 @@ vi.mock("../../hooks/useDecks", async () => { ); return { ...actual, - useDecks: () => null, + useDecks: () => ({ decks: null, status: "success" as const }), }; });