Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/superpowers/specs/2026-07-14-tcg-promo-cards-design.md
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 76 additions & 0 deletions packages/core/src/api/tcg.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
94 changes: 65 additions & 29 deletions packages/core/src/api/tcg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<string>();
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<TCGCard[]> {
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<TCGCard[]>(`/${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 ?? '');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -893,42 +947,24 @@ export const getPokemonCards = async (
englishName?: string,
): Promise<TCGCard[]> => {
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<TCGCard[]>(cacheKey);
if (cached) return cached;

const { data } = await tcgClient.get<TCGCard[]>(
`/${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<TCGCard[]>(
`/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(
Expand Down
6 changes: 5 additions & 1 deletion src/components/tcg/TCGFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
94 changes: 65 additions & 29 deletions src/lib/api/tcg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<string>();
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<TCGCard[]> {
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<TCGCard[]>(`/${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 ?? '');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -948,42 +1002,24 @@ export const getPokemonCards = async (
englishName?: string,
): Promise<TCGCard[]> => {
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<TCGCard[]>(cacheKey);
if (cached) return cached;

const { data } = await tcgClient.get<TCGCard[]>(
`/${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<TCGCard[]>(
`/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(
Expand Down
Loading