--- import { client } from '../../db/typesense'; import RarityIcon from '../../components/RarityIcon.astro'; import FirstEditionIcon from "../../components/FirstEditionIcon.astro"; export const prerender = false; import * as util from 'util'; // all the facet fields we want to use for filtering const facetFields:any = { //"productLineName": "Product Line", "setName": "Set", "variant": "Variant", "rarityName": "Rarity", "cardType": "Card Type", "energyType": "Energy Type" } // ── Allowed sort values ─────────────────────────────────────────────────── const sortMap: Record = { 'releaseDate:desc,number:asc': '_text_match:asc,releaseDate:desc,number:asc', 'releaseDate:asc,number:asc': '_text_match:asc,releaseDate:asc,number:asc', 'marketPrice:desc': 'marketPrice:desc,releaseDate:desc,number:asc', 'marketPrice:asc': 'marketPrice:asc,releaseDate:desc,number:asc', 'number:asc': '_text_match:asc,number:asc', 'number:desc': '_text_match:asc,number:desc', }; const DEFAULT_SORT = '_text_match:asc,releaseDate:desc,number:asc'; // get the query from post request using form data const formData = await Astro.request.formData(); const query = formData.get('q')?.toString() || ''; const start = Number(formData.get('start')?.toString() || '0'); const sortKey = formData.get('sort')?.toString() || ''; const resolvedSort = sortMap[sortKey] ?? DEFAULT_SORT; // ── Language filter ─────────────────────────────────────────────────────── // Expects a `language` field on your card documents in Typesense. // Valid values: 'en', 'jp' — anything else (or 'all') means no filter. const language = formData.get('language')?.toString() || 'all'; const languageFilter = language === 'en' ? " && productLineName:=`Pokemon`" : language === 'jp' ? " && productLineName:=`Pokemon Japan`" : ''; // ── Query alias expansion ───────────────────────────────────────────────── // Intercepts known shorthand queries that can't be handled by Typesense // synonyms alone (e.g. terms that need to match across multiple set names) // and rewrites them into a direct filter, clearing the query so it doesn't // also try to text-match against card names. const ALIAS_FILTERS = [ // ── Era / set groupings ─────────────────────────────────────────────── { re: /^(e-?reader|e reader)$/i, field: 'setName', values: ['Expedition Base Set', 'Aquapolis', 'Skyridge', 'Battle-e'] }, { re: /^neo$/i, field: 'setName', values: ['Neo Genesis', 'Neo Discovery', 'Neo Revelation', 'Neo Destiny'] }, { re: /^(wotc|wizards)$/i, field: 'setName', values: ['Base Set', 'Jungle', 'Fossil', 'Base Set 2', 'Team Rocket', 'Gym Heroes', 'Gym Challenge', 'Neo Genesis', 'Neo Discovery', 'Neo Revelation', 'Neo Destiny', 'Expedition Base Set', 'Aquapolis', 'Skyridge', 'Battle-e'] }, { re: /^(sun\s*(&|and)\s*moon|s(&|and)m|sm)$/i, field: 'setName', values: ['Sun & Moon', 'Guardians Rising', 'Burning Shadows', 'Crimson Invasion', 'Ultra Prism', 'Forbidden Light', 'Celestial Storm', 'Dragon Majesty', 'Lost Thunder', 'Team Up', 'Unbroken Bonds', 'Unified Minds', 'Hidden Fates', 'Cosmic Eclipse', 'Detective Pikachu'] }, { re: /^(sword\s*(&|and)\s*shield|s(&|and)s|swsh)$/i, field: 'setName', values: ['Sword & Shield', 'Rebel Clash', 'Darkness Ablaze', 'Vivid Voltage', 'Battle Styles', 'Chilling Reign', 'Evolving Skies', 'Fusion Strike', 'Brilliant Stars', 'Astral Radiance', 'Pokemon GO', 'Lost Origin', 'Silver Tempest', 'Crown Zenith'] }, // ── Card type shorthands ────────────────────────────────────────────── { re: /^trainers?$/i, field: 'cardType', values: ['Trainer'] }, { re: /^supporters?$/i, field: 'cardType', values: ['Supporter'] }, { re: /^stadiums?$/i, field: 'cardType', values: ['Stadium'] }, { re: /^items?$/i, field: 'cardType', values: ['Item'] }, { re: /^(energys?|energies)$/i, field: 'cardType', values: ['Energy'] }, // ── Rarity shorthands ───────────────────────────────────────────────── { re: /^promos?$/i, field: 'rarityName', values: ['Promo'] }, ]; let resolvedQuery = query; let queryFilter = ''; for (const alias of ALIAS_FILTERS) { if (alias.re.test(query.trim())) { resolvedQuery = ''; queryFilter = `${alias.field}:=[${alias.values.map(s => '`' + s + '`').join(',')}]`; break; } } const filters = Array.from(formData.entries()) .filter(([key]) => key !== 'q' && key !== 'start' && key !== 'sort' && key !== 'language') .reduce((acc, [key, value]) => { if (!acc[key]) { acc[key] = []; } acc[key].push(value.toString()); return acc; }, {} as Record); const filterChecked = (field: string, value: string) => { return (filters[field]?.includes(value) ?? false) ? 'checked' : ''; }; const filterBy = Object.entries(filters).map(([field, values]) => { return `${field}:=[${values.map(v => '`'+v+'`').join(',')}]`; }).join(' && '); const facetFilter = (facet:string) => { const otherFilters = Object.entries(filters) .filter(([field]) => field !== facet) .map(([field, values]) => `${field}:=[${values.map(v => '`'+v+'`').join(',')}]`) .join(' && '); // Language filter is always included so facet counts stay accurate return `sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${otherFilters ? ` && ${otherFilters}` : ''}`; }; // primary search values (for cards) let searchArray = [{ collection: 'cards', filter_by: `sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${filterBy ? ` && ${filterBy}` : ''}`, per_page: 20, facet_by: '', max_facet_values: 0, page: Math.floor(start / 20) + 1, sort_by: resolvedSort, include_fields: '$skus(*)', }]; // on first load (start === 0) we want to get the facets for the filters if (start === 0) { for (const facet of Object.keys(facetFields)) { searchArray.push({ collection: 'cards', filter_by: facetFilter(facet), per_page: 0, facet_by: facet, max_facet_values: 500, page: 1, sort_by: '', include_fields: '', }); } } const searchRequests = { searches: searchArray }; const commonSearchParams = { q: resolvedQuery, query_by: 'content,setName,setCode,productName', query_by_weights: '10,6,8,9', num_typos: '2,1,0,1', prefix: 'true,true,false,false', }; // use typesense to search for cards matching the query and return the productIds of the results const searchResults = await client.multiSearch.perform(searchRequests, commonSearchParams); const cardResults = searchResults.results[0] as any; const pokemon = cardResults.hits?.map((hit: any) => hit.document) ?? []; const totalHits = cardResults?.found; // format price to 2 decimal places (or 0 if price >=100) and adds a $ sign, if the price is null it returns "–" const formatPrice = (condition:string, skus: any) => { const sku:any = skus.find((price:any) => price.condition === condition); if (typeof sku === 'undefined' || typeof sku.marketPrice === 'undefined') return '—'; const price = Number(sku.marketPrice) / 100.0; if (price > 99.99) return `$${Math.round(price)}`; return `$${price.toFixed(2)}`; }; const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"]; const conditionShort = (condition:string) => { return { "Near Mint": "NM", "Lightly Played": "LP", "Moderately Played": "MP", "Heavily Played": "HP", "Damaged": "DMG" }[condition] || condition.split(' ').map((w) => w[0]).join(''); } const facetNames = (name:string) => { return facetFields[name] || name; } const facets = searchResults.results.slice(1).map((result: any) => { const facet = result.facet_counts?.[0]; if (!facet) return null; // Sort: checked items first, then alphabetically facet.counts = facet.counts.sort((a: any, b: any) => { const aChecked = filters[facet.field_name]?.includes(a.value) ?? false; const bChecked = filters[facet.field_name]?.includes(b.value) ?? false; if (aChecked && !bChecked) return -1; if (!aChecked && bChecked) return 1; return a.value.localeCompare(b.value); }); return facet; }).filter(Boolean); --- {(start === 0) &&
{facets.map((facet) => (
{facetNames(facet.field_name)}
{(facet.counts.length > 20) && }
{facet.counts.map((count:any) => (
))}
))}
{sortKey ? ({"releaseDate:desc,number:asc":"Set: Newest to Oldest","releaseDate:asc,number:asc":"Set: Oldest to Newest","marketPrice:desc":"Price: High to Low","marketPrice:asc":"Price: Low to High","number:asc":"Card Number: Ascending","number:desc":"Card Number: Descending"}[sortKey] ?? '') : ''}
{totalHits} {totalHits === 1 ? ' result' : ' results'}
{(Object.entries(filters).length > 0) && Filtered by:
    {Object.entries(filters).map(([filter, values]) => ( values.map((value) => (
  • {value}
  • )) ))}
}
} {pokemon.length === 0 && (
No cards found! Please modify your search and try again.
)} {pokemon.map((card:any) => (
+/-
{card.productName}
{conditionOrder.map((condition) => (
{ conditionShort(condition) }
{formatPrice(condition, card.skus)}
))}
{card.productName}
{card.setName}
{card.number}
{card.variant}
{card.productId}
))} {start + 20 < totalHits &&
Loading...
}