39 Commits

Author SHA1 Message Date
Zach Harding
85cfd1de64 added the ability to search by artist name 2026-04-07 08:07:47 -04:00
Zach Harding
c03a0b36a0 removed ads, fixed copy image for newer iOS 2026-04-05 13:11:46 -04:00
Zach Harding
5cdf9b1772 ads placement - tbd 2026-04-05 10:17:43 -04:00
Zach Harding
17465b13c1 adsense verification, remove latest sales table, add new search mechanic (weight, synonyms), fix low volatility (NaN%) 2026-04-01 17:43:47 -04:00
Zach Harding
c61cafecdc added /admin for admin panel - limited to users in the admin role (also updated local .env to match prod keys for clerk) 2026-03-28 16:52:53 -04:00
Zach Harding
2b3d5f322e Merge branch 'master' of papi.tkpups.com:tmiller/pokemon 2026-03-25 14:32:43 -04:00
Zach Harding
53cdddb183 correct sort by market price by adding a back a roll-up market price from skus to product ID during sync 2026-03-25 14:31:46 -04:00
35c8bf25f5 [bugfix] update static path on scripts 2026-03-25 13:42:19 -04:00
3f9b1accda [chore] employ static assets directory 2026-03-25 05:34:11 -04:00
03e606e152 [bugfix] missed async await in tcgplayer preload 2026-03-23 21:24:24 -04:00
b871385fba [bugfix] don't close db connection pool in upload api script 2026-03-21 20:52:39 -04:00
4c6922f76b [feat] testing tcgcollector upload 2026-03-21 16:40:04 -04:00
171ce294f4 [chore] refactor common functions into helper script 2026-03-19 22:18:24 -04:00
Zach Harding
023cd87319 fixed backToTop z-index when scrolling on mobile 2026-03-18 20:36:33 -04:00
Zach Harding
04ea65eeeb hotfix for image-grow class 2026-03-18 14:53:10 -04:00
Zach Harding
9d9524e654 Merge branch 'feat/csv-prices' of papi.tkpups.com:tmiller/pokemon 2026-03-18 13:45:57 -04:00
c0120e3e77 [feat] read tcgcollector csv 2026-03-18 13:39:39 -04:00
Zach Harding
bc99be51ea setup (but did not apply) holofoil styling and added new seticon for perfect order set 2026-03-18 13:31:56 -04:00
660da7cded read/write CSV, prices from db 2026-03-18 13:26:42 -04:00
2a17654c74 [chore] sales history schema 2026-03-18 11:14:19 -04:00
Zach Harding
b06e24d382 added data attributes to be used later for holofoil styling and added ability to search by e-reader 2026-03-17 17:27:39 -04:00
Zach Harding
7b4e06733f added a button group for quick filtering by productLine 2026-03-17 11:27:16 -04:00
Zach Harding
f72d479c1d rearranged sort and filter on mobile into a flex col instead of row 2026-03-17 10:35:02 -04:00
zach
ee9f7a2561 added the mechanism for sort by, added total results and made it all look nice in one row 2026-03-16 14:39:55 -04:00
zach
2f17912949 reqrote volatility with proper standard deviation and added tooltip 2026-03-16 14:07:37 -04:00
a86dc08b50 [bugfix] fixing schema messed up by something adding tabs 2026-03-16 13:54:50 -04:00
zach
c4ebbfb060 modified layout and made it so you can switch between card modals and keep the pricing chart 2026-03-16 11:05:10 -04:00
zach
9c81a13c69 created price-history.ts to get history data and added to modal via chart.js 2026-03-16 08:39:06 -04:00
3a6dbf2ed9 [chore] preload all price history 2026-03-14 23:50:14 -04:00
e1ab59a2eb [feat] price history 2026-03-12 22:31:29 -04:00
zach
a8df9c71ee Merge branch 'master' of papi.tkpups.com:tmiller/pokemon 2026-03-12 13:41:02 -04:00
zach
835a174da2 refactored 404 page, fixed copy image toast on mobile and filtered missing images to exclude sealed 2026-03-12 13:40:12 -04:00
485f26de7b [chore] refactor indexing scripts 2026-03-12 08:18:40 -04:00
c10e34cc34 [feat] move missing image script out of test scripts so it's picked up by git 2026-03-11 23:09:35 -04:00
d9995e5e10 [bugfix] escape facet filters so special characters like parentheses work 2026-03-11 20:33:43 -04:00
c622c8bd8f Merge branch 'feat/postgresql' 2026-03-11 19:26:52 -04:00
f03c909745 [chore] schema for price history 2026-03-11 19:19:47 -04:00
a68ed7f7b8 [feat] switched from mysql to postgresql 2026-03-11 19:18:45 -04:00
1089bcdc20 [chore] schema for price history 2026-03-09 15:44:06 -04:00
65 changed files with 6384 additions and 1692 deletions

3
.gitignore vendored
View File

@@ -26,6 +26,9 @@ pnpm-debug.log*
# imges from tcgplayer
public/cards/*
# static assets
/static/
# anything test
test.*

View File

@@ -4,8 +4,12 @@ import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle', // Directory for migration files
schema: './src/db/schema.ts', // Path to your schema file
dialect: 'mysql', // Specify the database dialect
casing: 'snake_case', // camelCase JS objects become snake_case in the DB
dialect: 'postgresql', // Specify the database dialect
dbCredentials: {
url: process.env.DATABASE_URL!, // Use the URL from your .env file
},
schemaFilter: ['pokemon'],
verbose: true,
strict: true,
});

2730
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,15 +17,17 @@
"bootstrap": "^5.3.8",
"chalk": "^5.6.2",
"chart.js": "^4.5.1",
"csv": "^6.4.1",
"dotenv": "^17.2.4",
"drizzle-orm": "^1.0.0-beta.15-859cf75",
"mysql2": "^3.16.3",
"pg": "^8.20.0",
"sass": "^1.97.3",
"typesense": "^3.0.1"
},
"devDependencies": {
"@types/bootstrap": "^5.2.10",
"@types/node": "^25.2.1",
"@types/pg": "^8.18.0",
"drizzle-kit": "^1.0.0-beta.15-859cf75",
"typescript": "^5.9.3"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
public/holofoils/cosmos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

BIN
public/holofoils/galaxy.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

BIN
public/holofoils/grain.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
public/holofoils/metal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/holofoils/vmaxbg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
public/holofoils/wave.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

87
scripts/csvprices.ts Normal file
View File

@@ -0,0 +1,87 @@
import 'dotenv/config';
import { db, ClosePool } from '../src/db/index.ts';
import chalk from 'chalk';
import fs from "fs";
//import path from "node:path";
import { parse, stringify, transform } from 'csv';
import { client } from '../src/db/typesense.ts';
async function PricesFromCSV() {
const inputFilePath = 'scripts/test.tcgcollector.csv';
const outputFilePath = 'scripts/output.csv';
// Create read and write streams
const inputStream = fs.createReadStream(inputFilePath, 'utf8');
const outputStream = fs.createWriteStream(outputFilePath);
// Define the transformation logic
const transformer = transform({ parallel: 1 }, async function(this: any, row: any, callback: any) {
try {
// Specific query bsaed on tcgcollector CSV
const query = String(Object.values(row)[1]);
const setname = String(Object.values(row)[4]).replace(/Wizards of the coast promos/ig,'WoTC Promo');
const cardNumber = String(Object.values(row)[7]);
console.log(`${query} ${cardNumber} : ${setname}`);
// Use Typesense to find the card because we can easily use the combined fields
let cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `setName:\`${setname}\` && number:${cardNumber}` });
if (cards.hits?.length === 0) {
// Try without card number
cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `setName:\`${setname}\`` });
}
if (cards.hits?.length === 0) {
// Try without set name
cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `number:${cardNumber}` });
}
if (cards.hits?.length === 0) {
// I give up, just output the values from the csv
console.log(chalk.red(' - not found'));
const newRow = { ...row };
newRow.Variant = '';
newRow.marketPrice = '';
this.push(newRow);
}
else {
for (const card of cards.hits?.map((hit: any) => hit.document) ?? []) {
console.log(chalk.blue(` - ${card.cardId} : ${card.productName} : ${card.number}`), chalk.yellow(`${card.setName}`), chalk.green(`${card.variant}`));
const variant = await db.query.cards.findFirst({
with: { prices: true, tcgdata: true },
where: { cardId: card.cardId }
});
const newRow = { ...row };
newRow.Variant = variant?.variant;
newRow.marketPrice = variant?.prices.find(p => p.condition === 'Near Mint')?.marketPrice;
this.push(newRow);
}
}
callback();
} catch (error) {
callback(error);
}
});
// Pipe the streams: Read -> Parse -> Transform -> Stringify -> Write
inputStream
.on('error', (error) => console.error('Input stream error:', error))
.pipe(parse({ columns: true, trim: true }))
.on('error', (error) => console.error('Parse error:', error))
.pipe(transformer)
.on('error', (error) => console.error('Transform error:', error))
.pipe(stringify({ header: true }))
.on('error', (error) => console.error('Stringify error:', error))
.pipe(outputStream);
outputStream.on('finish', () => {
console.log(`Successfully written to ${outputFilePath}`);
ClosePool();
});
outputStream.on('error', (error) => {
console.error('An error occurred in the process:', error);
ClosePool();
});
}
await PricesFromCSV();

View File

@@ -0,0 +1,30 @@
import * as schema from '../src/db/schema.ts';
import { db, ClosePool } from '../src/db/index.ts';
import { sql } from "drizzle-orm";
import fs from "node:fs/promises";
import path from "node:path";
async function findMissingImages() {
const cards = await db
.select()
.from(schema.tcgcards)
.where(sql`${schema.tcgcards.sealed} = false`);
const missingImages: string[] = [];
for (const card of cards) {
const imagePath = path.join(process.cwd(), 'static', 'cards', `${card.productId}.jpg`);
try {
await fs.access(imagePath);
} catch (err) {
missingImages.push(`${card.productId}\t${card.setId}\t${card.productName}\t${card.number}`);
}
}
return missingImages;
}
const missingImages = await findMissingImages();
//console.log("Missing Images:", missingImages.join('\n'));
fs.writeFile(path.join(process.cwd(), 'missing-images.log'), missingImages.join('\n'));
await ClosePool();

187
scripts/pokemon-helper.ts Normal file
View File

@@ -0,0 +1,187 @@
import chalk from 'chalk';
import { client } from '../src/db/typesense.ts';
import type { DBInstance } from '../src/db/index.ts';
import fs from "node:fs/promises";
import { sql } from 'drizzle-orm'
const DollarToInt = (dollar: any) => {
if (dollar === null) return null;
return Math.round(dollar * 100);
}
export const Sleep = (ms: number) => {
return new Promise(resolve => setTimeout(resolve, ms));
}
export const FileExists = async (path: string): Promise<boolean> => {
try {
await fs.access(path);
return true;
} catch {
return false;
}
}
export const GetNumberOrNull = (value: any): number | null => {
const number = Number(value); // Attempt to convert the value to a number
if (Number.isNaN(number)) {
return null; // Return null if the result is NaN
}
return number; // Otherwise, return the number
}
// Delete and recreate the 'cards' index
export const createCardCollection = async () => {
try {
await client.collections('cards').delete();
} catch (error) {
// Ignore error, just means collection doesn't exist
}
await client.collections().create({
name: 'cards',
fields: [
{ name: 'id', type: 'string' },
{ name: 'cardId', type: 'int32' },
{ name: 'productId', type: 'int32' },
{ name: 'variant', type: 'string', facet: true },
{ name: 'productName', type: 'string' },
{ name: 'productLineName', type: 'string', facet: true },
{ name: 'rarityName', type: 'string', facet: true },
{ name: 'setName', type: 'string', facet: true },
{ name: 'setCode', type: 'string' },
{ name: 'cardType', type: 'string', facet: true },
{ name: 'energyType', type: 'string', facet: true },
{ name: 'number', type: 'string', sort: true },
{ name: 'Artist', type: 'string' },
{ name: 'sealed', type: 'bool' },
{ name: 'releaseDate', type: 'int32' },
{ name: 'marketPrice', type: 'int32', optional: true, sort: true },
{ name: 'content', type: 'string', token_separators: ['/'] },
{ name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
],
});
console.log(chalk.green('Collection "cards" created successfully.'));
}
// Delete and recreate the 'skus' index
export const createSkuCollection = async () => {
try {
await client.collections('skus').delete();
} catch (error) {
// Ignore error, just means collection doesn't exist
}
await client.collections().create({
name: 'skus',
fields: [
{ name: 'id', type: 'string' },
{ name: 'condition', type: 'string' },
{ name: 'highestPrice', type: 'int32', optional: true },
{ name: 'lowestPrice', type: 'int32', optional: true },
{ name: 'marketPrice', type: 'int32', optional: true },
]
});
console.log(chalk.green('Collection "skus" created successfully.'));
}
export const upsertCardCollection = async (db:DBInstance) => {
const pokemon = await db.query.cards.findMany({
with: { set: true, tcgdata: true, prices: true },
});
await client.collections('cards').documents().import(pokemon.map(card => {
// Use the NM SKU price matching the card's variant (kept fresh by syncPrices)
// Fall back to any NM sku, then to tcgdata price
const nmSku = card.prices.find(p => p.condition === 'Near Mint' && p.variant === card.variant)
?? card.prices.find(p => p.condition === 'Near Mint');
const marketPrice = nmSku?.marketPrice
? DollarToInt(nmSku.marketPrice)
: card.tcgdata?.marketPrice
? DollarToInt(card.tcgdata.marketPrice)
: null;
return {
id: card.cardId.toString(),
cardId: card.cardId,
productId: card.productId,
variant: card.variant,
productName: card.productName,
productLineName: card.productLineName,
rarityName: card.rarityName,
setName: card.set?.setName || "",
setCode: card.set?.setCode || "",
cardType: card.cardType || "",
energyType: card.energyType || "",
number: card.number,
Artist: card.artist || "",
sealed: card.sealed,
content: [card.productName, card.productLineName, card.set?.setName || "", card.set?.setCode || "", card.number, card.rarityName, card.artist || ""].join(' '),
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
...(marketPrice !== null && { marketPrice }),
sku_id: card.prices.map(price => price.skuId.toString())
};
}), { action: 'upsert' });
console.log(chalk.green('Collection "cards" indexed successfully.'));
}
export const upsertSkuCollection = async (db:DBInstance) => {
const skus = await db.query.skus.findMany();
await client.collections('skus').documents().import(skus.map(sku => ({
id: sku.skuId.toString(),
condition: sku.condition,
highestPrice: DollarToInt(sku.highestPrice),
lowestPrice: DollarToInt(sku.lowestPrice),
marketPrice: DollarToInt(sku.marketPrice),
})), { action: 'upsert' });
console.log(chalk.green('Collection "skus" indexed successfully.'));
}
export const UpdateVariants = async (db:DBInstance) => {
const updates = await db.execute(sql`update cards as c
set
product_name = a.product_name, product_line_name = a.product_line_name, product_url_name = a.product_url_name, rarity_name = a.rarity_name,
sealed = a.sealed, set_id = a.set_id, card_type = a.card_type, energy_type = a.energy_type, number = a.number, artist = a.artist
from (
select t.product_id, b.variant,
coalesce(o.product_name, regexp_replace(regexp_replace(coalesce(nullif(t.product_name, ''), t.product_url_name),' \\\\(.*\\\\)',''),' - .*$','')) as product_name,
coalesce(o.product_line_name, t.product_line_name) as product_line_name, coalesce(o.product_url_name, t.product_url_name) as product_url_name,
coalesce(o.rarity_name, t.rarity_name) as rarity_name, coalesce(o.sealed, t.sealed) as sealed, coalesce(o.set_id, t.set_id) as set_id,
coalesce(o.card_type, t.card_type) as card_type, coalesce(o.energy_type, t.energy_type) as energy_type,
coalesce(o.number, t.number) as number, coalesce(o.artist, t.artist) as artist
from tcg_cards t
join (select distinct product_id, variant from skus) b on t.product_id = b.product_id
left join tcg_overrides o on t.product_id = o.product_id
) a
where c.product_id = a.product_id and c.variant = a.variant and
(
c.product_name is distinct from a.product_name or c.product_line_name is distinct from a.product_line_name or
c.product_url_name is distinct from a.product_url_name or c.rarity_name is distinct from a.rarity_name or
c.sealed is distinct from a.sealed or c.set_id is distinct from a.set_id or c.card_type is distinct from a.card_type or
c.energy_type is distinct from a.energy_type or c."number" is distinct from a."number" or c.artist is distinct from a.artist
)
`);
console.log(`Updated ${updates.rowCount} rows in cards table`);
const inserts = await db.execute(sql`insert into cards (product_id, variant, product_name, product_line_name, product_url_name, rarity_name, sealed, set_id, card_type, energy_type, "number", artist)
select t.product_id, b.variant,
coalesce(o.product_name, regexp_replace(regexp_replace(coalesce(nullif(t.product_name, ''), t.product_url_name),' \\\\(.*\\\\)',''),' - .*$','')) as product_name,
coalesce(o.product_line_name, t.product_line_name) as product_line_name, coalesce(o.product_url_name, t.product_url_name) as product_url_name, coalesce(o.rarity_name, t.rarity_name) as rarity_name,
coalesce(o.sealed, t.sealed) as sealed, coalesce(o.set_id, t.set_id) as set_id, coalesce(o.card_type, t.card_type) as card_type,
coalesce(o.energy_type, t.energy_type) as energy_type, coalesce(o.number, t.number) as number, coalesce(o.artist, t.artist) as artist
from tcg_cards t
join (select distinct product_id, variant from skus) b on t.product_id = b.product_id
left join tcg_overrides o on t.product_id = o.product_id
where not exists (select 1 from cards where product_id=t.product_id and variant=b.variant)
`);
console.log(`Inserted ${inserts.rowCount} rows into cards table`);
}

View File

@@ -0,0 +1,147 @@
import chalk from 'chalk';
import { db, ClosePool } from '../src/db/index.ts';
import { sql } from 'drizzle-orm';
import { skus, priceHistory } from '../src/db/schema.ts';
import { toSnakeCase } from 'drizzle-orm/casing';
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const productPath = path.join(__dirname, 'products.log');
const sleep = (ms: number) => {
return new Promise(resolve => setTimeout(resolve, ms));
}
const headers = {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36'
}
const GetHistory = async (productId:number) => {
let monthData;
let quarterData;
let annualData;
let retries = 10;
while (retries > 0) {
try {
const monthResponse = await fetch(`https://infinite-api.tcgplayer.com/price/history/${productId}/detailed?range=month`, { headers: headers });
if (!monthResponse.ok) {
throw new Error(`Error fetching month data: ${monthResponse.statusText}`);
}
monthData = await monthResponse.json();
const quarterResponse = await fetch(`https://infinite-api.tcgplayer.com/price/history/${productId}/detailed?range=quarter`, { headers: headers });
if (!quarterResponse.ok) {
throw new Error(`Error fetching quarter data: ${quarterResponse.statusText}`);
}
quarterData = await quarterResponse.json();
const annualResponse = await fetch(`https://infinite-api.tcgplayer.com/price/history/${productId}/detailed?range=annual`, { headers: headers });
if (!annualResponse.ok) {
throw new Error(`Error fetching annual data: ${annualResponse.statusText}`);
}
annualData = await annualResponse.json();
retries = 0;
}
catch (error) {
retries--;
const err = error as Error;
console.error(err);
if (err.message.startsWith('Error fetching ')) await sleep(7500);
await sleep(2500);
}
}
if (annualData.result === null) {
console.error(chalk.red(`\tNo results found for productId: ${productId}`));
fs.appendFile(productPath, `${productId}\n`);
return null;
}
let skuCount = 0;
let priceCount = 0;
for (const annual of annualData.result) {
const quarter = quarterData.result?.find((r:any) => r.skuId == annual.skuId);
const month = monthData.result?.find((r:any) => r.skuId == annual.skuId);
const allPrices = [
...annual?.buckets?.map((r:any) => { return { skuId:Number(annual.skuId), calculatedAt:r.bucketStartDate, marketPrice:Number(r.marketPrice) }; }) || [],
...quarter?.buckets?.map((r:any) => { return { skuId:Number(annual.skuId), calculatedAt:r.bucketStartDate, marketPrice:Number(r.marketPrice) }; }) || [],
...month?.buckets?.map((r:any) => { return { skuId:Number(annual.skuId), calculatedAt:r.bucketStartDate, marketPrice:Number(r.marketPrice) }; }) || []
].sort((a:any,b:any) => { if(a.calculatedAt<b.calculatedAt) return -1; if(a.calculatedAt>b.calculatedAt) return 1; return 0; });;
const priceUpdates = allPrices.reduce((accumulator:any[],currentItem:any) => {
if (accumulator.length === 0 || (accumulator[accumulator.length-1].marketPrice !== currentItem.marketPrice && accumulator[accumulator.length-1].calculatedAt != currentItem.calculatedAt)) {
accumulator.push(currentItem);
}
return accumulator;
},[]);
skuCount++;
priceCount += priceUpdates.length;
console.log(chalk.gray(`\tSkuId: ${annual.skuId} with ${priceUpdates.length} updates`));
await db.insert(priceHistory).values(priceUpdates).onConflictDoUpdate({
target: [priceHistory.skuId, priceHistory.calculatedAt ],
set: {
marketPrice: sql.raw(`excluded.${toSnakeCase(skus.marketPrice.name)}`),
},
}).returning();
}
fs.appendFile(productPath, `${productId}\n`);
return { skuCount:skuCount, priceCount:priceCount };
}
const start = Date.now();
let productSet;
try {
const data = await fs.readFile(productPath, 'utf8');
const lines = data.split(/\r?\n/);
productSet = new Set(lines.map(line => line.trim()));
} catch (err) {
productSet = new Set();
}
// problem with this product
productSet.add('632947');
productSet.add('635161');
productSet.add('642504');
productSet.add('654346');
let count = productSet.size;
console.log(chalk.green(`${count} products already done.`));
const productIds = await db.query.tcgcards.findMany({ columns: { productId: true }});
const total = productIds.length;
for (const product of productIds) {
const productId = product.productId;
if (productSet.has(productId.toString().trim())) {
// console.log(chalk.blue(`ProductId: ${productId} (.../${total})`));
} else {
count++;
console.log(chalk.blue(`ProductId: ${productId} (${count}/${total})`));
await GetHistory(productId);
//await sleep(7000);
}
}
await ClosePool();
const end = Date.now();
const duration = (end - start) / 1000;
console.log(chalk.green(`Price history preloaded in ${duration.toFixed(2)} seconds.`));
export {};

View File

@@ -1,14 +1,15 @@
import 'dotenv/config';
import * as schema from '../src/db/schema.ts';
import { db, poolConnection } from '../src/db/index.ts';
import { db, ClosePool } from '../src/db/index.ts';
import fs from "node:fs/promises";
import path from "node:path";
import chalk from 'chalk';
import * as helper from './pokemon-helper.ts';
//import util from 'util';
async function syncTcgplayer() {
async function syncTcgplayer(cardSets:string[] = []) {
const productLines = [ "pokemon", "pokemon-japan" ];
@@ -29,44 +30,21 @@ async function syncTcgplayer() {
const setNames = data.results[0].aggregations.setName;
for (const setName of setNames) {
console.log(chalk.blue(`Syncing product line "${productLine}" with setName "${setName.urlValue}"...`));
await syncProductLine(productLine, "setName", setName.urlValue);
let processSet = true;
if (cardSets.length > 0) {
processSet = cardSets.some(set => setName.value.toLowerCase().includes(set.toLowerCase()));
}
if (processSet) {
console.log(chalk.blue(`Syncing product line "${productLine}" with setName "${setName.urlValue}"...`));
await syncProductLine(productLine, "setName", setName.urlValue);
}
}
}
console.log(chalk.green('✓ All TCGPlayer data synchronized successfully!'));
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function cleanProductName(name: string): string {
// remove TCGPlayer crap
name = name.replace(/ - .*$/, '');
name = name.replace(/ \[.*\]/, '');
name = name.replace(/ \(.*\)/, '');
return name.trim();
}
async function fileExists(path: string): Promise<boolean> {
try {
await fs.access(path);
return true;
} catch {
return false;
}
}
function getNumberOrNull(value: any): number | null {
const number = Number(value); // Attempt to convert the value to a number
if (Number.isNaN(number)) {
return null; // Return null if the result is NaN
}
return number; // Otherwise, return the number
}
async function syncProductLine(productLine: string, field: string, fieldValue: string) {
let start = 0;
@@ -130,10 +108,10 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
for (const item of data.results[0].results) {
// // Check if productId already exists and skip if it does (to avoid hitting the API too much)
// if (allProductIds.has(item.productId)) {
// continue;
// }
// Check if productId already exists and skip if it does (to avoid hitting the API too much)
if (allProductIds.size > 0 && allProductIds.has(item.productId)) {
continue;
}
console.log(chalk.blue(` - ${item.productName} (ID: ${item.productId})`));
@@ -171,7 +149,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
cardTypeB: item.customAttributes.cardTypeB || null,
energyType: detailData.customAttributes.energyType?.[0] || null,
flavorText: detailData.customAttributes.flavorText || null,
hp: getNumberOrNull(item.customAttributes.hp),
hp: helper.GetNumberOrNull(item.customAttributes.hp),
number: detailData.customAttributes.number || '',
releaseDate: detailData.customAttributes.releaseDate ? new Date(detailData.customAttributes.releaseDate) : null,
resistance: item.customAttributes.resistance || null,
@@ -184,8 +162,9 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
maxFulfillableQuantity: detailData.maxFulfillableQuantity,
medianPrice: detailData.medianPrice,
totalListings: item.totalListings,
Artist: detailData.formattedAttributes.Artist || null,
}).onDuplicateKeyUpdate({
artist: detailData.formattedAttributes.Artist || null,
}).onConflictDoUpdate({
target: schema.tcgcards.productId,
set: {
productName: detailData.productName,
//productName: cleanProductName(item.productName),
@@ -208,7 +187,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
cardTypeB: item.customAttributes.cardTypeB || null,
energyType: detailData.customAttributes.energyType?.[0] || null,
flavorText: detailData.customAttributes.flavorText || null,
hp: getNumberOrNull(item.customAttributes.hp),
hp: helper.GetNumberOrNull(item.customAttributes.hp),
number: detailData.customAttributes.number || '',
releaseDate: detailData.customAttributes.releaseDate ? new Date(detailData.customAttributes.releaseDate) : null,
resistance: item.customAttributes.resistance || null,
@@ -221,18 +200,21 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
maxFulfillableQuantity: detailData.maxFulfillableQuantity,
medianPrice: detailData.medianPrice,
totalListings: item.totalListings,
Artist: detailData.formattedAttributes.Artist || null,
artist: detailData.formattedAttributes.Artist || null,
},
});
console.log(`item: ${item.setId}\tdetail: ${detailData.setId}`);
console.log(`item: ${item.setCode}\tdetail: ${detailData.setCode}`);
console.log(`item: ${item.setName}\tdetail: ${detailData.setName}`);
// set is...
await db.insert(schema.sets).values({
setId: detailData.setId,
setCode: detailData.setCode,
setName: detailData.setName,
setUrlName: detailData.setUrlName,
}).onDuplicateKeyUpdate({
}).onConflictDoUpdate({
target: schema.sets.setId,
set: {
setCode: detailData.setCode,
setName: detailData.setName,
@@ -249,7 +231,8 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
condition: skuItem.condition,
language: skuItem.language,
variant: skuItem.variant,
}).onDuplicateKeyUpdate({
}).onConflictDoUpdate({
target: schema.skus.skuId,
set: {
condition: skuItem.condition,
language: skuItem.language,
@@ -259,8 +242,8 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
}
// get image if it doesn't already exist
const imagePath = path.join(process.cwd(), 'public', 'cards', `${item.productId}.jpg`);
if (!await fileExists(imagePath)) {
const imagePath = path.join(process.cwd(), 'static', 'cards', `${item.productId}.jpg`);
if (!await helper.FileExists(imagePath)) {
const imageResponse = await fetch(`https://tcgplayer-cdn.tcgplayer.com/product/${item.productId}_in_1000x1000.jpg`);
if (imageResponse.ok) {
const buffer = await imageResponse.arrayBuffer();
@@ -272,7 +255,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
}
// be nice to the API and not send too many requests in a short time
await sleep(300);
await helper.Sleep(300);
}
@@ -282,8 +265,21 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
// clear the log file
await fs.rm('missing_images.log', { force: true });
let allProductIds = new Set();
const allProductIds = new Set(await db.select({ productId: schema.cards.productId }).from(schema.cards).then(rows => rows.map(row => row.productId)));
const args = process.argv.slice(2);
if (args.length === 0) {
allProductIds = new Set(await db.select({ productId: schema.cards.productId }).from(schema.cards).then(rows => rows.map(row => row.productId)));
await syncTcgplayer();
}
else {
await syncTcgplayer(args);
}
await syncTcgplayer();
await poolConnection.end();
// update the card table with new/updated variants
await helper.UpdateVariants(db);
// index the card updates
await helper.upsertCardCollection(db);
await ClosePool();

View File

@@ -1,134 +1,11 @@
import { Client } from 'typesense';
import chalk from 'chalk';
import { db, poolConnection } from '../src/db/index.ts';
import { client } from '../src/db/typesense.ts';
import { release } from 'node:os';
const DollarToInt = (dollar: any) => {
if (dollar === null) return null;
return Math.round(dollar * 100);
}
async function createCollection(client: Client) {
// Delete the collection if it already exists to ensure a clean slate
try {
await client.collections('cards').delete();
await client.collections('skus').delete();
//console.log(`Collection "cards" deleted successfully:`, response);
} catch (error) {
//console.error(`Error deleting collection "cards":`, error);
}
// Create the collection with the specified schema
try {
await client.collections('cards').retrieve();
console.log(chalk.yellow('Collection "cards" already exists.'));
} catch (error) {
if (error instanceof Error && error.message.includes('404')) {
await client.collections().create({
name: 'cards',
fields: [
{ name: 'id', type: 'string' },
{ name: 'cardId', type: 'int32' },
{ name: 'productId', type: 'int32' },
{ name: 'variant', type: 'string', facet: true },
{ name: 'productName', type: 'string' },
{ name: 'productLineName', type: 'string', facet: true },
{ name: 'rarityName', type: 'string', facet: true },
{ name: 'setName', type: 'string', facet: true },
{ name: 'cardType', type: 'string', facet: true },
{ name: 'energyType', type: 'string', facet: true },
{ name: 'number', type: 'string', sort: true },
{ name: 'Artist', type: 'string' },
{ name: 'sealed', type: 'bool' },
{ name: 'releaseDate', type: 'int32'},
{ name: 'content', type: 'string', token_separators: ['/'] },
{ name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
],
//default_sorting_field: 'productId',
});
console.log(chalk.green('Collection "cards" created successfully.'));
} else {
console.error(chalk.red('Error checking/creating collection:'), error);
process.exit(1);
}
}
try {
await client.collections('skus').retrieve();
console.log(chalk.yellow('Collection "skus" already exists.'));
} catch(error) {
if (error instanceof Error && error.message.includes('404')) {
await client.collections().create({
name: 'skus',
fields: [
{ name: 'id', type: 'string' },
{ name: 'condition', type: 'string' },
{ name: 'highestPrice', type: 'int32', optional: true },
{ name: 'lowestPrice', type: 'int32', optional: true },
{ name: 'marketPrice', type: 'int32', optional: true },
//{ name: 'card_id', type: 'string', reference: 'cards.id' },
]
});
}
}
}
import { db, ClosePool } from '../src/db/index.ts';
import * as Indexing from './pokemon-helper.ts';
async function preloadSearchIndex() {
const pokemon = await db.query.cards.findMany({
with: { set: true, tcgdata: true, prices: true },
});
// Ensure the collection exists before importing documents
await createCollection(client);
await client.collections('cards').documents().import(pokemon.map(card => ({
id: card.cardId.toString(),
cardId: card.cardId,
productId: card.productId,
variant: card.variant,
productName: card.productName,
productLineName: card.productLineName,
rarityName: card.rarityName,
setName: card.set?.setName || "",
cardType: card.cardType || "",
energyType: card.energyType || "",
number: card.number,
Artist: card.Artist || "",
sealed: card.sealed,
content: [card.productName,card.productLineName,card.set?.setName || "",card.number,card.rarityName,card.Artist || ""].join(' '),
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
sku_id: card.prices.map(price => price.skuId.toString())
})), { action: 'upsert' });
const skus = await db.query.skus.findMany({
with: { card: true }
});
await client.collections('skus').documents().import(skus.map(sku => ({
id: sku.skuId.toString(),
condition: sku.condition,
highestPrice: DollarToInt(sku.highestPrice),
lowestPrice: DollarToInt(sku.lowestPrice),
marketPrice: DollarToInt(sku.marketPrice),
//card_id: sku.card?.cardId.toString()
})));
console.log(chalk.green('Search index preloaded with Pokémon cards.'));
}
await preloadSearchIndex().catch((error) => {
console.error(chalk.red('Error preloading search index:'), error);
for (const e of error.importResults) {
if (!e.success) {
console.error(chalk.red(`Error importing document ${e.id}:`), e.error);
}
}
process.exit(1);
}).finally(() => {
poolConnection.end();
console.log(chalk.blue('Database connection closed.'));
process.exit(0);
});
//await Indexing.createCardCollection();
//await Indexing.createSkuCollection();
await Indexing.upsertCardCollection(db);
await Indexing.upsertSkuCollection(db);
await ClosePool();
console.log(chalk.green('Pokémon reindex complete.'));

View File

@@ -1,30 +1,23 @@
import 'dotenv/config';
import chalk from 'chalk';
import { db, poolConnection } from '../src/db/index.ts';
import { db, ClosePool } from '../src/db/index.ts';
import { sql, inArray, eq } from 'drizzle-orm';
import { skus, processingSkus } from '../src/db/schema.ts';
import { client } from '../src/db/typesense.ts';
import { skus, processingSkus, priceHistory, salesHistory } from '../src/db/schema.ts';
import { toSnakeCase } from 'drizzle-orm/casing';
import * as helper from './pokemon-helper.ts';
const DollarToInt = (dollar: any) => {
if (dollar === null) return null;
return Math.round(dollar * 100);
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function resetProcessingTable() {
// Use sql.raw to execute the TRUNCATE TABLE statement
await db.execute(sql.raw('TRUNCATE TABLE processingSkus;'));
await db.execute(sql.raw('TRUNCATE TABLE pokemon.processing_skus;'));
await db.insert(processingSkus).select(db.select({skuId: skus.skuId}).from(skus));
}
async function syncPrices() {
const batchSize = 1000;
// const skuIndex = client.collections('skus');
const updatedCards = new Set<number>();
await resetProcessingTable();
console.log(chalk.green('Processing table reset and populated with current SKUs.'));
@@ -59,6 +52,15 @@ async function syncPrices() {
console.error(chalk.yellow(`Expected ${batchSize} SKUs, got ${skuData.length}`));
}
if (skuData.length === 0) {
console.error(chalk.red('0 SKUs, skipping DB updates.'));
// remove skus from the 'working' processingSkus table
await db.delete(processingSkus).where(inArray(processingSkus.skuId, skuIds));
// be nice to the API and not send too many requests in a short time
await helper.Sleep(200);
continue;
}
const skuUpdates = skuData.map((sku: any) => { return {
skuId: sku.skuId,
cardId: 0,
@@ -72,41 +74,91 @@ async function syncPrices() {
marketPrice: sku.marketPrice,
priceCount: null,
}});
await db.insert(skus).values(skuUpdates).onDuplicateKeyUpdate({
const skuRows = await db.insert(skus).values(skuUpdates).onConflictDoUpdate({
target: skus.skuId,
set: {
calculatedAt: sql`values(${skus.calculatedAt})`,
highestPrice: sql`values(${skus.highestPrice})`,
lowestPrice: sql`values(${skus.lowestPrice})`,
marketPrice: sql`values(${skus.marketPrice})`,
calculatedAt: sql.raw(`excluded.${toSnakeCase(skus.calculatedAt.name)}`),
highestPrice: sql.raw(`excluded.${toSnakeCase(skus.highestPrice.name)}`),
lowestPrice: sql.raw(`excluded.${toSnakeCase(skus.lowestPrice.name)}`),
marketPrice: sql.raw(`excluded.${toSnakeCase(skus.marketPrice.name)}`),
},
setWhere: sql`skus.market_price is distinct from excluded.market_price`,
}).returning();
if (skuRows && skuRows.length > 0) {
const skuHistory = skuRows.filter(row => row.calculatedAt != null).map(row => { return {
skuId: row.skuId,
calculatedAt: new Date(row.calculatedAt?.toISOString().slice(0, 10)||0),
marketPrice: row.marketPrice,
}});
if (skuHistory && skuHistory.length > 0) {
await db.insert(priceHistory).values(skuHistory).onConflictDoUpdate({
target: [priceHistory.skuId,priceHistory.calculatedAt],
set: {
marketPrice: sql.raw(`excluded.${toSnakeCase(skus.marketPrice.name)}`),
}
});
console.log(chalk.cyan(`${skuRows.length} history rows added.`));
}
});
for (const productId of skuRows.filter(row => row.calculatedAt != null).map(row => row.productId)) {
updatedCards.add(productId);
}
}
// remove skus from the 'working' processingSkus table
await db.delete(processingSkus).where(inArray(processingSkus.skuId, skuIds));
// be nice to the API and not send too many requests in a short time
await sleep(100);
await helper.Sleep(200);
}
return updatedCards;
}
async function indexPrices() {
const skus = await db.query.skus.findMany();
await client.collections('skus').documents().import(skus.map(sku => ({
id: sku.skuId.toString(),
condition: sku.condition,
highestPrice: DollarToInt(sku.highestPrice),
lowestPrice: DollarToInt(sku.lowestPrice),
marketPrice: DollarToInt(sku.marketPrice),
})), { action: 'upsert' });
const updateLatestSales = async (updatedCards: Set<number>) => {
for (const productId of updatedCards.values()) {
console.log(`Getting sale history for ${productId}`)
const salesResponse = await fetch(`https://mpapi.tcgplayer.com/v2/product/${productId}/latestsales`,{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36'
},
body: JSON.stringify({ conditions:[], languages:[1], limit:25, listType:"All", variants:[] }),
});
if (!salesResponse.ok) {
console.error('Error fetching sale history:', salesResponse.statusText);
process.exit(1);
}
const salesData = await salesResponse.json();
for (const sale of salesData.data) {
const skuData = await db.query.skus.findFirst({ where: { productId: productId, variant: sale.variant, condition: sale.condition } });
if (skuData) {
await db.insert(salesHistory).values({
skuId: skuData.skuId,
orderDate: new Date(sale.orderDate),
title: sale.title,
customListingId: sale.customListingId,
language: sale.language,
listingType: sale.listingType,
purchasePrice: sale.purchasePrice,
quantity: sale.quantity,
shippingPrice: sale.shippingPrice
}).onConflictDoNothing();
}
}
await helper.Sleep(500);
}
}
const start = Date.now();
await syncPrices();
await indexPrices();
await poolConnection.end();
const updatedCards = await syncPrices();
await helper.upsertSkuCollection(db);
await helper.upsertCardCollection(db);
//console.log(updatedCards);
//console.log(updatedCards.size);
//await updateLatestSales(updatedCards);
await ClosePool();
const end = Date.now();
const duration = (end - start) / 1000;
console.log(chalk.green(`Price sync completed in ${duration.toFixed(2)} seconds.`));

View File

@@ -1,32 +0,0 @@
import 'dotenv/config';
import { db, poolConnection } from '../src/db/index.ts';
import { sql } from 'drizzle-orm'
async function syncVariants() {
const updates = await db.execute(sql`update cards as c
join tcgcards t on c.productId = t.productId
join (select distinct productId, variant from skus) b on c.productId = b.productId and c.variant = b.variant
left join tcg_overrides o on c.productId = o.productId
set c.productName = coalesce(o.productName, regexp_replace(regexp_replace(coalesce(nullif(t.productName, ''), t.productUrlName),' \\\\(.*\\\\)',''),' - .*$','')),
c.productLineName = coalesce(o.productLineName, t.productLineName), c.productUrlName = coalesce(o.productUrlName, t.productUrlName), c.rarityName = coalesce(o.rarityName, t.rarityName),
c.sealed = coalesce(o.sealed, t.sealed), c.setId = coalesce(o.setId, t.setId), c.cardType = coalesce(o.cardType, t.cardType),
c.energyType = coalesce(o.energyType, t.energyType), c.number = coalesce(o.number, t.number), c.Artist = coalesce(o.Artist, t.Artist)`);
console.log(`Updated ${updates[0].affectedRows} rows in cards table`);
const inserts = await db.execute(sql`insert into cards (productId, variant, productName, productLineName, productUrlName, rarityName, sealed, setId, cardType, energyType, number, Artist)
select t.productId, b.variant,
coalesce(o.productName, regexp_replace(regexp_replace(coalesce(nullif(t.productName, ''), t.productUrlName),' \\\\(.*\\\\)',''),' - .*$','')) as productName,
coalesce(o.productLineName, t.productLineName) as productLineName, coalesce(o.productUrlName, t.productUrlName) as productUrlName, coalesce(o.rarityName, t.rarityName) as rarityName,
coalesce(o.sealed, t.sealed) as sealed, coalesce(o.setId, t.setId) as setId, coalesce(o.cardType, t.cardType) as cardType,
coalesce(o.energyType, t.energyType) as energyType, coalesce(o.number, t.number) as number, coalesce(o.Artist, t.Artist) as Artist
from tcgcards t
join (select distinct productId, variant from skus) b on t.productId = b.productId
left join tcg_overrides o on t.productId = o.productId
where not exists (select 1 from cards where productId=t.productId and variant=b.variant)
`);
console.log(`Inserted ${inserts[0].affectedRows} rows into cards table`);
}
await syncVariants();
await poolConnection.end();

View File

@@ -22,7 +22,7 @@
@import 'bootstrap/scss/alert';
@import 'bootstrap/scss/badge';
// @import 'bootstrap/scss/breadcrumb';
// @import 'bootstrap/scss/button-group';
@import 'bootstrap/scss/button-group';
@import 'bootstrap/scss/buttons';
@import 'bootstrap/scss/card';
// @import 'bootstrap/scss/carousel';
@@ -41,7 +41,7 @@
// @import 'bootstrap/scss/spinners';
@import 'bootstrap/scss/tables';
@import 'bootstrap/scss/toasts';
// @import 'bootstrap/scss/tooltip';
@import 'bootstrap/scss/tooltip';
@import 'bootstrap/scss/transitions';
// Optional helpers

2115
src/assets/css/_card.scss Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,349 @@
// =============================================================================
// HOLOFOIL INTEGRATION
// _holofoil-integration.scss
// =============================================================================
@import "card";
// -----------------------------------------------------------------------------
// 1. WRAPPER NORMALISATION
// -----------------------------------------------------------------------------
%holofoil-wrapper-base {
--card-aspect: 0.718;
--card-radius: 4.55% / 3.5%;
--pointer-x: 50%;
--pointer-y: 50%;
--background-x: 50%;
--background-y: 50%;
--pointer-from-center: 0;
--pointer-from-top: 0.5;
--pointer-from-left: 0.5;
--card-scale: 1;
--card-opacity: 0;
--grain: url('/public/holofoils/grain.webp');
--glitter: url('/public/holofoils/glitter.png');
--glittersize: 25%;
--space: 5%;
--angle: 133deg;
--imgsize: cover;
--red: #f80e35;
--yellow: #eedf10;
--green: #21e985;
--blue: #0dbde9;
--violet: #c929f1;
--clip: inset(9.85% 8% 52.85% 8%);
--clip-invert: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 47.15%, 91.5% 47.15%, 91.5% 9.85%, 8% 9.85%, 8% 47.15%, 0 50%);
--clip-stage: polygon(91.5% 9.85%, 57% 9.85%, 54% 12%, 17% 12%, 16% 14%, 12% 16%, 8% 16%, 8% 47.15%, 92% 47.15%);
--clip-stage-invert: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 47.15%, 91.5% 47.15%, 91.5% 9.85%, 57% 9.85%, 54% 12%, 17% 12%, 16% 14%, 12% 16%, 8% 16%, 8% 47.15%, 0 50%);
--clip-trainer: inset(14.5% 8.5% 48.2% 8.5%);
--clip-borders: inset(2.8% 4% round 2.55% / 1.5%);
--sunpillar-clr-1: var(--sunpillar-1);
--sunpillar-clr-2: var(--sunpillar-2);
--sunpillar-clr-3: var(--sunpillar-3);
--sunpillar-clr-4: var(--sunpillar-4);
--sunpillar-clr-5: var(--sunpillar-5);
--sunpillar-clr-6: var(--sunpillar-6);
// NOTE: no overflow:hidden here -- that would clip the lift/scale transform
// on .image-grow. Overflow is handled by the child .holo-shine/.holo-glare.
position: relative;
isolation: isolate;
border-radius: var(--card-radius);
}
%holofoil-energy-glows {
&[data-energy="Water"] { --card-glow: hsl(192, 97%, 60%); }
&[data-energy="Fire"] { --card-glow: hsl(9, 81%, 59%); }
&[data-energy="Grass"] { --card-glow: hsl(96, 81%, 65%); }
&[data-energy="Lightning"] { --card-glow: hsl(54, 87%, 63%); }
&[data-energy="Psychic"] { --card-glow: hsl(281, 62%, 58%); }
&[data-energy="Fighting"] { --card-glow: rgb(145, 90, 39); }
&[data-energy="Darkness"] { --card-glow: hsl(189, 77%, 27%); }
&[data-energy="Metal"] { --card-glow: hsl(184, 20%, 70%); }
&[data-energy="Dragon"] { --card-glow: hsl(51, 60%, 35%); }
&[data-energy="Fairy"] { --card-glow: hsl(323, 100%, 89%); }
}
// -----------------------------------------------------------------------------
// 2. SHINE + GLARE CHILD DIVS
// -----------------------------------------------------------------------------
%shine-base {
pointer-events: none;
position: absolute;
inset: 0;
border-radius: var(--card-radius);
overflow: hidden; // clipping lives here, not on the parent
z-index: 3;
will-change: transform, opacity, background-image, background-size,
background-position, background-blend-mode, filter;
&::before,
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: var(--card-radius);
}
}
%glare-base {
pointer-events: none;
position: absolute;
inset: 0;
border-radius: var(--card-radius);
z-index: 4;
transform: translateZ(0);
overflow: hidden;
will-change: transform, opacity, background-image, background-size,
background-position, background-blend-mode, filter;
}
// -----------------------------------------------------------------------------
// 3. MODES
// -----------------------------------------------------------------------------
// -- 3a. GRID -----------------------------------------------------------------
// No idle animation. Effect is invisible until hover.
.image-grow,
.card-image-wrap {
@extend %holofoil-wrapper-base;
@extend %holofoil-energy-glows;
// No effect if the image fell back to default.jpg
&[data-default="true"] {
.holo-shine,
.holo-glare { display: none !important; }
}
.holo-shine { @extend %shine-base; }
.holo-glare { @extend %glare-base; }
}
// -- 3b. GRID HOVER -----------------------------------------------------------
// The existing main.scss .image-grow:hover handles lift + scale.
// We layer the holo effect on top without overriding transform or transition.
.image-grow:hover,
.image-grow[data-holo-active] {
--card-opacity: 0.45;
}
// -- 3c. MODAL ----------------------------------------------------------------
// Sweeps once per minute. Peaks at 0.35.
// Pointer tracking bumps opacity to 0.45 while hovering.
@keyframes holo-modal-pulse {
0% {
--card-opacity: 0;
--pointer-x: 50%; --pointer-y: 50%;
--background-x: 50%; --background-y: 50%;
--pointer-from-center: 0; --pointer-from-left: 0.5; --pointer-from-top: 0.5;
}
4% { --card-opacity: 0; }
8% {
--card-opacity: 0.35;
--pointer-x: 25%; --pointer-y: 15%;
--background-x: 38%; --background-y: 28%;
--pointer-from-center: 0.85; --pointer-from-left: 0.25; --pointer-from-top: 0.15;
}
25% {
--pointer-x: 70%; --pointer-y: 30%;
--background-x: 64%; --background-y: 34%;
--pointer-from-center: 0.9; --pointer-from-left: 0.70; --pointer-from-top: 0.30;
}
45% {
--pointer-x: 80%; --pointer-y: 70%;
--background-x: 74%; --background-y: 68%;
--pointer-from-center: 0.88; --pointer-from-left: 0.80; --pointer-from-top: 0.70;
}
65% {
--pointer-x: 35%; --pointer-y: 80%;
--background-x: 38%; --background-y: 76%;
--pointer-from-center: 0.8; --pointer-from-left: 0.35; --pointer-from-top: 0.80;
}
85% {
--card-opacity: 0.35;
--pointer-x: 25%; --pointer-y: 15%;
--background-x: 38%; --background-y: 28%;
--pointer-from-center: 0.85;
}
90% { --card-opacity: 0; }
100% {
--card-opacity: 0;
--pointer-x: 50%; --pointer-y: 50%;
--background-x: 50%; --background-y: 50%;
--pointer-from-center: 0;
}
}
.card-image-wrap.holo-modal-mode {
--card-opacity: 0;
.holo-shine,
.holo-glare {
animation: holo-modal-pulse 60s ease-in-out infinite;
animation-delay: var(--shimmer-delay, -2s);
}
&[data-holo-active] {
--card-opacity: 0.45;
.holo-shine,
.holo-glare { animation-play-state: paused; }
}
}
// -----------------------------------------------------------------------------
// 4. RARITY -> CLIP-PATH BRIDGE
// -----------------------------------------------------------------------------
.image-grow,
.card-image-wrap {
// No effect on common/uncommon or unrecognised wrapper
&[data-rarity="common"],
&[data-rarity="uncommon"],
&:not([data-rarity]) {
.holo-shine,
.holo-glare { display: none; }
}
// Standard holo — artwork area only
&[data-rarity="rare holo"] {
.holo-shine { clip-path: var(--clip); }
&[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); }
&[data-subtypes^="supporter"] .holo-shine,
&[data-subtypes^="item"] .holo-shine { clip-path: var(--clip-trainer); }
}
// Cosmos holo
&[data-rarity="rare holo cosmos"] {
.holo-shine { clip-path: var(--clip); }
&[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); }
&[data-subtypes^="supporter"] .holo-shine { clip-path: var(--clip-trainer); }
}
&[data-rarity="radiant rare"] { .holo-shine { clip-path: var(--clip-borders); } }
&[data-rarity="amazing rare"] { .holo-shine { clip-path: var(--clip); } }
&[data-rarity="trainer gallery rare holo"],
&[data-rarity="rare holo"][data-trainer-gallery="true"] {
.holo-shine { clip-path: var(--clip-borders); }
}
&[data-rarity="rare shiny"] {
.holo-shine { clip-path: var(--clip); }
&[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); }
}
// Reverse holo by rarity — borders only
&[data-rarity$="reverse holo"] { .holo-shine { clip-path: var(--clip-invert); } }
// Reverse Holofoil variant — borders only
&[data-variant="Reverse Holofoil"] { .holo-shine { clip-path: var(--clip-invert); } }
// True holofoil variants + full-bleed rarities — no clip
&[data-variant="Holofoil"],
&[data-variant="1st Edition Holofoil"],
&[data-variant="Unlimited Holofoil"],
&[data-rarity="rare ultra"],
&[data-rarity="rare holo v"],
&[data-rarity="rare holo vmax"],
&[data-rarity="rare holo vstar"],
&[data-rarity="rare shiny v"],
&[data-rarity="rare shiny vmax"],
&[data-rarity="rare rainbow"],
&[data-rarity="rare rainbow alt"],
&[data-rarity="rare secret"] {
.holo-shine { clip-path: none; }
}
// Foil variant shine/glare — clip handled above per variant type
&[data-variant="Holofoil"],
&[data-variant="Reverse Holofoil"],
&[data-variant="1st Edition Holofoil"],
&[data-variant="Unlimited Holofoil"] {
.holo-shine {
background-image:
radial-gradient(
circle at var(--pointer-x) var(--pointer-y),
#fff 5%, #000 50%, #fff 80%
),
linear-gradient(
var(--foil-angle, -45deg),
#000 15%, #fff, #000 85%
);
background-blend-mode: soft-light, difference;
background-size: 120% 120%, 200% 200%;
background-position:
center center,
calc(100% * var(--pointer-from-left)) calc(100% * var(--pointer-from-top));
filter: brightness(var(--foil-brightness, 0.4)) contrast(1.3) saturate(var(--foil-saturation, 0.5));
mix-blend-mode: color-dodge;
opacity: calc((var(--card-opacity) * 0.9) - (var(--pointer-from-center) * 0.1));
}
.holo-glare {
opacity: calc(var(--card-opacity) * 0.5);
background-image: radial-gradient(
farthest-corner circle at var(--pointer-x) var(--pointer-y),
hsla(0, 0%, 100%, 0.5) 10%,
hsla(0, 0%, 100%, 0.25) 30%,
hsla(0, 0%, 0%, 0.4) 90%
);
filter: brightness(0.7) contrast(1.2);
mix-blend-mode: overlay;
}
}
}
// -----------------------------------------------------------------------------
// 5. DEFAULT HOLO SHINE / GLARE
// Fallback for rarities not explicitly handled above.
// -----------------------------------------------------------------------------
.image-grow,
.card-image-wrap {
&[data-rarity]:not([data-rarity="common"]):not([data-rarity="uncommon"]) {
.holo-shine {
background-image:
repeating-linear-gradient(110deg,
var(--violet), var(--blue), var(--green), var(--yellow), var(--red),
var(--violet), var(--blue), var(--green), var(--yellow), var(--red)
);
background-position:
calc(((50% - var(--background-x)) * 2.6) + 50%)
calc(((50% - var(--background-y)) * 3.5) + 50%);
background-size: 400% 400%;
filter: brightness(0.7) contrast(0.9) saturate(0.8);
mix-blend-mode: color-dodge;
opacity: calc(var(--card-opacity) * 0.6);
}
.holo-glare {
background-image: radial-gradient(
farthest-corner circle at var(--pointer-x) var(--pointer-y),
hsla(0, 0%, 100%, 0.35) 10%,
hsla(0, 0%, 100%, 0.15) 30%,
hsla(0, 0%, 0%, 0.35) 90%
);
opacity: calc(var(--card-opacity) * 0.4);
mix-blend-mode: overlay;
filter: brightness(0.7) contrast(1.1);
}
}
}

View File

@@ -23,6 +23,9 @@ $container-max-widths: (
@import "_bootstrap";
// ── Holofoil ──────────────────────────────────────────────────────────────
//@import "_holofoil-integration"; // also pulls in _card.scss
/* --------------------------------------------------
Root Variables
-------------------------------------------------- */
@@ -292,7 +295,7 @@ $tiers: (
.card-image {
aspect-ratio: 23 / 32;
object-fit: cover;
z-index: 998;
z-index: 1;
cursor: pointer;
}
@@ -360,6 +363,7 @@ $tiers: (
bottom: 5vh;
right: 5vw;
display: none;
z-index: 2;
}
.top-icon svg {
@@ -380,12 +384,27 @@ $tiers: (
drop-shadow(0 4px 6px rgba(0, 0, 0, 0.2));
}
.tooltip.volatility-popover .tooltip-inner {
background: #1d1f21;
color: #e9ecef;
padding: 0.9rem 1rem;
border-radius: 0.6rem;
text-align: left;
max-width: 260px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.tooltip.volatility-popover .tooltip-arrow::before {
border-top-color: #1d1f21 !important;
}
/* --------------------------------------------------
Pricing
-------------------------------------------------- */
.price-row {
position: relative;
z-index: 2;
margin-top: -1.25rem;
border-radius: 0.33rem;
background: linear-gradient(
@@ -651,4 +670,4 @@ input[type="search"]::-webkit-search-cancel-button {
background-size: 1rem;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAAn0lEQVR42u3UMQrDMBBEUZ9WfQqDmm22EaTyjRMHAlM5K+Y7lb0wnUZPIKHlnutOa+25Z4D++MRBX98MD1V/trSppLKHqj9TTBWKcoUqffbUcbBBEhTjBOV4ja4l4OIAZThEOV6jHO8ARXD+gPPvKMABinGOrnu6gTNUawrcQKNCAQ7QeTxORzle3+sDfjJpPCqhJh7GixZq4rHcc9l5A9qZ+WeBhgEuAAAAAElFTkSuQmCC);
}
-------------------------------------------------- */
-------------------------------------------------- */

View File

@@ -0,0 +1,280 @@
/**
* holofoil-init.js
* -----------------------------------------------------------------------------
* Instruments .image-grow and .card-image-wrap with the holofoil effect system.
*
* GRID (.image-grow)
* Effect is invisible at rest. On hover, pointer tracking drives the shine
* and glare layers. The card lift/scale comes from main.scss as before.
*
* MODAL (.card-image-wrap)
* Effect sweeps autonomously once per minute via CSS animation.
* Pointer tracking takes over while the user hovers the image.
*
* DEFAULT FALLBACK
* If data-default="true" is set (onerror in the Astro markup), no effect
* is applied -- even if the attribute appears after stamp() has run.
* -----------------------------------------------------------------------------
*/
(function HolofoilSystem() {
'use strict';
// -- Constants --------------------------------------------------------------
const SHIMMER_SEL = [
'.image-grow[data-rarity]',
'.image-grow[data-variant="Holofoil"]',
'.image-grow[data-variant="1st Edition Holofoil"]',
'.image-grow[data-variant="Unlimited Holofoil"]',
'.image-grow[data-variant="Reverse Holofoil"]',
'.card-image-wrap[data-rarity]',
'.card-image-wrap[data-variant="Holofoil"]',
'.card-image-wrap[data-variant="1st Edition Holofoil"]',
'.card-image-wrap[data-variant="Unlimited Holofoil"]',
'.card-image-wrap[data-variant="Reverse Holofoil"]',
].join(',');
const ALL_WRAPPERS_SEL = '.image-grow, .card-image-wrap';
// Foil variant visual randomisation
const FOIL_ANGLE_MIN = -65, FOIL_ANGLE_MAX = -25;
const FOIL_BRITE_MIN = 0.18, FOIL_BRITE_MAX = 0.32;
const FOIL_SAT_MIN = 0.40, FOIL_SAT_MAX = 0.75;
const SKIP_RARITIES = new Set(['common', 'uncommon', '']);
// -- Helpers ----------------------------------------------------------------
const rand = (min, max) => parseFloat((Math.random() * (max - min) + min).toFixed(2));
const clamp01 = n => Math.max(0, Math.min(1, n));
function pointerVars(x, y, rect) {
const fromLeft = clamp01((x - rect.left) / rect.width);
const fromTop = clamp01((y - rect.top) / rect.height);
const fromCenter = clamp01(Math.sqrt((fromLeft - 0.5) ** 2 + (fromTop - 0.5) ** 2) * 2);
return {
px: fromLeft * 100,
py: fromTop * 100,
fromLeft,
fromTop,
fromCenter,
bgX: 50 + (fromLeft - 0.5) * 30,
bgY: 50 + (fromTop - 0.5) * 30,
};
}
function applyPointerVars(el, v) {
el.style.setProperty('--pointer-x', v.px.toFixed(1) + '%');
el.style.setProperty('--pointer-y', v.py.toFixed(1) + '%');
el.style.setProperty('--pointer-from-left', v.fromLeft.toFixed(3));
el.style.setProperty('--pointer-from-top', v.fromTop.toFixed(3));
el.style.setProperty('--pointer-from-center', v.fromCenter.toFixed(3));
el.style.setProperty('--background-x', v.bgX.toFixed(1) + '%');
el.style.setProperty('--background-y', v.bgY.toFixed(1) + '%');
}
const isHoloVariant = v => ['Holofoil', 'Reverse Holofoil', '1st Edition Holofoil', 'Unlimited Holofoil'].includes(v);
const isModalWrapper = el => el.classList.contains('card-image-wrap');
const isDefault = el => el.dataset.default === 'true';
// -- Child injection --------------------------------------------------------
function injectChildren(el) {
if (el.querySelector('.holo-shine')) return;
const shine = document.createElement('div');
shine.className = 'holo-shine';
const glare = document.createElement('div');
glare.className = 'holo-glare';
el.appendChild(shine);
el.appendChild(glare);
}
// -- Default image guard ----------------------------------------------------
/**
* Watch for the onerror handler in the Astro markup setting data-default="true"
* after stamp() has already run. Hide the effect children immediately when seen.
*/
function watchForDefault(el) {
if (isDefault(el)) return;
var observer = new MutationObserver(function() {
if (isDefault(el)) {
var shine = el.querySelector('.holo-shine');
var glare = el.querySelector('.holo-glare');
if (shine) shine.style.display = 'none';
if (glare) glare.style.display = 'none';
observer.disconnect();
}
});
observer.observe(el, { attributes: true, attributeFilter: ['data-default'] });
}
// -- Stamp ------------------------------------------------------------------
function stamp(el) {
if (el.dataset.holoInit) return;
// Skip if already a default fallback image
if (isDefault(el)) {
el.dataset.holoInit = 'skip';
return;
}
const rarity = (el.dataset.rarity || '').toLowerCase();
const variant = el.dataset.variant || '';
const hasHoloRarity = rarity && !SKIP_RARITIES.has(rarity);
const hasHoloVariant = isHoloVariant(variant);
if (!hasHoloRarity && !hasHoloVariant) {
el.dataset.holoInit = 'skip';
return;
}
injectChildren(el);
// Per-card foil visual randomisation (angle/brightness/saturation)
if (hasHoloVariant) {
el.style.setProperty('--foil-angle', Math.round(rand(FOIL_ANGLE_MIN, FOIL_ANGLE_MAX)) + 'deg');
el.style.setProperty('--foil-brightness', rand(FOIL_BRITE_MIN, FOIL_BRITE_MAX).toFixed(2));
el.style.setProperty('--foil-saturation', rand(FOIL_SAT_MIN, FOIL_SAT_MAX ).toFixed(2));
}
// Modal-only: set a stable delay offset for the autonomous CSS animation
if (isModalWrapper(el)) {
el.classList.add('holo-modal-mode');
el.style.setProperty('--shimmer-delay', rand(-8, 0) + 's');
}
watchForDefault(el);
el.dataset.holoInit = '1';
}
function stampAll(root) {
(root || document).querySelectorAll(ALL_WRAPPERS_SEL).forEach(stamp);
}
// -- Pointer tracking -------------------------------------------------------
const pointerState = new WeakMap();
function onPointerEnter(e) {
const el = e.currentTarget;
if (el.dataset.holoInit !== '1' || isDefault(el)) return;
el.dataset.holoActive = '1';
if (!pointerState.has(el)) pointerState.set(el, { rafId: null });
}
function onPointerMove(e) {
const el = e.currentTarget;
if (el.dataset.holoInit !== '1') return;
const state = pointerState.get(el);
if (!state) return;
if (state.rafId) cancelAnimationFrame(state.rafId);
state.rafId = requestAnimationFrame(function() {
const rect = el.getBoundingClientRect();
applyPointerVars(el, pointerVars(e.clientX, e.clientY, rect));
state.rafId = null;
});
}
function onPointerLeave(e) {
const el = e.currentTarget;
if (el.dataset.holoInit !== '1') return;
const state = pointerState.get(el);
if (state && state.rafId) { cancelAnimationFrame(state.rafId); state.rafId = null; }
delete el.dataset.holoActive;
if (isModalWrapper(el)) {
// Let the CSS animation resume driving --card-opacity
el.style.removeProperty('--card-opacity');
}
}
function attachListeners(el) {
if (el.dataset.holoListeners) return;
el.addEventListener('pointerenter', onPointerEnter, { passive: true });
el.addEventListener('pointermove', onPointerMove, { passive: true });
el.addEventListener('pointerleave', onPointerLeave, { passive: true });
el.dataset.holoListeners = '1';
}
function attachAllListeners(root) {
(root || document).querySelectorAll(SHIMMER_SEL).forEach(function(el) {
stamp(el);
if (el.dataset.holoInit === '1') attachListeners(el);
});
}
// -- MutationObserver: react to HTMX / infinite scroll ----------------------
function observeGrid() {
var grid = document.getElementById('cardGrid');
if (!grid) return;
new MutationObserver(function(mutations) {
for (var i = 0; i < mutations.length; i++) {
var nodes = mutations[i].addedNodes;
for (var j = 0; j < nodes.length; j++) {
var node = nodes[j];
if (node.nodeType !== 1) continue;
if (node.matches && node.matches(ALL_WRAPPERS_SEL)) {
stamp(node);
if (node.dataset.holoInit === '1') attachListeners(node);
}
if (node.querySelectorAll) {
node.querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) {
stamp(el);
if (el.dataset.holoInit === '1') attachListeners(el);
});
}
}
}
}).observe(grid, { childList: true, subtree: true });
}
function observeModal() {
var modal = document.getElementById('cardModal');
if (!modal) return;
new MutationObserver(function() {
modal.querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) {
stamp(el);
if (el.dataset.holoInit === '1') attachListeners(el);
});
}).observe(modal, { childList: true, subtree: true });
}
// -- Bootstrap --------------------------------------------------------------
function init() {
stampAll();
attachAllListeners();
observeGrid();
observeModal();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -1,6 +1,6 @@
import * as bootstrap from 'bootstrap';
window.bootstrap = bootstrap;
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
// trap browser back and close the modal if open
const cardModal = document.getElementById('cardModal');
@@ -24,4 +24,29 @@ cardModal.addEventListener('hide.bs.modal', () => {
if (history.state && history.state.modalOpen) {
history.back();
}
});
});
import { Tooltip } from "bootstrap";
// Initialize all tooltips globally
const initTooltips = () => {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
if (!el._tooltipInstance) {
el._tooltipInstance = new Tooltip(el, {
container: 'body', // ensures tooltip is appended to body, important for modals
});
}
});
};
// Run on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTooltips);
} else {
initTooltips();
}
// Optional: observe DOM changes for dynamically added tooltips (e.g., modals loaded later)
const observer = new MutationObserver(() => initTooltips());
observer.observe(document.body, { childList: true, subtree: true });

251
src/assets/js/priceChart.js Normal file
View File

@@ -0,0 +1,251 @@
import Chart from 'chart.js/auto';
const CONDITIONS = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
const CONDITION_COLORS = {
"Near Mint": { active: 'rgba(156, 204, 102, 1)', muted: 'rgba(156, 204, 102, 0.67)' },
"Lightly Played": { active: 'rgba(211, 225, 86, 1)', muted: 'rgba(211, 225, 86, 0.67)' },
"Moderately Played": { active: 'rgba(255, 238, 87, 1)', muted: 'rgba(255, 238, 87, 0.67)' },
"Heavily Played": { active: 'rgba(255, 201, 41, 1)', muted: 'rgba(255, 201, 41, 0.67)' },
"Damaged": { active: 'rgba(255, 167, 36, 1)', muted: 'rgba(255, 167, 36, 0.67)' },
};
const RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '1y': 365, 'all': Infinity };
let chartInstance = null;
let allHistory = [];
let activeCondition = "Near Mint";
let activeRange = '1m';
function formatDate(dateStr) {
const [year, month, day] = dateStr.split('-');
const d = new Date(Number(year), Number(month) - 1, Number(day));
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function setEmptyState(isEmpty) {
const modal = document.getElementById('cardModal');
const empty = modal?.querySelector('#priceHistoryEmpty');
const canvasWrapper = empty?.nextElementSibling;
if (!empty || !canvasWrapper) return;
empty.classList.toggle('d-none', !isEmpty);
canvasWrapper.classList.toggle('d-none', isEmpty);
}
function buildChartData(history, rangeKey) {
const cutoff = RANGE_DAYS[rangeKey] === Infinity
? new Date(0)
: new Date(Date.now() - RANGE_DAYS[rangeKey] * 86_400_000);
const filtered = history.filter(r => new Date(r.calculatedAt) >= cutoff);
// Always build the full date axis for the selected window, even if sparse.
// Generate one label per day in the range so the x-axis reflects the
// chosen period rather than collapsing to only the days that have data.
const dataDateSet = new Set(filtered.map(r => r.calculatedAt));
const allDates = [...dataDateSet].sort((a, b) => new Date(a) - new Date(b));
// If we have real data, expand the axis to span from cutoff → today so
// empty stretches at the start/end of a range are visible.
let axisLabels = allDates;
if (allDates.length > 0 && RANGE_DAYS[rangeKey] !== Infinity) {
const start = new Date(cutoff);
const end = new Date();
const expanded = [];
// Step through every day in the window
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
expanded.push(d.toISOString().split('T')[0]);
}
axisLabels = expanded;
}
const labels = axisLabels.map(formatDate);
const lookup = {};
for (const row of filtered) {
if (!lookup[row.condition]) lookup[row.condition] = {};
lookup[row.condition][row.calculatedAt] = Number(row.marketPrice);
}
const activeConditionHasData = allDates.some(
date => lookup[activeCondition]?.[date] != null
);
const datasets = CONDITIONS.map(condition => {
const isActive = condition === activeCondition;
const colors = CONDITION_COLORS[condition];
const data = axisLabels.map(date => lookup[condition]?.[date] ?? null);
return {
label: condition,
data,
borderColor: isActive ? colors.active : colors.muted,
borderWidth: isActive ? 2 : 1,
pointRadius: isActive ? 2.5 : 0,
pointHoverRadius: isActive ? 5 : 3,
pointBackgroundColor: isActive ? colors.active : colors.muted,
tension: 0.3,
fill: false,
spanGaps: true,
order: isActive ? 0 : 1,
};
});
return {
labels,
datasets,
hasData: allDates.length > 0,
activeConditionHasData,
};
}
function updateChart() {
if (!chartInstance) return;
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
// Always push the new labels/datasets to the chart so the x-axis
// reflects the selected time window — even when there's no data for
// the active condition. Then toggle the empty state overlay on top.
chartInstance.data.labels = labels;
chartInstance.data.datasets = datasets;
chartInstance.update('none');
// Show the empty state overlay if the active condition has no points
// in this window, but leave the (empty) chart visible underneath so
// the axis communicates the selected period.
setEmptyState(!hasData || !activeConditionHasData);
}
function initPriceChart(canvas) {
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
try {
allHistory = JSON.parse(canvas.dataset.history ?? '[]');
} catch (err) {
console.error('Failed to parse price history:', err);
return;
}
if (!allHistory.length) {
setEmptyState(true);
return;
}
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
// Render the chart regardless — show empty state overlay if needed
setEmptyState(!hasData || !activeConditionHasData);
chartInstance = new Chart(canvas.getContext('2d'), {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.85)',
titleColor: 'rgba(255, 255, 255, 0.9)',
bodyColor: 'rgba(255, 255, 255, 0.75)',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
padding: 10,
callbacks: {
labelColor: (ctx) => {
const colors = CONDITION_COLORS[ctx.dataset.label];
return {
borderColor: colors.active,
backgroundColor: colors.active,
};
},
label: (ctx) => {
const isActive = ctx.dataset.label === activeCondition;
const price = ctx.parsed.y != null ? `$${ctx.parsed.y.toFixed(2)}` : '—';
return isActive
? ` ${ctx.dataset.label}: ${price}`
: ` ${ctx.dataset.label}: ${price}`;
}
}
}
},
scales: {
x: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: {
color: 'rgba(255, 255, 255, 0.4)',
maxTicksLimit: 6,
maxRotation: 0,
},
border: { color: 'rgba(255, 255, 255, 0.1)' },
},
y: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: {
color: 'rgba(255, 255, 255, 0.4)',
callback: (val) => `$${Number(val).toFixed(2)}`,
},
border: { color: 'rgba(255, 255, 255, 0.1)' },
}
}
}
});
}
function initFromCanvas(canvas) {
activeCondition = "Near Mint";
activeRange = '1m';
const modal = document.getElementById('cardModal');
modal?.querySelectorAll('.price-range-btn').forEach(b => {
b.classList.toggle('active', b.dataset.range === '1m');
});
initPriceChart(canvas);
}
function setup() {
const modal = document.getElementById('cardModal');
if (!modal) return;
modal.addEventListener('card-modal:swapped', () => {
const canvas = modal.querySelector('#priceHistoryChart');
if (canvas) initFromCanvas(canvas);
});
modal.addEventListener('hidden.bs.modal', () => {
if (chartInstance) { chartInstance.destroy(); chartInstance = null; }
allHistory = [];
});
document.addEventListener('shown.bs.tab', (e) => {
if (!modal.contains(e.target)) return;
const target = e.target?.getAttribute('data-bs-target');
const conditionMap = {
'#nav-nm': 'Near Mint',
'#nav-lp': 'Lightly Played',
'#nav-mp': 'Moderately Played',
'#nav-hp': 'Heavily Played',
'#nav-dmg': 'Damaged',
};
if (target && conditionMap[target]) {
activeCondition = conditionMap[target];
updateChart();
}
});
document.addEventListener('click', (e) => {
const btn = e.target?.closest('.price-range-btn');
if (!btn || !modal.contains(btn)) return;
modal.querySelectorAll('.price-range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeRange = btn.dataset.range ?? '1m';
updateChart();
});
}
setup();

View File

@@ -15,19 +15,9 @@ import BackToTop from "./BackToTop.astro"
</div>
</div>
<div class="col-sm-12 col-md-10 mt-0">
<div class="d-flex flex-row align-items-center mb-2">
<div id="sortBy" class="mb-2 d-flex align-items-center justify-content-start small d-none">
<button class="btn btn-sm btn-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Sort by</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item" href="#">Price: High to Low</a></li>
<li><a class="dropdown-item" href="#">Price: Low to High</a></li>
<li><a class="dropdown-item" href="#">Set: Newest to Oldest</a></li>
<li><a class="dropdown-item" href="#">Set: Oldest to Newest</a></li>
<li><a class="dropdown-item" href="#">Card Number: Ascending</a></li>
<li><a class="dropdown-item" href="#">Card Number: Descending</a></li>
</ul>
<div id="totalResults"></div>
</div>
<div class="d-flex flex-column gap-1 flex-md-row align-items-center mb-3 w-100 justify-content-start">
<div id="sortBy"></div>
<div id="totalResults"></div>
<div id="activeFilters"></div>
</div>
<div id="cardGrid" aria-live="polite" class="row g-xxl-3 g-2 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5"></div>
@@ -41,7 +31,6 @@ import BackToTop from "./BackToTop.astro"
</div>
</div>
<!-- Modal nav buttons, rendered outside modal-content so they survive htmx swaps -->
<button id="modalPrevBtn" class="modal-nav-btn modal-nav-prev d-none" aria-label="Previous card">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"/>
@@ -55,10 +44,146 @@ import BackToTop from "./BackToTop.astro"
<BackToTop />
<!-- <script src="src/assets/js/holofoil-init.js" is:inline></script> -->
<script is:inline>
(function () {
// ── State ────────────────────────────────────────────────────────────────
// ── Sort dropdown ─────────────────────────────────────────────────────────
document.addEventListener('click', (e) => {
const sortBy = document.getElementById('sortBy');
const btn = e.target.closest('#sortBy [data-bs-toggle="dropdown"]');
if (btn) {
e.preventDefault();
e.stopPropagation();
const menu = btn.nextElementSibling;
menu.classList.toggle('show');
btn.setAttribute('aria-expanded', menu.classList.contains('show'));
return;
}
const opt = e.target.closest('#sortBy .sort-option');
if (opt) {
e.preventDefault();
const menu = opt.closest('.dropdown-menu');
const btn2 = menu?.previousElementSibling;
menu?.classList.remove('show');
if (btn2) btn2.setAttribute('aria-expanded', 'false');
const sortInput = document.getElementById('sortInput');
if (sortInput) sortInput.value = opt.dataset.sort;
document.getElementById('sortLabel').textContent = opt.dataset.label;
document.querySelectorAll('.sort-option').forEach(o => o.classList.remove('active'));
opt.classList.add('active');
const start = document.getElementById('start');
if (start) start.value = '0';
document.getElementById('searchform').dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true })
);
return;
}
const menu = document.querySelector('#sortBy .dropdown-menu.show');
if (menu) {
menu.classList.remove('show');
const btn3 = menu.previousElementSibling;
if (btn3) btn3.setAttribute('aria-expanded', 'false');
}
});
// ── Language toggle ───────────────────────────────────────────────────────
document.addEventListener('click', (e) => {
const btn = e.target.closest('.language-btn');
if (!btn) return;
e.preventDefault();
const input = document.getElementById('languageInput');
if (input) input.value = btn.dataset.lang;
const start = document.getElementById('start');
if (start) start.value = '0';
document.getElementById('searchform').dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true })
);
});
// ── Global helpers ────────────────────────────────────────────────────────
window.copyImage = async function(img) {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
// Load with crossOrigin so toBlob() stays untainted
await new Promise((resolve) => {
const clean = new Image();
clean.crossOrigin = 'anonymous';
clean.onload = () => { ctx.drawImage(clean, 0, 0); resolve(); };
clean.onerror = () => { ctx.drawImage(img, 0, 0); resolve(); };
clean.src = img.src;
});
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
});
if (isIOS) {
const file = new File([blob], 'card.png', { type: 'image/png' });
await navigator.share({ files: [file] });
return;
}
if (navigator.clipboard && navigator.clipboard.write) {
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
showCopyToast('📋 Image copied!', '#198754');
} else {
const url = img.src;
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(url);
showCopyToast('📋 Image URL copied!', '#198754');
} else {
const input = document.createElement('input');
input.value = url;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
showCopyToast('📋 Image URL copied!', '#198754');
}
}
} catch (err) {
if (err.name === 'AbortError') return;
console.error('Failed:', err);
showCopyToast('❌ Copy failed', '#dc3545');
}
};
function showCopyToast(message, color) {
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: ${color}; color: white; padding: 10px 20px;
border-radius: 8px; font-size: 14px; z-index: 9999;
opacity: 0; transition: opacity 0.2s ease;
pointer-events: none;
`;
document.body.appendChild(toast);
requestAnimationFrame(() => toast.style.opacity = '1');
setTimeout(() => {
toast.style.opacity = '0';
toast.addEventListener('transitionend', () => toast.remove());
}, 2000);
}
// ── State ─────────────────────────────────────────────────────────────────
const cardIndex = [];
let currentCardId = null;
let isNavigating = false;
@@ -109,7 +234,6 @@ import BackToTop from "./BackToTop.astro"
nextBtn.classList.toggle('d-none', next === null);
}
// ── Trigger infinite scroll sentinel ─────────────────────────────────────
function tryTriggerSentinel() {
const sentinel = cardGrid.querySelector('[hx-trigger="revealed"]');
if (!sentinel) return;
@@ -120,6 +244,14 @@ import BackToTop from "./BackToTop.astro"
}
}
function initChartAfterSwap(modal) {
const canvas = modal.querySelector('#priceHistoryChart');
if (!canvas) return;
requestAnimationFrame(() => {
modal.dispatchEvent(new CustomEvent('card-modal:swapped', { bubbles: false }));
});
}
async function loadCard(cardId, direction = null) {
if (!cardId || isNavigating) return;
isNavigating = true;
@@ -130,16 +262,18 @@ import BackToTop from "./BackToTop.astro"
const url = `/partials/card-modal?cardId=${cardId}`;
const { idx, total } = getAdjacentIds();
if (idx >= total - 3) {
tryTriggerSentinel();
}
if (idx >= total - 3) tryTriggerSentinel();
const doSwap = async () => {
const response = await fetch(url);
const html = await response.text();
if (modal._reconnectChartObserver) modal._reconnectChartObserver();
modal.innerHTML = html;
if (typeof htmx !== 'undefined') htmx.process(modal);
updateNavButtons(modal);
initChartAfterSwap(modal);
};
if (document.startViewTransition && direction) {
@@ -153,9 +287,7 @@ import BackToTop from "./BackToTop.astro"
isNavigating = false;
const { idx: newIdx, total: newTotal } = getAdjacentIds();
if (newIdx >= newTotal - 3) {
tryTriggerSentinel();
}
if (newIdx >= newTotal - 3) tryTriggerSentinel();
}
function navigatePrev() {
@@ -168,11 +300,9 @@ import BackToTop from "./BackToTop.astro"
if (next) loadCard(next, 'next');
}
// ── Nav button clicks ─────────────────────────────────────────────────────
document.getElementById('modalPrevBtn').addEventListener('click', navigatePrev);
document.getElementById('modalNextBtn').addEventListener('click', navigateNext);
// ── Keyboard ──────────────────────────────────────────────────────────────
document.addEventListener('keydown', (e) => {
const modal = document.getElementById('cardModal');
if (!modal.classList.contains('show')) return;
@@ -180,7 +310,6 @@ import BackToTop from "./BackToTop.astro"
if (e.key === 'ArrowRight') { e.preventDefault(); navigateNext(); }
});
// ── Touch / swipe ─────────────────────────────────────────────────────────
let touchStartX = 0;
let touchStartY = 0;
const SWIPE_THRESHOLD = 50;
@@ -198,7 +327,6 @@ import BackToTop from "./BackToTop.astro"
else navigatePrev();
}, { passive: true });
// ── Hook into HTMX card-modal opens ──────────────────────────────────────
document.body.addEventListener('htmx:beforeRequest', async (e) => {
if (e.detail.elt.getAttribute('hx-target') !== '#cardModal') return;
@@ -213,24 +341,29 @@ import BackToTop from "./BackToTop.astro"
const target = document.getElementById('cardModal');
const sourceImg = cardEl?.querySelector('img');
// ── Fetch first, THEN transition ──────────────────────────────────────
const response = await fetch(url, { headers: { 'HX-Request': 'true' } });
if (!response.ok) throw new Error(`Failed to load modal: ${response.status}`);
const html = await response.text();
const transitionName = `card-hero-${currentCardId}`;
try {
if (sourceImg) {
sourceImg.style.viewTransitionName = 'card-hero';
sourceImg.style.opacity = '0'; // hide original immediately after capture
sourceImg.style.viewTransitionName = transitionName;
sourceImg.style.opacity = '0';
}
const transition = document.startViewTransition(async () => {
if (sourceImg) sourceImg.style.viewTransitionName = '';
if (target._reconnectChartObserver) target._reconnectChartObserver();
target.innerHTML = html;
if (typeof htmx !== 'undefined') htmx.process(target);
const destImg = target.querySelector('img.card-image');
if (destImg) {
destImg.style.viewTransitionName = 'card-hero';
destImg.style.viewTransitionName = transitionName;
if (!destImg.complete) {
await new Promise(resolve => {
destImg.addEventListener('load', resolve, { once: true });
@@ -242,6 +375,7 @@ import BackToTop from "./BackToTop.astro"
await transition.finished;
updateNavButtons(target);
initChartAfterSwap(target);
} catch (err) {
console.error('[card-modal] transition failed:', err);
@@ -249,22 +383,27 @@ import BackToTop from "./BackToTop.astro"
} finally {
if (sourceImg) {
sourceImg.style.viewTransitionName = '';
sourceImg.style.opacity = ''; // restore after transition
sourceImg.style.opacity = '';
}
const destImg = target.querySelector('img.card-image');
if (destImg) destImg.style.viewTransitionName = '';
}
});
// ── Show/hide nav buttons with Bootstrap modal events ────────────────────
const cardModal = document.getElementById('cardModal');
cardModal.addEventListener('shown.bs.modal', () => {
updateNavButtons(cardModal);
initChartAfterSwap(cardModal);
});
cardModal.addEventListener('hidden.bs.modal', () => {
currentCardId = null;
updateNavButtons(null);
});
// ── AdSense re-init on infinite scroll ───────────────────────────────────
document.addEventListener('htmx:afterSwap', () => {
(window.adsbygoogle = window.adsbygoogle || []).push({});
});
})();
</script>

View File

@@ -0,0 +1,7 @@
<div class="d-none d-xl-block sticky-top mt-5" style="top: 70px;">
<ins class="adsbygoogle"
style="display:block"
data-ad-format="autorelaxed"
data-ad-client="ca-pub-1140571217687341"
data-ad-slot="8889263515"></ins>
</div>

View File

@@ -26,15 +26,21 @@ import { Show } from '@clerk/astro/components'
</script>
<Show when="signed-in">
<form class="d-flex ms-2" role="search" id="searchform" hx-post="/partials/cards" hx-target="#cardGrid" hx-trigger="load, submit" hx-vals='{"start":"0"}' hx-on--after-request="afterUpdate()" hx-on--before-request="beforeSearch()">
<a class="btn btn-secondary btn-lg me-2" data-bs-toggle="offcanvas" href="#filterBar" role="button" aria-controls="filterBar" aria-label="filter"><span class="d-block d-md-none filter-icon mt-1"><svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg></span><span class="d-none d-md-block">Filters</span></a>
<div class="input-group">
<input type="hidden" name="start" id="start" value="0" />
<input type="search" name="q" class="form-control form-control-lg" placeholder="Search cards..." />
<button type="submit" class="btn btn-danger btn-lg border border-danger-subtle border-start-0" aria-label="search" value="" onclick="const q = this.closest('form').querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });">
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M432 272C432 183.6 360.4 112 272 112C183.6 112 112 183.6 112 272C112 360.4 183.6 432 272 432C360.4 432 432 360.4 432 272zM401.1 435.1C365.7 463.2 320.8 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272C480 320.8 463.2 365.7 435.1 401.1L569 535C578.4 544.4 578.4 558.1 569 567.5C559.6 575.9 544.4 575.9 536 567.5L401.1 435.1z"/></svg>
</button>
</div>
</form>
</Show>
<form class="d-flex ms-2 align-items-center gap-2" role="search" id="searchform" hx-post="/partials/cards" hx-target="#cardGrid" hx-trigger="load, submit" hx-vals='{"start":"0"}' hx-on--after-request="afterUpdate()" hx-on--before-request="beforeSearch()">
<a class="btn btn-secondary btn-lg" data-bs-toggle="offcanvas" href="#filterBar" role="button" aria-controls="filterBar" aria-label="filter">
<span class="d-block d-md-none filter-icon py-2">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg>
</span>
<span class="d-none d-md-block">Filters</span>
</a>
<div class="input-group">
<input type="hidden" name="start" id="start" value="0" />
<input type="hidden" name="sort" id="sortInput" value="" />
<input type="hidden" name="language" id="languageInput" value="all" />
<input type="search" name="q" class="form-control form-control-lg" placeholder="Search cards..." />
<button type="submit" class="btn btn-danger btn-lg border border-danger-subtle border-start-0" aria-label="search" value="" onclick="const q = this.closest('form').querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });">
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M432 272C432 183.6 360.4 112 272 112C183.6 112 112 183.6 112 272C112 360.4 183.6 432 272 432C360.4 432 432 360.4 432 272zM401.1 435.1C365.7 463.2 320.8 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272C480 320.8 463.2 365.7 435.1 401.1L569 535C578.4 544.4 578.4 558.1 569 567.5C559.6 575.9 544.4 575.9 536 567.5L401.1 435.1z"/></svg>
</button>
</div>
</form>

View File

@@ -124,6 +124,7 @@ import phantasmal_flames from "/src/svg/set/phantasmal_flames.svg?raw";
import destined_rivals from "/src/svg/set/destined_rivals.svg?raw";
import surging_sparks from "/src/svg/set/surging_sparks.svg?raw";
import team_rocket from "/src/svg/set/team_rocket.svg?raw";
import perfect_order from "/src/svg/set/perfect_order.svg?raw";
const { set } = Astro.props;
@@ -252,6 +253,7 @@ const setMap = {
"ASC": ascended_heroes,
"DRI": destined_rivals,
"SSP": surging_sparks,
"ME03": perfect_order,
};
const svg = setMap[set as keyof typeof setMap] ?? "";

View File

@@ -1,11 +1,24 @@
// src/db/index.ts
import 'dotenv/config';
import { relations } from './relations.ts';
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
//export const poolConnection = mysql.createPool({ uri: process.env.DATABASE_URL, client_found_rows: false });
export const poolConnection = mysql.createPool({ uri: process.env.DATABASE_URL, flags: ["-FOUND_ROWS"] });
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
export const db = drizzle({ client: poolConnection, relations: relations});
// Handle pool errors to prevent connection corruption
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
});
export const db = drizzle({ client: pool, relations: relations, casing: 'snake_case' });
export type DBInstance = typeof db;
export const ClosePool = () => {
pool.end();
}

View File

@@ -2,11 +2,25 @@ import { defineRelations } from "drizzle-orm";
import * as schema from "./schema.ts";
export const relations = defineRelations(schema, (r) => ({
priceHistory: {
sku: r.one.skus({
from: r.priceHistory.skuId,
to: r.skus.skuId,
}),
},
salesHistory: {
sku: r.one.skus({
from: r.salesHistory.skuId,
to: r.skus.skuId,
}),
},
skus: {
card: r.one.cards({
from: [r.skus.productId, r.skus.variant],
to: [r.cards.productId, r.cards.variant],
}),
history: r.many.priceHistory(),
latestSales: r.many.salesHistory(),
},
cards: {
prices: r.many.skus(),

View File

@@ -1,22 +1,25 @@
import { mysqlTable, int, varchar, boolean, decimal, datetime, index } from "drizzle-orm/mysql-core"
//import { mysqlTable, int, varchar, boolean, decimal, datetime, index } from "drizzle-orm/mysql-core"
import { integer, varchar, boolean, decimal, timestamp, index, pgSchema, uniqueIndex, primaryKey } from "drizzle-orm/pg-core";
export const tcgcards = mysqlTable("tcgcards", {
productId: int().primaryKey(),
export const pokeSchema = pgSchema("pokemon");
export const tcgcards = pokeSchema.table('tcg_cards', {
productId: integer().primaryKey(),
productName: varchar({ length: 255 }).notNull(),
productLineName: varchar({ length: 255 }).default("").notNull(),
productLineUrlName: varchar({ length: 255 }).default("").notNull(),
productStatusId: int().default(0).notNull(),
productTypeId: int().default(0).notNull(),
productStatusId: integer().default(0).notNull(),
productTypeId: integer().default(0).notNull(),
productUrlName: varchar({ length: 255 }).default("").notNull(),
rarityName: varchar({ length: 100 }).default("").notNull(),
sealed: boolean().default(false).notNull(),
sellerListable: boolean().default(false).notNull(),
setId: int(),
shippingCategoryId: int(),
setId: integer(),
shippingCategoryId: integer(),
duplicate: boolean().default(false).notNull(),
foilOnly: boolean().default(false).notNull(),
maxFulfillableQuantity: int(),
totalListings: int(),
maxFulfillableQuantity: integer(),
totalListings: integer(),
score: decimal({ precision: 10, scale: 2, mode: 'number' }),
lowestPrice: decimal({ precision: 10, scale: 2, mode: 'number' }),
lowestPriceWithShipping: decimal({ precision: 10, scale: 2, mode: 'number' }),
@@ -30,73 +33,97 @@ export const tcgcards = mysqlTable("tcgcards", {
cardTypeB: varchar({ length: 100 }),
energyType: varchar({ length: 100 }),
flavorText: varchar({ length: 1000 }),
hp: int(),
hp: integer(),
number: varchar({ length: 50 }).default("").notNull(),
releaseDate: datetime(),
releaseDate: timestamp(),
resistance: varchar({ length: 100 }),
retreatCost: varchar({ length: 100 }),
stage: varchar({ length: 100 }),
weakness: varchar({ length: 100 }),
Artist: varchar({ length: 255 }),
artist: varchar({ length: 255 }),
});
export const cards = mysqlTable("cards", {
cardId: int().notNull().primaryKey().autoincrement(),
productId: int().notNull(),
export const cards = pokeSchema.table('cards', {
cardId: integer().notNull().primaryKey().generatedAlwaysAsIdentity(),
productId: integer().notNull(),
variant: varchar({ length: 100 }).notNull(),
productName: varchar({ length: 255 }),
productLineName: varchar({ length: 255 }),
productUrlName: varchar({ length: 255 }).default("").notNull(),
rarityName: varchar({ length: 100 }),
sealed: boolean().default(false).notNull(),
setId: int(),
setId: integer(),
cardType: varchar({ length: 100 }),
energyType: varchar({ length: 100 }),
number: varchar({ length: 50 }),
Artist: varchar({ length: 255 }),
artist: varchar({ length: 255 }),
},
(table) => [
index("card_productIdIdx").on(table.productId, table.variant),
index('idx_card_product_id').on(table.productId, table.variant),
]);
export const tcg_overrides = mysqlTable("tcg_overrides", {
productId: int().primaryKey(),
export const tcg_overrides = pokeSchema.table('tcg_overrides', {
productId: integer().primaryKey(),
productName: varchar({ length: 255 }),
productLineName: varchar({ length: 255 }),
productUrlName: varchar({ length: 255 }).default("").notNull(),
productUrlName: varchar({ length: 255 }).default('').notNull(),
rarityName: varchar({ length: 100 }),
sealed: boolean().default(false).notNull(),
setId: int(),
setId: integer(),
cardType: varchar({ length: 100 }),
energyType: varchar({ length: 100 }),
number: varchar({ length: 50 }),
Artist: varchar({ length: 255 }),
artist: varchar({ length: 255 }),
});
export const sets = mysqlTable("sets", {
setId: int().primaryKey(),
export const sets = pokeSchema.table('sets', {
setId: integer().primaryKey(),
setName: varchar({ length: 255 }).notNull(),
setUrlName: varchar({ length: 255 }).notNull(),
setCode: varchar({ length: 100 }).notNull(),
});
export const skus = mysqlTable("skus", {
skuId: int().primaryKey(),
cardId: int().default(0).notNull(),
productId: int().notNull(),
export const skus = pokeSchema.table('skus', {
skuId: integer().primaryKey(),
cardId: integer().default(0).notNull(),
productId: integer().notNull(),
condition: varchar({ length: 255 }).notNull(),
language: varchar({ length: 100 }).notNull(),
variant: varchar({ length: 100 }).notNull(),
calculatedAt: datetime(),
calculatedAt: timestamp(),
highestPrice: decimal({ precision: 10, scale: 2 }),
lowestPrice: decimal({ precision: 10, scale: 2 }),
marketPrice: decimal({ precision: 10, scale: 2 }),
priceCount: int(),
priceCount: integer(),
},
(table) => [
index("productIdIdx").on(table.productId, table.variant),
index('idx_product_id_condition').on(table.productId, table.variant, table.condition),
]);
export const processingSkus = mysqlTable("processingSkus", {
skuId: int().primaryKey(),
export const priceHistory = pokeSchema.table('price_history', {
skuId: integer().notNull(),
calculatedAt: timestamp().notNull(),
marketPrice: decimal({ precision: 10, scale: 2 }),
},
(table) => [
primaryKey({ name: 'pk_price_history', columns: [table.skuId, table.calculatedAt] })
]);
export const salesHistory = pokeSchema.table('sales_history',{
skuId: integer().notNull(),
orderDate: timestamp().notNull(),
title: varchar({ length: 255 }),
customListingId: varchar({ length: 255 }),
language: varchar({ length: 100 }),
listingType: varchar({ length: 100 }),
purchasePrice: decimal({ precision: 10, scale: 2 }),
quantity: integer(),
shippingPrice: decimal({ precision: 10, scale: 2 })
},
(table) => [
primaryKey({ name: 'pk_sales_history', columns: [table.skuId, table.orderDate] })
]);
export const processingSkus = pokeSchema.table('processing_skus', {
skuId: integer().primaryKey(),
});

View File

@@ -16,6 +16,7 @@ const { title } = Astro.props;
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="htmx-config" content='{"historyCacheSize": 50}'/>
<meta name="google-adsense-account" content="ca-pub-1140571217687341">
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<title>{title}</title>
@@ -38,7 +39,8 @@ const { title } = Astro.props;
</div>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
<script src="../assets/js/main.js"></script>
<script>import '../assets/js/priceChart.js';</script>
</body>
</html>
</html>

View File

@@ -1,17 +1,45 @@
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server';
import type { AstroMiddlewareRequest, AstroMiddlewareResponse } from 'astro';
import { clerkMiddleware, createRouteMatcher, clerkClient } from '@clerk/astro/server';
const isProtectedRoute = createRouteMatcher([
'/pokemon',
]);
const isProtectedRoute = createRouteMatcher(['/pokemon']);
const isAdminRoute = createRouteMatcher(['/admin']);
export const onRequest = clerkMiddleware((auth, context) => {
const { isAuthenticated, redirectToSignIn } = auth()
const TARGET_ORG_ID = "org_3Baav9czkRLLlC7g89oJWqRRulK";
export const onRequest = clerkMiddleware(async (auth, context) => {
const { isAuthenticated, userId, redirectToSignIn } = auth();
if (!isAuthenticated && isProtectedRoute(context.request)) {
// Add custom logic to run before redirecting
return redirectToSignIn()
return redirectToSignIn();
}
});
if (isAdminRoute(context.request)) {
if (!isAuthenticated || !userId) {
return redirectToSignIn();
}
try {
const client = await clerkClient(context); // pass context here
const memberships = await client.organizations.getOrganizationMembershipList({
organizationId: TARGET_ORG_ID,
});
console.log("Total memberships found:", memberships.data.length);
console.log("Current userId:", userId);
console.log("Memberships:", JSON.stringify(memberships.data.map(m => ({
userId: m.publicUserData?.userId,
role: m.role,
})), null, 2));
const userMembership = memberships.data.find(
(m) => m.publicUserData?.userId === userId
);
if (!userMembership || userMembership.role !== "org:admin") {
return context.redirect("/");
}
} catch (e) {
console.error("Clerk membership check failed:", e);
return context.redirect("/");
}
}
});

View File

@@ -6,9 +6,6 @@ import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro';
import pokedexList from '../data/pokedex.json';
const searchParams = Astro.url.searchParams;
const query = searchParams.get('q') || '*';
// Get random # (00011025)
const randomNumber = String(Math.floor(Math.random() * 1025) + 1).padStart(4, "0");
@@ -34,7 +31,7 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
</p>
</div>
<div class="col-12 col-md-5 offset-md-1">
<div class="alert alert-warning border p-2" role="alert">
<div id="reveal-hint" class="alert alert-warning border p-2" role="alert">
<h4 class="alert-heading">Who's that Pokémon?</h4>
<p class="mb-0">Click the image to reveal.</p>
</div>
@@ -47,12 +44,14 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
<img class="w-100 starburst top-0 bottom-0 left-0 right-0" src="/404/glow.png" alt="" />
<img
class="m-auto position-absolute w-75 top-0 left-25 bottom-10 right-0 d-block img-fluid masked-image top-50 start-50 translate-middle"
class="m-auto position-absolute w-75 top-0 left-25 bottom-10 right-0 d-block img-fluid masked-image top-50 start-50 translate-middle pokemon-clickable"
src={pokedexImage}
alt={pokemonName}
alt=""
data-name={pokemonName}
role="button"
tabindex="0"
draggable="false"
aria-label="Reveal the Pokémon"
/>
</div>
</div>
@@ -60,21 +59,97 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
<!-- Pokémon name reveal -->
<div class="col-12 text-center mt-3">
<h3 id="pokemon-name" class="opacity-0 transition-opacity">???</h3>
<h3
id="pokemon-name"
class="opacity-0 pokemon-transition"
aria-live="polite"
aria-atomic="true"
>???</h3>
<button
id="play-again"
class="btn btn-primary mt-3 opacity-0 pokemon-transition"
style="pointer-events: none;"
aria-hidden="true"
>
Guess another Pokémon
</button>
</div>
</div>
</div>
<Footer slot="footer" />
</Layout>
<style>
.pokemon-transition {
transition: opacity 0.4s ease;
}
.pokemon-clickable {
cursor: pointer;
}
.pokemon-clickable:focus-visible {
outline: 3px solid #ffc107;
outline-offset: 4px;
border-radius: 4px;
}
@keyframes pokemon-pulse {
0%, 100% { filter: brightness(0) drop-shadow(0 0 6px var(--bs-info-border-subtle)); }
50% { filter: brightness(0) drop-shadow(0 0 18px var(--bs-info)); }
}
.masked-image {
filter: brightness(0);
animation: pokemon-pulse 2s ease-in-out infinite;
}
</style>
<script>
const img = document.querySelector('.masked-image') as HTMLImageElement | null;
const nameEl = document.querySelector('#pokemon-name');
const playAgainBtn = document.querySelector('#play-again') as HTMLButtonElement | null;
const hintEl = document.querySelector('#reveal-hint');
function revealPokemon() {
if (!img || !nameEl) return;
const doReveal = () => {
img.classList.remove('masked-image');
nameEl.textContent = img.dataset.name || "Unknown Pokémon";
// Remove masked styles and interactivity from image
img.classList.remove('masked-image', 'pokemon-clickable');
img.removeAttribute('role');
img.removeAttribute('tabindex');
img.removeAttribute('aria-label');
img.style.animation = '';
// Update alt text now that it's revealed
img.alt = img.dataset.name || 'Unknown Pokémon';
// Reveal name
nameEl.textContent = img.dataset.name || 'Unknown Pokémon';
nameEl.classList.remove('opacity-0');
dataLayer.push({ event: '404reveal', pokemonName: img.dataset.name });
// Update hint text
if (hintEl) {
hintEl.querySelector('p')!.textContent = "It's " + (img.dataset.name || 'Unknown Pokémon') + "!";
}
// Show play again button
if (playAgainBtn) {
playAgainBtn.classList.remove('opacity-0');
playAgainBtn.style.pointerEvents = '';
playAgainBtn.removeAttribute('aria-hidden');
}
// Fire analytics safely
try {
if (typeof dataLayer !== 'undefined') {
dataLayer.push({ event: '404reveal', pokemonName: img.dataset.name });
}
} catch (e) {
// Analytics unavailable, continue silently
}
};
if (!document.startViewTransition) {
@@ -98,9 +173,8 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
revealPokemon();
}
});
</script>
</div>
<Footer slot="footer" />
</Layout>
playAgainBtn?.addEventListener('click', () => {
window.location.reload();
});
</script>

18
src/pages/admin.astro Normal file
View File

@@ -0,0 +1,18 @@
---
export const prerender = false;
import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro';
---
<Layout title="Admin Panel">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<div class="col-12">
<h1>Admin Panel</h1>
</div>
</div>
<Footer slot="footer" />
</Layout>

95
src/pages/api/upload.ts Normal file
View File

@@ -0,0 +1,95 @@
// src/pages/api/upload.ts
import type { APIRoute } from 'astro';
import { parse, stringify, transform } from 'csv';
import { Readable } from 'stream';
import { client } from '../../db/typesense';
import chalk from 'chalk';
import { db, ClosePool } from '../../db/index';
// Define the transformation logic
const transformer = transform({ parallel: 1 }, async function(this: any, row: any, callback: any) {
try {
// Specific query bsaed on tcgcollector CSV
const query = String(Object.values(row)[1]);
const setname = String(Object.values(row)[4]).replace(/Wizards of the coast promos/ig,'WoTC Promo');
const cardNumber = String(Object.values(row)[7]);
console.log(`${query} ${cardNumber} : ${setname}`);
// Use Typesense to find the card because we can easily use the combined fields
let cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `setName:\`${setname}\` && number:${cardNumber}` });
if (cards.hits?.length === 0) {
// Try without card number
cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `setName:\`${setname}\`` });
}
if (cards.hits?.length === 0) {
// Try without set name
cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `number:${cardNumber}` });
}
if (cards.hits?.length === 0) {
// I give up, just output the values from the csv
console.log(chalk.red(' - not found'));
const newRow = { ...row };
newRow.Variant = '';
newRow.marketPrice = '';
this.push(newRow);
}
else {
for (const card of cards.hits?.map((hit: any) => hit.document) ?? []) {
console.log(chalk.blue(` - ${card.cardId} : ${card.productName} : ${card.number}`), chalk.yellow(`${card.setName}`), chalk.green(`${card.variant}`));
const variant = await db.query.cards.findFirst({
with: { prices: true, tcgdata: true },
where: { cardId: card.cardId }
});
const newRow = { ...row };
newRow.Variant = variant?.variant;
newRow.marketPrice = variant?.prices.find(p => p.condition === 'Near Mint')?.marketPrice;
this.push(newRow);
}
}
callback();
} catch (error) {
callback(error);
}
});
export const POST: APIRoute = async ({ request }) => {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
const inputStream = Readable.from(file.stream());
if (!file) {
return new Response('No file uploaded', { status: 400 });
}
// Pipe the streams: Read -> Parse -> Transform -> Stringify -> Write
const outputStream = inputStream
.on('error', (error) => console.error('Input stream error:', error))
.pipe(parse({ columns: true, trim: true }))
.on('error', (error) => console.error('Parse error:', error))
.pipe(transformer)
.on('error', (error) => console.error('Transform error:', error))
.pipe(stringify({ header: true }))
.on('error', (error) => console.error('Stringify error:', error));
// outputStream.on('finish', () => {
// ClosePool();
// }).on('error', (error) => {
// ClosePool();
// });
return new Response(outputStream as any, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename=transformed.csv',
},
});
} catch (error) {
console.error('Error processing CSV stream:', error);
return new Response('Internal Server Error', { status: 500 });
}
};

View File

@@ -4,7 +4,7 @@ import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro';
import { Show, SignInButton, SignUpButton, SignOutButton } from '@clerk/astro/components'
import { Show, SignInButton, SignUpButton, SignOutButton, GoogleOneTap, UserAvatar, UserButton, UserProfile } from '@clerk/astro/components'
---
<Layout title="Rigid's App Thing">
<NavBar slot="navbar">
@@ -16,31 +16,41 @@ import { Show, SignInButton, SignUpButton, SignOutButton } from '@clerk/astro/co
<p class="text-secondary">(working title)</p>
</div>
<div class="col-12 col-md-6 mb-2">
<h2 class="mt-3">Welcome!</h2>
<h2 class="mt-3">The Pokémon card tracker you actually want.</h2>
<p class="mt-2">
You've been selected to participate in the closed beta! This single page web application is meant to elevate condition/variant data for the Pokemon TCG. In future iterations, we will add vendor inventory/collection management features, as well.
Browse real market prices and condition data across 70,000+ cards! No more
juggling multiple tabs or guessing what your cards are worth.
</p>
<p class="my-2">
After the closed beta is complete, the app will move into a more open beta. Feel free to play "Who's that Pokémon?" with the random Pokémon generator <a href="/404">here</a>. Refresh the page to see a new Pokémon!
We're now open to everyone. Create a free account to get started —
collection and inventory management tools are coming soon as part of a
premium plan.
</p>
<Show when="signed-in">
<a href="/pokemon" class="btn btn-warning mt-2">Take me to the cards</a>
<a href="/pokemon" class="btn btn-warning mt-2">Take me to the cards!</a>
</Show>
</div>
<div class="col-12 col-md-6 d-flex justify-content-end align-items-start mt-3">
<div class="d-flex gap-3">
<div class="d-flex gap-3 mx-auto">
<Show when="signed-out">
<SignInButton asChild mode="modal">
<button class="btn btn-success">Sign In</button>
</SignInButton>
<div class="card border p-5 w-100">
<SignUpButton asChild mode="modal">
<button class="btn btn-dark">Request Access</button>
<button class="btn btn-success w-100 mb-2">Create free account</button>
</SignUpButton>
<SignInButton asChild mode="modal">
<p class="text-center text-secondary my-2">Already have an account?</p>
<button class="btn btn-outline-light w-100">Sign in</button>
</SignInButton>
<p class="text-center h6 text-light mt-2 mb-0">Free to join!</p>
</div>
<GoogleOneTap />
</Show>
<Show when="signed-in">
<div class="w-100">
<SignOutButton asChild>
<button class="btn btn-danger">Sign Out</button>
<button class="btn btn-danger mt-2 ms-auto float-end">Sign Out</button>
</SignOutButton>
</div>
</Show>
</div>
</div>

26
src/pages/myprices.astro Normal file
View File

@@ -0,0 +1,26 @@
---
import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro';
---
<Layout title="Rigid's App Thing">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<div class="col-12">
<h1>Rigid's App Thing</h1>
<p class="text-secondary">(working title)</p>
</div>
<div class="col-12">
<!-- src/components/FileUploader.astro -->
<form action="/api/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file" accept=".csv" required />
<button type="submit">Upload CSV</button>
</form>
</div>
</div>
<Footer slot="footer" />
</Layout>

View File

@@ -4,16 +4,18 @@ import SetIcon from '../../components/SetIcon.astro';
import EnergyIcon from '../../components/EnergyIcon.astro';
import RarityIcon from '../../components/RarityIcon.astro';
import { db } from '../../db/index';
import { privateDecrypt } from "node:crypto";
import { priceHistory, skus } from '../../db/schema';
import { eq, inArray } from 'drizzle-orm';
import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
import { Tooltip } from "bootstrap";
export const partial = true;
export const prerender = false;
const searchParams = Astro.url.searchParams;
const cardId = Number(searchParams.get('cardId')) || 0;
// query the database for the card with the given productId and return the card data as json
const card = await db.query.cards.findFirst({
where: { cardId: Number(cardId) },
with: {
@@ -22,21 +24,9 @@ const card = await db.query.cards.findFirst({
}
});
// Get the current card's position in the grid and find previous/next cards
// This assumes cards are displayed in a specific order in the DOM
const cardElements = typeof document !== 'undefined' ? document.querySelectorAll('[data-card-id]') : [];
let prevCardId = null;
let nextCardId = null;
// Since this is server-side, we can't access the DOM directly
// Instead, we'll pass the current cardId and let JavaScript handle navigation
// The JS will look for the next/prev cards in the grid based on the visible cards
function timeAgo(date: Date | null) {
if (!date) return "Not applicable";
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
const intervals: Record<string, number> = {
year: 31536000,
month: 2592000,
@@ -44,67 +34,104 @@ function timeAgo(date: Date | null) {
hour: 3600,
minute: 60
};
for (const [unit, value] of Object.entries(intervals)) {
const count = Math.floor(seconds / value);
if (count >= 1) return `${count} ${unit}${count > 1 ? "s" : ""} ago`;
}
return "just now";
}
// Get the most recent calculatedAt across all prices
const calculatedAt = (() => {
if (!card?.prices?.length) return null;
// Extract all valid calculatedAt timestamps
const dates = card.prices
.map(p => p.calculatedAt)
.filter(d => d) // remove null/undefined
.map(d => new Date(d));
.filter(d => d)
.map(d => new Date(d!));
if (!dates.length) return null;
// Return the most recent one
return new Date(Math.max(...dates.map(d => d.getTime())));
})();
// ── Spread-based volatility (high - low) / low ────────────────────────────
// Log-return volatility was unreliable because marketPrice is a smoothed daily
// value, not transaction-driven. The 30-day high/low spread is a more honest
// proxy for price movement over the period.
const volatilityByCondition: Record<string, { label: string; spread: number }> = {};
for (const price of card?.prices ?? []) {
const condition = price.condition;
const low = Number(price.lowestPrice);
const high = Number(price.highestPrice);
const market = Number(price.marketPrice);
if (!low || !high || !market || market <= 0) {
volatilityByCondition[condition] = { label: '—', spread: 0 };
continue;
}
const spread = (high - low) / market;
const label = spread >= 0.50 ? 'High'
: spread >= 0.25 ? 'Medium'
: 'Low';
volatilityByCondition[condition] = { label, spread: Math.round(spread * 100) / 100 };
}
// ── Price history for chart ───────────────────────────────────────────────
const cardSkus = card?.prices?.length
? await db.select().from(skus).where(eq(skus.productId, card.productId))
: [];
const skuIds = cardSkus.map(s => s.skuId);
const historyRows = skuIds.length
? await db
.select({
skuId: priceHistory.skuId,
calculatedAt: priceHistory.calculatedAt,
marketPrice: priceHistory.marketPrice,
condition: skus.condition,
})
.from(priceHistory)
.innerJoin(skus, eq(priceHistory.skuId, skus.skuId))
.where(inArray(priceHistory.skuId, skuIds))
.orderBy(priceHistory.calculatedAt)
: [];
const priceHistoryForChart = historyRows.map(row => ({
condition: row.condition,
calculatedAt: row.calculatedAt
? new Date(row.calculatedAt).toISOString().split('T')[0]
: null,
marketPrice: row.marketPrice,
})).filter(r => r.calculatedAt !== null);
const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
const conditionAttributes = (price: any) => {
const volatility = (() => {
const market = price?.marketPrice;
const low = price?.lowestPrice;
const high = price?.highestPrice;
if (market == null || low == null || high == null || Number(market) === 0) {
return "Indeterminate";
}
const spreadPct = (Number(high) - Number(low)) / Number(market) * 100;
if (spreadPct >= 81) return "High";
if (spreadPct >= 59) return "Medium";
return "Low";
})();
const condition: string = price?.condition || "Near Mint";
const vol = volatilityByCondition[condition] ?? { label: '—', spread: 0 };
const volatilityClass = (() => {
switch (volatility) {
case "High": return "alert-danger";
case "Medium": return "alert-warning";
case "Low": return "alert-success";
default: return "alert-dark"; // Indeterminate
switch (vol.label) {
case "High": return "alert-danger";
case "Medium": return "alert-warning";
case "Low": return "alert-success";
default: return "alert-dark";
}
})();
const condition: string = price?.condition || "Near Mint";
const volatilityDisplay = vol.label === '—'
? '—'
: `${vol.label} (${(vol.spread * 100).toFixed(0)}%)`;
return {
"Near Mint": { label: "nav-nm", volatility, volatilityClass, class: "show active" },
"Lightly Played": { label: "nav-lp", volatility, volatilityClass },
"Moderately Played": { label: "nav-mp", volatility, volatilityClass },
"Heavily Played": { label: "nav-hp", volatility, volatilityClass },
"Damaged": { label: "nav-dmg", volatility, volatilityClass }
"Near Mint": { label: "nav-nm", volatility: volatilityDisplay, volatilityClass, class: "show active" },
"Lightly Played": { label: "nav-lp", volatility: volatilityDisplay, volatilityClass },
"Moderately Played":{ label: "nav-mp", volatility: volatilityDisplay, volatilityClass },
"Heavily Played": { label: "nav-hp", volatility: volatilityDisplay, volatilityClass },
"Damaged": { label: "nav-dmg", volatility: volatilityDisplay, volatilityClass }
}[condition];
};
@@ -121,166 +148,209 @@ const altSearchUrl = (card: any) => {
<div class="modal-content" data-card-id={card?.cardId}>
<div class="modal-header border-0">
<div class="container-fluid row align-items-center">
<div class="h4 card-title pe-2 col-sm-12 col-md-auto mb-sm-1">{card?.productName}</div>
<div class="text-secondary col-auto">{card?.number}</div>
<div class="text-light col-auto">{card?.variant}</div>
<div class="h4 card-title pe-2 col-sm-12 col-md-auto mb-sm-1">{card?.productName}</div>
<div class="text-secondary col-auto">{card?.number}</div>
<div class="text-light col-auto">{card?.variant}</div>
</div>
<div class="d-flex gap-2 align-items-center">
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="modal"></button>
</div>
</div>
<div class="modal-body pt-0">
</div>
<div class="modal-body pt-0">
<div class="container-fluid">
<div class="card mb-2 border-0">
<div class="card mb-2 border-0">
<div class="row g-4">
<div class="col-sm-12 col-md-3">
<div class="position-relative mt-1"><img src={`/cards/${card?.productId}.jpg`} class="card-image w-100 img-fluid rounded-4" alt={card?.productName} onerror="this.onerror=null;this.src='/cards/default.jpg'" onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});"><span class="position-absolute top-50 start-0 d-inline"><FirstEditionIcon edition={card?.variant} /></span><span class="position-absolute bottom-0 start-0 d-inline"><SetIcon set={card?.set?.setCode} /></span><span class="position-absolute top-0 end-0 d-inline"><EnergyIcon energy={card?.energyType} /></span><span class="rarity-icon-large position-absolute bottom-0 end-0 d-inline"><RarityIcon rarity={card?.rarityName} /></span></div>
<div class="d-flex flex-column flex-lg-row justify-content-between mt-2">
<div class="text-secondary">{card?.set?.setCode}</div>
<div class="text-secondary">Illus<span class="d-none d-lg-inline">trator</span>: {card?.Artist}</div>
</div>
</div>
<div class="col-sm-12 col-md-7">
<ul class="nav nav-tabs nav-fill border-0 me-3 mb-2" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link nm active" id="nm-tab" data-bs-toggle="tab" data-bs-target="#nav-nm" type="button" role="tab" aria-controls="nav-nm" aria-selected="true"><span class="d-none d-lg-inline">Near Mint</span><span class="d-lg-none">NM</span></button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link lp" id="lp-tab" data-bs-toggle="tab" data-bs-target="#nav-lp" type="button" role="tab" aria-controls="nav-lp" aria-selected="false"><span class="d-none d-lg-inline">Lightly Played</span><span class="d-lg-none">LP</span></button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link mp" id="mp-tab" data-bs-toggle="tab" data-bs-target="#nav-mp" type="button" role="tab" aria-controls="nav-mp" aria-selected="false"><span class="d-none d-lg-inline">Moderately Played</span><span class="d-lg-none">MP</span></button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link hp" id="hp-tab" data-bs-toggle="tab" data-bs-target="#nav-hp" type="button" role="tab" aria-controls="nav-hp" aria-selected="false"><span class="d-none d-lg-inline">Heavily Played</span><span class="d-lg-none">HP</span></button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link dmg" id="dmg-tab" data-bs-toggle="tab" data-bs-target="#nav-dmg" type="button" role="tab" aria-controls="nav-dmg" aria-selected="false"><span class="d-none d-lg-inline">Damaged</span><span class="d-lg-none">DMG</span></button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link vendor d-none" id="vendor-tab" data-bs-toggle="tab" data-bs-target="#nav-vendor" type="button" role="tab" aria-controls="nav-vendor" aria-selected="false"><span class="d-none d-lg-inline">Inventory</span><span class="d-lg-none">+/-</span></button>
</li>
</ul>
<div class="tab-content" id="myTabContent">
{card?.prices.slice().sort((a, b) => conditionOrder.indexOf(a.condition) - conditionOrder.indexOf(b.condition)).map((price) => {
const attributes = conditionAttributes(price);
return (
<div class={`tab-pane fade ${attributes?.label} ${attributes?.class}`} id={`${attributes?.label}`} role="tabpanel" tabindex="0">
<div class="d-block gap-1 d-lg-flex">
<div class="d-flex flex-row flex-lg-column gap-1 col-12 col-lg-2 mb-1">
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-1">
<h6 class="mb-auto">Market Price</h6>
<p class="mb-0 mt-1">${price.marketPrice}</p>
</div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-1">
<h6 class="mb-auto">Lowest Price</h6>
<p class="mb-0 mt-1">${price.lowestPrice}</p>
</div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-1">
<h6 class="mb-auto">Highest Price</h6>
<p class="mb-0 mt-1">${price.highestPrice}</p>
</div>
<div class={`alert alert-secondary rounded p-2 flex-fill d-flex flex-column mb-1 ${attributes?.volatilityClass}`}>
<h6 class="mb-auto">Volatility</h6>
<p class="mb-0 mt-1">{attributes?.volatility}</p>
</div>
</div>
<div class="d-flex flex-column gap-1 col-12 col-lg-10 mb-0 me-2 clearfix">
<div class="alert alert-dark rounded p-2 mb-1 table-responsive">
<h6>Latest Verified Sales</h6>
<table class="table table-sm mb-0">
<caption class="small">Filtered to remove mismatched language variants</caption>
<thead class="table-dark">
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">Price</th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</div>
<div class="alert alert-dark rounded p-2 mb-1">
<h6>Market Price History</h6>
<div class="position-relative" style="height: 200px;">
<canvas id={`priceChart-${price.priceId}`} class="price-history-chart" data-card-id={card?.cardId} data-condition={price.condition}></canvas>
</div>
<div class="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end" role="group" aria-label="Time range">
<button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="3m">3M</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="6m">6M</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="1y">1Y</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>
</div>
</div>
</div>
</div>
</div>
);
})}
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0">
<div class="row g-2 mt-2">
</div>
</div>
</div>
<!-- Card image column -->
<div class="col-sm-12 col-md-3">
<div class="position-relative mt-1">
<div
class="card-image-wrap rounded-4"
data-energy={card?.energyType}
data-rarity={card?.rarityName}
data-variant={card?.variant}
data-name={card?.productName}
>
<img
src={`/static/cards/${card?.productId}.jpg`}
class="card-image w-100 img-fluid rounded-4"
alt={card?.productName}
crossorigin="anonymous"
onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow, .card-image-wrap')?.setAttribute('data-default','true')"
onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});"
/>
</div>
<span class="position-absolute top-50 start-0 d-inline"><FirstEditionIcon edition={card?.variant} /></span>
<span class="position-absolute bottom-0 start-0 d-inline"><SetIcon set={card?.set?.setCode} /></span>
<span class="position-absolute top-0 end-0 d-inline"><EnergyIcon energy={card?.energyType} /></span>
<span class="rarity-icon-large position-absolute bottom-0 end-0 d-inline"><RarityIcon rarity={card?.rarityName} /></span>
</div>
<div class="col-sm-12 col-md-2 mt-0 mt-md-5 d-flex flex-row flex-md-column">
<div class="d-flex flex-column flex-lg-row justify-content-between mt-2">
<div class="text-secondary">{card?.set?.setCode}</div>
<div class="text-secondary">Illus<span class="d-none d-lg-inline">trator</span>: {card?.artist}</div>
</div>
</div>
<!-- Tabs + price data column -->
<div class="col-sm-12 col-md-7">
<ul class="nav nav-tabs nav-fill border-0 me-3 mb-2" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link nm active" id="nm-tab" data-bs-toggle="tab" data-bs-target="#nav-nm" type="button" role="tab" aria-controls="nav-nm" aria-selected="true">
<span class="d-none d-xxl-inline">Near Mint</span><span class="d-xxl-none">NM</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link lp" id="lp-tab" data-bs-toggle="tab" data-bs-target="#nav-lp" type="button" role="tab" aria-controls="nav-lp" aria-selected="false">
<span class="d-none d-xxl-inline">Lightly Played</span><span class="d-xxl-none">LP</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link mp" id="mp-tab" data-bs-toggle="tab" data-bs-target="#nav-mp" type="button" role="tab" aria-controls="nav-mp" aria-selected="false">
<span class="d-none d-xxl-inline">Moderately Played</span><span class="d-xxl-none">MP</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link hp" id="hp-tab" data-bs-toggle="tab" data-bs-target="#nav-hp" type="button" role="tab" aria-controls="nav-hp" aria-selected="false">
<span class="d-none d-xxl-inline">Heavily Played</span><span class="d-xxl-none">HP</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link dmg" id="dmg-tab" data-bs-toggle="tab" data-bs-target="#nav-dmg" type="button" role="tab" aria-controls="nav-dmg" aria-selected="false">
<span class="d-none d-xxl-inline">Damaged</span><span class="d-xxl-none">DMG</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link vendor d-none" id="vendor-tab" data-bs-toggle="tab" data-bs-target="#nav-vendor" type="button" role="tab" aria-controls="nav-vendor" aria-selected="false">
<span class="d-none d-xxl-inline">Inventory</span><span class="d-xxl-none">+/-</span>
</button>
</li>
</ul>
<div class="tab-content" id="myTabContent">
{card?.prices.slice().sort((a, b) => conditionOrder.indexOf(a.condition) - conditionOrder.indexOf(b.condition)).map((price) => {
const attributes = conditionAttributes(price);
return (
<div class={`tab-pane fade ${attributes?.label} ${attributes?.class ?? ''}`} id={`${attributes?.label}`} role="tabpanel" tabindex="0">
<div class="d-flex flex-column gap-1">
<!-- Stat cards -->
<div class="d-flex flex-fill flex-row gap-1">
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Market Price</h6>
<p class="mb-0 mt-1">${price.marketPrice}</p>
</div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Low Price <span class="small p text-secondary">(30 day)</span></h6>
<p class="mb-0 mt-1">${price.lowestPrice}</p>
</div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">High Price <span class="small p text-secondary">(30 day)</span></h6>
<p class="mb-0 mt-1">${price.highestPrice}</p>
</div>
<div class={`alert rounded p-2 flex-fill d-flex flex-column mb-0 ${attributes?.volatilityClass}`}>
<h6 class="mb-auto d-flex justify-content-between align-items-start">
<span class="me-1">Volatility</span>
<span
class="volatility-info float-end mt-0"
data-bs-toggle="tooltip"
data-bs-placement="top"
data-bs-container="body"
data-bs-custom-class="volatility-popover"
data-bs-trigger="hover focus click"
data-bs-html="true"
data-bs-title={`
<div class='tooltip-heading fw-bold mb-1'>30-Day Price Spread</div>
<div class='small'>
<p class="mb-1">
<strong>What this measures:</strong> how wide the gap between the 30-day low and high is,
relative to the market price.
</p>
<p class="mb-0">
A card with <strong>50%+ spread</strong> has seen significant price swings over the past month.
</p>
</div>
`}
>
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 16 16"> <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/> <path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/> </svg>
</span>
</h6>
<p class="mb-0 mt-1">{attributes?.volatility}</p>
</div>
</div>
<!-- Table only — chart is outside the tab panes -->
<div class="w-100">
<div class="alert alert-dark rounded p-2 mb-0 table-responsive d-none">
<h6>Latest Verified Sales</h6>
<table class="table table-sm mb-0">
<caption class="small">Filtered to remove mismatched language variants</caption>
<thead class="table-dark">
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">Price</th>
</tr>
</thead>
<tbody>
<tr><td> </td><td> </td><td> </td></tr>
<tr><td> </td><td> </td><td> </td></tr>
<tr><td> </td><td> </td><td> </td></tr>
<tr><td> </td><td> </td><td> </td></tr>
<tr><td> </td><td> </td><td> </td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
);
})}
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0"></div>
</div>
<!-- Chart lives permanently outside tab-content so Bootstrap never hides it. -->
<div class="d-block d-lg-flex gap-1 mt-1">
<div class="col-12">
<div class="alert alert-dark rounded p-2 mb-0">
<h6>Market Price History</h6>
<div id="priceHistoryEmpty" class="d-none text-secondary text-center py-4">
No sales data for the selected period/condition
</div>
<div class="position-relative" style="height: 200px;">
<canvas
id="priceHistoryChart"
class="price-history-chart"
data-card-id={card?.cardId}
data-history={JSON.stringify(priceHistoryForChart)}>
</canvas>
</div>
<div class="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end mt-2" role="group" aria-label="Time range">
<button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="3m">3M</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="6m">6M</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="1y">1Y</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>
</div>
</div>
</div>
</div>
</div>
<!-- External links column -->
<div class="col-sm-12 col-md-2 mt-0 mt-md-5 d-flex flex-row flex-md-column">
<a class="btn btn-dark mb-2 w-100 p-2" href={`https://www.tcgplayer.com/product/${card?.productId}`} target="_blank" onclick="dataLayer.push({'event': 'tcgplayerClick', 'tcgplayerUrl': this.getAttribute('href')});"><img src="/vendors/tcgplayer.webp"> <span class="d-none d-lg-inline">TCGPlayer</span></a>
<a class="btn btn-dark mb-2 w-100 p-2" href={`${ebaySearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'ebayClick', 'ebayUrl': this.getAttribute('href')});"><span set:html={ebay} /></a>
<a class="btn btn-dark mb-2 w-100 p-2" href={`${altSearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'altClick', 'altUrl': this.getAttribute('href')});"><svg width="48" height="20.16" viewBox="0 0 48 20" fill="none"><path d="M14.2761 19.9996H18.5308L11.6934 0.0712891H7.76953L14.2761 19.9996Z" fill="#ffffff"></path><path d="M6.17778 19.9986H6.14536L3.19643 11.2305L0 19.9988L6.17768 19.9989L6.17778 19.9986Z" fill="#ffffff"></path><path d="M24.7842 0H20.6759V19.9661H34.3427V16.5426H24.7842V0Z" fill="#ffffff"></path><path d="M41.6644 3.42355H47.4981V0H31.5033V3.42355H37.5561V19.9661H41.6644V3.42355Z" fill="#ffffff"></path></svg></a>
</div>
</div>
</div>
<div class="text-end my-0"><small class="text-body-tertiary">Prices last changed: {timeAgo(calculatedAt)}</small></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script is:inline>
async function copyImage(img) {
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
});
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
showCopyToast('📋 Image copied!', '#198754');
} catch (err) {
console.error('Failed:', err);
showCopyToast('❌ Copy failed', '#dc3545');
}
}
function showCopyToast(message, color) {
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: ${color}; color: white; padding: 10px 20px;
border-radius: 8px; font-size: 14px; z-index: 9999;
opacity: 0; transition: opacity 0.2s ease;
pointer-events: none;
`;
document.body.appendChild(toast);
requestAnimationFrame(() => toast.style.opacity = '1');
setTimeout(() => {
toast.style.opacity = '0';
toast.addEventListener('transitionend', () => toast.remove());
}, 2000);
}
</script>
</div>

View File

@@ -9,7 +9,7 @@ import * as util from 'util';
// all the facet fields we want to use for filtering
const facetFields:any = {
"productLineName": "Product Line",
//"productLineName": "Product Line",
"setName": "Set",
"variant": "Variant",
"rarityName": "Rarity",
@@ -17,13 +17,88 @@ const facetFields:any = {
"energyType": "Energy Type"
}
// ── Allowed sort values ───────────────────────────────────────────────────
const sortMap: Record<string, string> = {
'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, value]) => key !== 'q' && key !== 'start')
.filter(([key]) => key !== 'q' && key !== 'start' && key !== 'sort' && key !== 'language')
.reduce((acc, [key, value]) => {
if (!acc[key]) {
acc[key] = [];
@@ -37,27 +112,28 @@ const filterChecked = (field: string, value: string) => {
};
const filterBy = Object.entries(filters).map(([field, values]) => {
return `${field}:=[${values.join(',')}]`;
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.join(',')}]`)
.map(([field, values]) => `${field}:=[${values.map(v => '`'+v+'`').join(',')}]`)
.join(' && ');
return `sealed:false${otherFilters ? ` && ${otherFilters}` : ''}`;
// 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${filterBy ? ` && ${filterBy}` : ''}`,
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: '_text_match:asc, releaseDate:desc, number:asc',
sort_by: resolvedSort,
include_fields: '$skus(*)',
}];
@@ -79,19 +155,17 @@ if (start === 0) {
const searchRequests = { searches: searchArray };
const commonSearchParams = {
q: query,
// query_by: 'productLineName,productName,setName,number,rarityName,Artist',
query_by: 'content'
q: resolvedQuery,
query_by: 'content,setName,setCode,productName,Artist',
query_by_weights: '10,6,8,9,8',
num_typos: '2,1,0,1,2',
prefix: 'true,true,false,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;
//console.log(util.inspect(cardResults.hits.map((c:any) => { return { productLineName:c.document.productLineName, productName:c.document.productName, setName:c.document.setName, number:c.document.number, rarityName:c.document.rarityName, Artist:c.document.Artist, text_match:c.text_match, text_match_info:c.text_match_info }; }), { showHidden: true, depth: null }));
//console.log(cardResults);
const pokemon = cardResults.hits?.map((hit: any) => hit.document) ?? [];
const totalHits = cardResults?.found;
@@ -122,8 +196,8 @@ const facetNames = (name:string) => {
}
const facets = searchResults.results.slice(1).map((result: any) => {
const facet = result.facet_counts[0];
if (!facet) return facet;
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) => {
@@ -135,7 +209,7 @@ const facets = searchResults.results.slice(1).map((result: any) => {
});
return facet;
});
}).filter(Boolean);
---
@@ -165,23 +239,41 @@ const facets = searchResults.results.slice(1).map((result: any) => {
</div>
))}
</div>
<div id="totalResults d-none" class="ms-5 text-secondary" hx-swap-oob="true">
<div id="sortBy" class="d-flex flex-fill align-items-center me-auto gap-2" hx-swap-oob="true">
<div class="dropdown">
<button class="btn btn-sm btn-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Sort by</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item sort-option" href="#" data-sort="releaseDate:desc,number:asc" data-label="Set: Newest to Oldest">Set: Newest to Oldest</a></li>
<li><a class="dropdown-item sort-option" href="#" data-sort="releaseDate:asc,number:asc" data-label="Set: Oldest to Newest">Set: Oldest to Newest</a></li>
<li><a class="dropdown-item sort-option" href="#" data-sort="marketPrice:desc" data-label="Price: High to Low">Price: High to Low</a></li>
<li><a class="dropdown-item sort-option" href="#" data-sort="marketPrice:asc" data-label="Price: Low to High">Price: Low to High</a></li>
<li><a class="dropdown-item sort-option" href="#" data-sort="number:asc" data-label="Card Number: Ascending">Card Number: Ascending</a></li>
<li><a class="dropdown-item sort-option" href="#" data-sort="number:desc" data-label="Card Number: Descending">Card Number: Descending</a></li>
</ul>
</div>
<span id="sortLabel" class="ms-1 text-secondary small">{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] ?? '') : ''}</span>
<div class="btn-group btn-group-sm ms-2 flex-shrink-0" role="group" aria-label="Language filter">
<button type="button" class={`btn btn-dark language-btn${language === 'all' ? ' active' : ''}`} data-lang="all">All</button>
<button type="button" class={`btn btn-dark language-btn${language === 'en' ? ' active' : ''}`} data-lang="en">EN</button>
<button type="button" class={`btn btn-dark language-btn${language === 'jp' ? ' active' : ''}`} data-lang="jp">JP</button>
</div>
</div>
<div id="totalResults" class="d-flex text-secondary small d-none align-items-center" hx-swap-oob="true">
{totalHits} {totalHits === 1 ? ' result' : ' results'}
</div>
<div id="activeFilters" class="d-flex align-items-center small ms-auto" hx-swap-oob="true">
<div id="activeFilters" class="d-flex small ms-auto align-items-center" hx-swap-oob="true">
{(Object.entries(filters).length > 0) &&
<span class="me-1">Filtered by:</span>
<span class="me-1 small">Filtered by:</span>
<ul class="list-group list-group-horizontal">
{Object.entries(filters).map(([filter, values]) => (
values.map((value) => (
<li data-facet={filter} data-value={value} class="list-group-item remove-filter">{value}</li>
<li data-facet={filter} data-value={value} class="list-group-item small p-2 remove-filter">{value}</li>
))
))}
</ul>
<span class="ms-2"><button type="button" class="btn-close" aria-label="Clear all filters" id="clear-all-filters"></button></span>
}
</div>
<script define:vars={{ totalHits, filters, facets }} is:inline>
// Filter the facet values to make things like Set easier to find
@@ -225,39 +317,46 @@ const facets = searchResults.results.slice(1).map((result: any) => {
}
{pokemon.length === 0 && (
<div id="notfound" hx-swap-oob="true">
Pokemon not found
<div id="notfound" class="mt-4 h6" hx-swap-oob="true">
No cards found! Please modify your search and try again.
</div>
)}
{pokemon.map((card:any) => (
<div class="col">
<div class="inventory-button position-relative float-end shadow-filter text-center d-none">
<div class="inventory-label pt-2">+/-</div>
</div>
<div class="card-trigger position-relative" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});">
<div class="image-grow"><img src={`/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 card-image w-100" onerror="this.onerror=null;this.src='/cards/default.jpg'"/><span class="position-absolute top-50 start-0 d-inline medium-icon"><FirstEditionIcon edition={card?.variant} /></span></div>
</div>
<div class="row row-cols-5 gx-1 price-row mb-2">
{conditionOrder.map((condition) => (
<div class="col price-label ps-1">
{ conditionShort(condition) }
<br />{formatPrice(condition, card.skus)}
</div>
))}
</div>
<div class="h5 my-0">{card.productName}</div>
<div class="d-flex flex-row lh-1 mt-1 justify-content-between">
<div class="text-secondary flex-grow-1 d-none d-lg-flex">{card.setName}</div>
<div class="text-body-tertiary">{card.number}</div>
<span class="ps-2 small-icon"><RarityIcon rarity={card.rarityName} /></span>
</div>
<div class="text-body-tertiary">{card.variant}</div><span class="d-none">{card.productId}</span>
</div>
{pokemon.map((card: any, i: number) => (
<div class="col equal-height-col">
<div class="inventory-button position-relative float-end shadow-filter text-center d-none">
<div class="inventory-label pt-2">+/-</div>
</div>
<div class="card-trigger position-relative" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});">
<div class="image-grow rounded-4 card-image h-100" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}>
<img src={`/static/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 w-100" onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow')?.setAttribute('data-default','true')"/>
<span class="position-absolute top-50 start-0 d-inline medium-icon"><FirstEditionIcon edition={card?.variant} /></span>
<div class="holo-shine"></div>
<div class="holo-glare"></div>
</div>
</div>
<div class="row row-cols-5 gx-1 price-row mb-2">
{conditionOrder.map((condition) => (
<div class="col price-label ps-1">
{conditionShort(condition)}
<br />{formatPrice(condition, card.skus)}
</div>
))}
</div>
<div class="h5 my-0">{card.productName}</div>
<div class="d-flex flex-row lh-1 mt-1 justify-content-between">
<div class="text-secondary flex-grow-1"><span class="d-none d-lg-flex">{card.setName}</span><span class="d-flex d-lg-none">{card.setCode}</span></div>
<div class="text-body-tertiary">{card.number}</div>
<span class="ps-2 small-icon"><RarityIcon rarity={card.rarityName} /></span>
</div>
<div class="text-body-tertiary">{card.variant}</div>
<span class="d-none">{card.productId}</span>
</div>
</>
))}
{start + 20 < totalHits &&
<div hx-post="/partials/cards" hx-trigger="revealed" hx-include="#searchform" hx-target="#cardGrid" hx-swap="beforeend" hx-on--after-request="afterUpdate(event)">
Loading...
</div>
}
}

View File

@@ -0,0 +1,71 @@
import type { APIRoute } from 'astro';
import { db } from '../../db/index';
import { priceHistory, skus } from '../../db/schema';
import { eq, inArray } from 'drizzle-orm';
export const prerender = false;
export const GET: APIRoute = async ({ url }) => {
const cardId = Number(url.searchParams.get('cardId')) || 0;
const cardSkus = await db
.select()
.from(skus)
.where(eq(skus.cardId, cardId));
if (!cardSkus.length) {
return new Response(JSON.stringify([]), { headers: { 'Content-Type': 'application/json' } });
}
const skuIds = cardSkus.map(s => s.skuId);
const historyRows = await db
.select({
skuId: priceHistory.skuId,
calculatedAt: priceHistory.calculatedAt,
marketPrice: priceHistory.marketPrice,
condition: skus.condition,
})
.from(priceHistory)
.innerJoin(skus, eq(priceHistory.skuId, skus.skuId))
.where(inArray(priceHistory.skuId, skuIds))
.orderBy(priceHistory.calculatedAt);
// Rolling 30-day cutoff for volatility
const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000);
const byCondition: Record<string, number[]> = {};
for (const row of historyRows) {
if (row.marketPrice == null) continue;
if (!row.calculatedAt) continue;
if (new Date(row.calculatedAt) < thirtyDaysAgo) continue;
const price = Number(row.marketPrice);
if (price <= 0) continue;
if (!byCondition[row.condition]) byCondition[row.condition] = [];
byCondition[row.condition].push(price);
}
function computeVolatility(prices: number[]): { label: string; monthlyVol: number } {
if (prices.length < 2) return { label: '—', monthlyVol: 0 };
const returns: number[] = [];
for (let i = 1; i < prices.length; i++) {
returns.push(Math.log(prices[i] / prices[i - 1]));
}
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
const variance = returns.reduce((sum, r) => sum + (r - mean) ** 2, 0) / (returns.length - 1);
const monthlyVol = Math.sqrt(variance) * Math.sqrt(30);
const label = monthlyVol >= 0.30 ? 'High'
: monthlyVol >= 0.15 ? 'Medium'
: 'Low';
return { label, monthlyVol: Math.round(monthlyVol * 100) / 100 };
}
const volatilityByCondition: Record<string, { label: string; monthlyVol: number }> = {};
for (const [condition, prices] of Object.entries(byCondition)) {
volatilityByCondition[condition] = computeVolatility(prices);
}
return new Response(JSON.stringify({ history: historyRows, volatilityByCondition }), {
headers: { 'Content-Type': 'application/json' }
});
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -1,5 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"include": [".astro/types.d.ts", "src/**/*"],
"exclude": ["dist"]
}