diff --git a/docs/superpowers/specs/2026-07-14-tcg-promo-cards-design.md b/docs/superpowers/specs/2026-07-14-tcg-promo-cards-design.md new file mode 100644 index 0000000..f655da4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-tcg-promo-cards-design.md @@ -0,0 +1,26 @@ +# TCG promo card visibility + +## Goal + +Make Pokémon TCG promo cards discoverable in both the cards shown on Pokémon detail pages and the main TCG catalog. Promo cards must remain normal catalog cards, with the existing `Promo` rarity filter and `wPromo` variant semantics preserved. + +## Approach + +Fix the shared web TCG API layer rather than adding display-specific logic: + +- paginate Pokémon-name searches until TCGdex has no further results, so promo cards are not lost after the first 100 results; +- keep promo-capable cards when normalizing card results and expose `Promo` in set rarity options when the set contains a promo variant; +- keep catalog filtering local and canonicalized through the existing rarity helpers; +- mirror the API behavior in `packages/core` so web and mobile share the same data contract. + +## Data flow + +`PokemonCards` calls `getPokemonCards`, which will fetch every matching Pokémon card page, normalize the cards, and hydrate their details. `TCGResearchDesk` continues to call `searchCards`; selecting a promo-capable set or the `Promo` rarity will use the same normalized card data and local rarity matcher. + +## Testing + +Add pure regression coverage for pagination termination and promo rarity matching. Keep the tests independent of the live TCGdex service; network behavior is represented by deterministic page fixtures. + +## Error handling and compatibility + +Existing cache keys will be versioned so stale truncated results are not reused. Existing abort, retry, language fallback, and empty-result behavior remain unchanged. No component-level filtering or new dependency is required. diff --git a/packages/core/src/api/tcg.test.ts b/packages/core/src/api/tcg.test.ts new file mode 100644 index 0000000..d32f091 --- /dev/null +++ b/packages/core/src/api/tcg.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGet = vi.hoisted(() => vi.fn()); + +vi.mock('axios', () => ({ + default: { + create: vi.fn(() => ({ get: mockGet })), + }, +})); + +vi.mock('axios-retry', () => ({ + default: vi.fn(), +})); + +import { getPokemonCards, matchesTcgRarityFilter, mergeTcgCardPages } from './tcg'; +import type { TCGCard } from '../types/tcg'; + +function makeCard(id: string): TCGCard { + return { + id, + localId: id, + name: 'Pikachu', + image: `https://assets.example.test/${id}`, + category: 'Pokemon', + rarity: 'Promo', + stage: 'Basic', + types: ['Lightning'], + }; +} + +describe('TCG card pagination', () => { + beforeEach(() => { + mockGet.mockReset(); + }); + + it('merges pages without losing cards or duplicating overlapping results', () => { + const firstPage = [makeCard('promo-001'), makeCard('promo-002')]; + const secondPage = [makeCard('promo-002'), makeCard('promo-003')]; + + expect(mergeTcgCardPages([firstPage, secondPage]).map((card) => card.id)).toEqual([ + 'promo-001', + 'promo-002', + 'promo-003', + ]); + }); + + it('loads Pokémon cards from every remote page', async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => makeCard(`promo-${index + 1}`)); + const secondPage = [makeCard('promo-101'), makeCard('promo-102')]; + + mockGet.mockImplementation(async (url: string) => { + if (url.includes('/cards?')) { + const page = new URL(`https://api.example.test${url}`).searchParams.get('pagination:page'); + return { data: page === '2' ? secondPage : firstPage }; + } + + const id = url.split('/').pop() ?? 'unknown'; + return { data: makeCard(id) }; + }); + + const cards = await getPokemonCards('Pikachu'); + const searchCalls = mockGet.mock.calls.filter(([url]) => String(url).includes('/cards?')); + + expect(searchCalls).toHaveLength(2); + expect(cards).toHaveLength(102); + expect(cards.at(-1)?.id).toBe('promo-102'); + }); +}); + +describe('TCG promo rarity', () => { + it('matches both the explicit Promo rarity and the promo variant', () => { + expect(matchesTcgRarityFilter(makeCard('promo-001'), 'Promo')).toBe(true); + expect(matchesTcgRarityFilter({ ...makeCard('promo-002'), rarity: 'Rare', variants: { firstEdition: false, holo: false, normal: true, reverse: false, wPromo: true } }, 'Promo')).toBe(true); + expect(matchesTcgRarityFilter({ ...makeCard('rare-001'), rarity: 'Rare', variants: { firstEdition: false, holo: false, normal: true, reverse: false, wPromo: false } }, 'Promo')).toBe(false); + }); +}); diff --git a/packages/core/src/api/tcg.ts b/packages/core/src/api/tcg.ts index c2c18eb..a6b5f93 100644 --- a/packages/core/src/api/tcg.ts +++ b/packages/core/src/api/tcg.ts @@ -62,6 +62,7 @@ export const TCG_GLOBAL_RARITIES = [ ]; const VISUAL_METADATA_CONCURRENCY = 8; +const POKEMON_CARD_PAGE_SIZE = 100; export const DEFAULT_TCG_CARD_FILTERS: TCGCardFilters = { selectedCategory: 'all', @@ -254,6 +255,59 @@ function matchesRarityFilter(card: TCGCard, selectedRarity: string): boolean { return cardRarity === rarityKey; } +export function matchesTcgRarityFilter(card: TCGCard, selectedRarity: string): boolean { + return matchesRarityFilter(card, selectedRarity); +} + +export function mergeTcgCardPages(pages: TCGCard[][]): TCGCard[] { + const seenIds = new Set(); + const cards: TCGCard[] = []; + + for (const page of pages) { + for (const card of page) { + if (!card.id || seenIds.has(card.id)) continue; + seenIds.add(card.id); + cards.push(card); + } + } + + return cards; +} + +async function fetchAllCardSearchPages( + filters: TCGCardFilters, + lang: string, + pageSize = POKEMON_CARD_PAGE_SIZE, +): Promise { + const pages: TCGCard[][] = []; + let page = 1; + let previousPageSignature = ''; + + while (true) { + // TCGdex documents a default page size of 100. Request exactly that + // amount and continue when a full page is returned, including when the + // service caps a larger requested page size. + const query = buildCardQueryParams(filters, page, pageSize - 1).toString(); + const { data } = await tcgClient.get(`/${lang}/cards?${query}`); + const pageCards = Array.isArray(data) ? data.map((card) => normaliseCard(card)) : []; + const pageSignature = pageCards.map((card) => card.id).join('|'); + + if (pageSignature && pageSignature === previousPageSignature) { + break; + } + + pages.push(pageCards); + if (pageCards.length < pageSize) { + break; + } + + previousPageSignature = pageSignature; + page += 1; + } + + return mergeTcgCardPages(pages); +} + function matchesTrainerType(card: TCGCard, selectedTypes: string[]): boolean { if (selectedTypes.length === 0) return true; const trainerType = normalizeFilterValue(card.trainerType ?? ''); @@ -708,7 +762,7 @@ export const searchCards = async ( if (!hasLocalOnlyFilters && !requiresLocalSorting && !requiresFullDatasetSort) { if (fetchAll) { - const ALL_PAGE_SIZE = 250; + const ALL_PAGE_SIZE = 100; const allCards: TCGCard[] = []; let remotePage = 1; let hasMoreRemote = true; @@ -893,42 +947,24 @@ export const getPokemonCards = async ( englishName?: string, ): Promise => { const tcgLang = resolveTcgLang(lang); - const cacheKey = `tcg-pokemon-cards-v6-${tcgLang}-${pokemonName}`; + const cacheKey = `tcg-pokemon-cards-v7-${tcgLang}-${pokemonName}`; try { const cached = await getCachedData(cacheKey); if (cached) return cached; - const { data } = await tcgClient.get( - `/${tcgLang}/cards?${buildCardQueryParams( - { - selectedCategory: 'Pokemon', - searchTerm: pokemonName, - sortBy: 'name', - sortOrder: 'asc', - }, - 1, - 100, - ).toString()}`, - ); + const searchFilters: TCGCardFilters = { + selectedCategory: 'Pokemon', + searchTerm: pokemonName, + sortBy: 'name', + sortOrder: 'asc', + }; - let cards = Array.isArray(data) ? data.filter((card) => card.image).map((card) => normaliseCard(card)) : []; + let cards = (await fetchAllCardSearchPages(searchFilters, tcgLang)).filter((card) => card.image); if (cards.length === 0 && tcgLang !== 'en' && englishName) { - const { data: fallbackData } = await tcgClient.get( - `/en/cards?${buildCardQueryParams( - { - selectedCategory: 'Pokemon', - searchTerm: englishName, - sortBy: 'name', - sortOrder: 'asc', - }, - 1, - 100, - ).toString()}`, - ); - - cards = Array.isArray(fallbackData) ? fallbackData.filter((card) => card.image).map((card) => normaliseCard(card)) : []; + cards = (await fetchAllCardSearchPages({ ...searchFilters, searchTerm: englishName }, 'en')) + .filter((card) => card.image); } const enriched = await Promise.all( diff --git a/src/components/tcg/TCGFilters.tsx b/src/components/tcg/TCGFilters.tsx index 4578db4..e4e9f01 100644 --- a/src/components/tcg/TCGFilters.tsx +++ b/src/components/tcg/TCGFilters.tsx @@ -194,7 +194,11 @@ export function TCGFilters({ }, [buildUniqueRarityOptions, filterOptions?.rarities]); const rarityOptions = useMemo(() => { - const source = selectedSet ? setRarities : allRarityOptions; + // Promo is represented by the card's `wPromo` variant and is not present + // in TCGdex's CardBrief set listing. Keep it available when a set is + // selected so promo cards can be discovered even when the set rarity + // sampler only returns regular rarities. + const source = selectedSet ? [...setRarities, 'Promo'] : allRarityOptions; return buildUniqueRarityOptions(source).sort((a, b) => a.localeCompare(b)); }, [allRarityOptions, buildUniqueRarityOptions, selectedSet, setRarities]); diff --git a/src/lib/api/tcg.ts b/src/lib/api/tcg.ts index b020b7d..1d3abe1 100644 --- a/src/lib/api/tcg.ts +++ b/src/lib/api/tcg.ts @@ -64,6 +64,7 @@ export const TCG_GLOBAL_RARITIES = [ ]; const VISUAL_METADATA_CONCURRENCY = 8; +const POKEMON_CARD_PAGE_SIZE = 100; export const DEFAULT_TCG_CARD_FILTERS: TCGCardFilters = { selectedCategory: 'all', @@ -259,6 +260,59 @@ function matchesRarityFilter(card: TCGCard, selectedRarity: string): boolean { return cardRarity === rarityKey; } +export function matchesTcgRarityFilter(card: TCGCard, selectedRarity: string): boolean { + return matchesRarityFilter(card, selectedRarity); +} + +export function mergeTcgCardPages(pages: TCGCard[][]): TCGCard[] { + const seenIds = new Set(); + const cards: TCGCard[] = []; + + for (const page of pages) { + for (const card of page) { + if (!card.id || seenIds.has(card.id)) continue; + seenIds.add(card.id); + cards.push(card); + } + } + + return cards; +} + +async function fetchAllCardSearchPages( + filters: TCGCardFilters, + lang: string, + pageSize = POKEMON_CARD_PAGE_SIZE, +): Promise { + const pages: TCGCard[][] = []; + let page = 1; + let previousPageSignature = ''; + + while (true) { + // TCGdex documents a default page size of 100. Request exactly that + // amount and continue when a full page is returned, including when the + // service caps a larger requested page size. + const query = buildCardQueryParams(filters, page, pageSize - 1).toString(); + const { data } = await tcgClient.get(`/${lang}/cards?${query}`); + const pageCards = Array.isArray(data) ? data.map((card) => normaliseCard(card)) : []; + const pageSignature = pageCards.map((card) => card.id).join('|'); + + if (pageSignature && pageSignature === previousPageSignature) { + break; + } + + pages.push(pageCards); + if (pageCards.length < pageSize) { + break; + } + + previousPageSignature = pageSignature; + page += 1; + } + + return mergeTcgCardPages(pages); +} + function matchesTrainerType(card: TCGCard, selectedTypes: string[]): boolean { if (selectedTypes.length === 0) return true; const trainerType = normalizeFilterValue(card.trainerType ?? ''); @@ -763,7 +817,7 @@ export const searchCards = async ( if (!hasLocalOnlyFilters && !requiresLocalSorting && !requiresFullDatasetSort) { if (fetchAll) { - const ALL_PAGE_SIZE = 250; + const ALL_PAGE_SIZE = 100; const allCards: TCGCard[] = []; let remotePage = 1; let hasMoreRemote = true; @@ -948,42 +1002,24 @@ export const getPokemonCards = async ( englishName?: string, ): Promise => { const tcgLang = resolveTcgLang(lang); - const cacheKey = `tcg-pokemon-cards-v6-${tcgLang}-${pokemonName}`; + const cacheKey = `tcg-pokemon-cards-v7-${tcgLang}-${pokemonName}`; try { const cached = await getCachedData(cacheKey); if (cached) return cached; - const { data } = await tcgClient.get( - `/${tcgLang}/cards?${buildCardQueryParams( - { - selectedCategory: 'Pokemon', - searchTerm: pokemonName, - sortBy: 'name', - sortOrder: 'asc', - }, - 1, - 100, - ).toString()}`, - ); + const searchFilters: TCGCardFilters = { + selectedCategory: 'Pokemon', + searchTerm: pokemonName, + sortBy: 'name', + sortOrder: 'asc', + }; - let cards = Array.isArray(data) ? data.filter((card) => card.image).map((card) => normaliseCard(card)) : []; + let cards = (await fetchAllCardSearchPages(searchFilters, tcgLang)).filter((card) => card.image); if (cards.length === 0 && tcgLang !== 'en' && englishName) { - const { data: fallbackData } = await tcgClient.get( - `/en/cards?${buildCardQueryParams( - { - selectedCategory: 'Pokemon', - searchTerm: englishName, - sortBy: 'name', - sortOrder: 'asc', - }, - 1, - 100, - ).toString()}`, - ); - - cards = Array.isArray(fallbackData) ? fallbackData.filter((card) => card.image).map((card) => normaliseCard(card)) : []; + cards = (await fetchAllCardSearchPages({ ...searchFilters, searchTerm: englishName }, 'en')) + .filter((card) => card.image); } const enriched = await Promise.all(