28 Commits

Author SHA1 Message Date
c0120e3e77 [feat] read tcgcollector csv 2026-03-18 13:39:39 -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
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
zach
3d46a48a7d sliding modals, view transitions, accessibility, etc, etc 2026-03-11 15:21:43 -04:00
1089bcdc20 [chore] schema for price history 2026-03-09 15:44:06 -04:00
zach
7482cb9e9c cleaned up css and made sorted active filters to the top 2026-03-09 14:25:18 -04:00
zach
68bed6ff8e new border pattern for each condition on modal, added icon display for 1st edition on both card/card modal 2026-03-09 12:00:29 -04:00
f5fcd7b3e7 [bugfix] clear the notfound message on new search 2026-03-08 00:03:25 -05:00
zach
4eed1869a6 hide modal nav buttons until working 2026-03-06 14:22:19 -05:00
zach
ce56d08efe scrolling within modals using keys/buttons/swipe (needs help with infinite scrolling) 2026-03-06 13:53:15 -05:00
zach
7fd8a21d1c clerk was missing /shared npm package 2026-03-05 22:59:16 -05:00
2fa0be9d23 [bugfix] combine all search terms to a single indexed field 2026-03-05 16:09:23 -05:00
zach
dedd7f8d87 changed the mechanism for auth on the index page (requires install of newer clerk components) 2026-03-05 15:24:08 -05:00
41 changed files with 3958 additions and 1806 deletions

View File

@@ -11,6 +11,9 @@ export default defineConfig({
},
}),
],
server: {
allowedHosts: true,
},
adapter: node({ mode: "standalone", checkOrigin: false }),
output: "server",
security: {

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,
});

2817
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,26 +5,29 @@
"scripts": {
"dev": "astro dev --host 0.0.0.0",
"build": "astro build",
"preview": "astro preview",
"preview": "astro preview --host 0.0.0.0",
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^9.5.4",
"@clerk/astro": "^2.17.6",
"@clerk/astro": "^3.0.1",
"@clerk/shared": "^4.0.0",
"@clerk/themes": "^2.4.55",
"astro": "^5.17.1",
"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"
}

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();

104
scripts/indexing.ts Normal file
View File

@@ -0,0 +1,104 @@
import chalk from 'chalk';
import { client } from '../src/db/typesense.ts';
import type { DBInstance } from '../src/db/index.ts';
const DollarToInt = (dollar: any) => {
if (dollar === null) return null;
return Math.round(dollar * 100);
}
// 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: '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 => {
const 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 || "",
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,
...(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.'));
}

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(), 'public', '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();

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,6 +1,6 @@
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";
@@ -43,14 +43,6 @@ 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);
@@ -130,10 +122,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.has(item.productId)) {
continue;
}
console.log(chalk.blue(` - ${item.productName} (ID: ${item.productId})`));
@@ -184,8 +176,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),
@@ -221,7 +214,7 @@ 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,
},
});
@@ -232,7 +225,8 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
setCode: detailData.setCode,
setName: detailData.setName,
setUrlName: detailData.setUrlName,
}).onDuplicateKeyUpdate({
}).onConflictDoUpdate({
target: schema.sets.setId,
set: {
setCode: detailData.setCode,
setName: detailData.setName,
@@ -249,7 +243,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,
@@ -286,4 +281,4 @@ await fs.rm('missing_images.log', { force: true });
const allProductIds = new Set(await db.select({ productId: schema.cards.productId }).from(schema.cards).then(rows => rows.map(row => row.productId)));
await syncTcgplayer();
await poolConnection.end();
await ClosePool();

View File

@@ -1,132 +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: '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 './indexing.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,
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,24 +1,20 @@
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 } from '../src/db/schema.ts';
import { toSnakeCase } from 'drizzle-orm/casing';
import * as Indexing from './indexing.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));
}
@@ -59,6 +55,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 sleep(200);
continue;
}
const skuUpdates = skuData.map((sku: any) => { return {
skuId: sku.skuId,
cardId: 0,
@@ -72,41 +77,48 @@ 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.`));
}
});
}
// 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 sleep(200);
}
}
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 start = Date.now();
await syncPrices();
await indexPrices();
await poolConnection.end();
await Indexing.upsertSkuCollection(db);
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 +1,47 @@
import 'dotenv/config';
import { db, poolConnection } from '../src/db/index.ts';
import { db, ClosePool } 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)
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(`Inserted ${inserts[0].affectedRows} rows into cards table`);
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`);
}
await syncVariants();
await poolConnection.end();
await ClosePool();

View File

@@ -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

View File

@@ -1,5 +1,5 @@
/* --------------------------------------------------
Bootstrap overrides
Bootstrap Overrides
-------------------------------------------------- */
$grid-breakpoints: (
@@ -21,21 +21,44 @@ $container-max-widths: (
xxxl: 1840px
) !default;
@import "_bootstrap";
/* --------------------------------------------------
Root Variables
-------------------------------------------------- */
:root {
--total: 11; /* Number of items in the energy wheel */
--radius: 40px; /* Circle radius */
--total: 11;
--radius: 40px;
}
html {
scroll-behavior: smooth;
}
/* --------------------------------------------------
View Transitions
-------------------------------------------------- */
@view-transition {
navigation: auto;
}
::view-transition-group(card-image) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(card-image),
::view-transition-new(card-image) {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Optional: fade everything else */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 150ms;
}
/* --------------------------------------------------
Layout
@@ -71,15 +94,11 @@ html {
top: 50%;
left: 50%;
--angle: calc(360deg / var(--total) * var(--i));
transform: rotate(var(--angle)) translateX(var(--radius)) rotate(calc(-1 * var(--angle)));
transform:
rotate(var(--angle))
translateX(var(--radius))
rotate(calc(-1 * var(--angle)));
}
.energy-wheel-item:first-of-type {
z-index: 100;
&:first-of-type {
z-index: 100;
}
}
/* --------------------------------------------------
@@ -143,7 +162,8 @@ html {
transition: box-shadow 350ms ease, transform 350ms ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.24);
&:is(:hover, :focus) {
&:hover,
&:focus {
box-shadow: 0 8px 10px rgba(0, 0, 0, 0.2);
transform: translateY(-0.9rem) scale(1.02);
}
@@ -164,9 +184,37 @@ html {
}
}
.modal-nav-btn {
position: fixed;
top: 50%;
transform: translateY(-50%);
z-index: 1060; /* above modal backdrop (1050) */
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
color: white;
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
backdrop-filter: blur(4px);
}
.modal-nav-btn:hover { background: rgba(255,255,255,0.25); }
.modal-nav-btn.d-none { display: none !important; }
.modal-nav-prev { left: 12px; }
.modal-nav-next { right: 12px; }
@media (max-width: 768px) {
.modal-nav-btn { display: none !important; } /* use swipe on mobile */
}
/* --------------------------------------------------
Navigation Tabs
Navigation Tabs & Tier Colors
-------------------------------------------------- */
.nav-link {
font-weight: 600;
color: rgba(255, 255, 255, 0.67);
@@ -193,7 +241,6 @@ html {
}
}
/* Tiered Colors */
$tiers: (
nm: rgba(156, 204, 102, 1),
lp: rgba(211, 225, 86, 1),
@@ -221,11 +268,17 @@ $tiers: (
}
}
}
/* price-row alert left borders */
.nav-#{$name} div.alert {
border-left: 3px solid $color;
}
}
/* --------------------------------------------------
Misc UI Elements
Misc UI
-------------------------------------------------- */
.dark-callout {
@media (min-width: 768px) {
background-color: rgba(44, 48, 59, 1);
@@ -243,21 +296,18 @@ $tiers: (
cursor: pointer;
}
/* Icon sizing */
.small-icon svg {
width: 100%;
max-height: 16px;
margin-top: -0.25rem;
}
/* Icon Sizes */
.small-icon svg { max-height: 16px; width: 100%; margin-top: -0.25rem; }
.medium-icon svg { max-height: 32px; width: 100%; margin-left: -0.25rem; }
/* Black silhouette overlay */
/* Masked Image */
.masked-image {
z-index: 1000;
opacity: 1;
filter: brightness(0);
}
/* Decorative background elements */
/* Decorative Background */
.starburst,
.whos-that-pokemon {
mix-blend-mode: lighten;
@@ -269,12 +319,16 @@ $tiers: (
aspect-ratio: 1 / 1;
}
/* SVG sizes */
/* SVG Generic Sizes */
.energy-icon svg,
.rarity-icon-large svg,
.set-icon svg {
width: 2.5rem;
.set-icon svg,
.edition-icon svg {
width: 2rem;
z-index: 999;
@media (min-width: 1024px) {
width: 2.5rem;
}
}
.rarity-icon-large svg,
@@ -282,12 +336,14 @@ $tiers: (
margin-bottom: -0.25rem;
}
.filter-icon svg {
.filter-icon svg,
.search-button {
width: 2rem;
fill: rgba(255,255,255,0.87);
stroke: rgba(255,255,255,0.87);
}
/* Form states */
.form-check-input:checked {
background-color: var(--bs-success);
border-color: var(--bs-success-border-subtle);
@@ -298,12 +354,7 @@ $tiers: (
box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25);
}
.search-button {
width: 2rem;
fill: rgba(255,255,255,0.87);
stroke: rgba(255,255,255,0.87);
}
/* Back to Top */
#btn-back-to-top {
position: fixed;
bottom: 5vh;
@@ -313,7 +364,7 @@ $tiers: (
.top-icon svg {
width: 2rem;
height:2rem;
height: 2rem;
fill: var(--bs-info-bg-subtle);
stroke: var(--bs-info-bg-subtle);
}
@@ -323,43 +374,49 @@ $tiers: (
stroke: var(--bs-info-border-subtle);
}
.energy-icon svg {
margin-top: -0.25rem;
margin-right: -0.25rem;
}
.set-icon svg {
margin-left: -0.25rem;
}
.shadow-filter {
filter:
drop-shadow(0 5px 5px rgba(0, 0, 0, 0.3))
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;
margin-top: -1.25rem;
border-radius: 0.33rem;
background: linear-gradient(
90deg,
rgba(156, 204, 102, 1) 21%,
rgba(211, 225, 86, 1) 42%,
rgba(255, 238, 87, 1) 63%,
rgba(255, 201, 41, 1) 74%,
rgba(255, 167, 36, 1) 85%
map-get($tiers, nm) 21%,
map-get($tiers, lp) 42%,
map-get($tiers, mp) 63%,
map-get($tiers, hp) 74%,
map-get($tiers, dmg) 85%
);
}
.inventory-button {
margin-bottom: -2rem;
margin-right: -0.25rem;
width: 40px;
height: 40px;
margin-bottom: -2rem;
margin-right: -0.25rem;
border-radius: 0.33rem;
background-color: hsl(262, 47%, 55%);
color: #fff;
@@ -376,6 +433,7 @@ $tiers: (
font-size: 0.9rem !important;
}
/* Price Label */
.price-label {
font-size: 0.69rem;
font-weight: 600;
@@ -384,37 +442,21 @@ $tiers: (
border-radius: 0.33rem 0 0 0.33rem;
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.35);
@media (min-width: 768px) {
font-size: 0.72rem;
}
@media (min-width: 996px) {
font-size: 0.75rem;
}
@media (min-width: 1200px) {
font-size: 0.8rem;
}
@media (min-width: 1600px) {
font-size: 1rem;
}
@media (min-width: 768px) { font-size: 0.72rem; }
@media (min-width: 996px) { font-size: 0.75rem; }
@media (min-width: 1200px) { font-size: 0.8rem; }
@media (min-width: 1600px) { font-size: 1rem; }
&:nth-of-type(2) {
background-color: hsl(66, 70%, 61%);
}
&:nth-of-type(3) {
background-color: hsl(54, 100%, 67%);
}
&:nth-of-type(4) {
background-color: hsl(45, 100%, 58%);
}
&:last-of-type {
background-color: hsl(36, 100%, 57%);
border-radius: 0.33rem;
}
&:nth-of-type(2) { background-color: hsl(66, 70%, 61%); }
&:nth-of-type(3) { background-color: hsl(54, 100%, 67%); }
&:nth-of-type(4) { background-color: hsl(45, 100%, 58%); }
&:last-of-type { background-color: hsl(36, 100%, 57%); border-radius: 0.33rem; }
}
/* --------------------------------------------------
Search
-------------------------------------------------- */
@media (max-width: 768px) {
.search-box,
.search-button {
@@ -429,7 +471,7 @@ $tiers: (
line-height: 2rem;
}
/* Sticky (mobile-bottom / desktop-top) search bar */
/* Sticky Search Bar */
.search-bar {
position: fixed;
bottom: 0;
@@ -441,7 +483,6 @@ $tiers: (
@media (min-width: 768px) {
position: sticky;
top: 0;
float: right;
min-width: 45vw;
max-width: 45vw;
transform: rotate(0);
@@ -454,71 +495,48 @@ $tiers: (
}
/* --------------------------------------------------
Circles (Header, Buttons, etc.)
Circles
-------------------------------------------------- */
/* Shared Circle Styles */
%circle-base {
width: 1rem;
height: 1rem;
border-radius: 50%;
position: absolute;
margin: 0 auto;
--a: 8deg;
border: 1px solid hsl(210, 11%, 15%);
mask: linear-gradient(135deg, #fffc 40%, #fff, #fffc 60%) 100% 100% / 240% 240%;
transition: 0.4s;
transform: perspective(400px) rotate3d(var(--i, 1, -1), 0, var(--a));
transform: perspective(400px) rotate3d(var(--i, 1, -1), 0, var(--a, 8deg));
}
/* Red */
.redCircle {
@extend %circle-base;
width: 1rem;
height: 1rem;
border: 1px solid hsl(210, 11%, 15%);
background: radial-gradient(circle at top left, hsl(0, 100%, 56%), hsl(0, 79%, 45%));
}
.redCircle { @extend %circle-base; background: radial-gradient(circle at top left, hsl(0, 100%, 56%), hsl(0, 79%, 45%)); }
.yellowCircle { @extend %circle-base; background: radial-gradient(circle at top left, hsl(61, 100%, 50%), hsl(61, 100%, 40%)); }
.greenCircle { @extend %circle-base; background: radial-gradient(circle at top left, hsl(149, 100%, 40%), hsl(149, 100%, 30%)); }
/* Yellow */
.yellowCircle {
@extend %circle-base;
width: 1rem;
height: 1rem;
border: 1px solid hsl(210, 11%, 15%);
background: radial-gradient(circle at top left, hsl(61, 100%, 50%), hsl(61, 100%, 40%));
}
/* Green */
.greenCircle {
@extend %circle-base;
width: 1rem;
height: 1rem;
border: 1px solid hsl(210, 11%, 15%);
background: radial-gradient(circle at top left, hsl(149, 100%, 40%), hsl(149, 100%, 30%));
}
/* Circle Hover Effect */
.yellowCircle:hover,
.redCircle:hover,
.yellowCircle:hover,
.greenCircle:hover,
.blueCircle:hover {
--i: -1, 1;
mask-position: 0 0;
}
.nav-icon {
width: 1.85rem;
height: 1.85rem;
}
/* --------------------------------------------------
Buttons
-------------------------------------------------- */
.btn-warning>span, .btn-warning>svg.nav-icon {
.btn-warning > span,
.btn-warning > svg.nav-icon {
fill: var(--bs-warning-border-subtle);
stroke: var(--bs-warning-border-subtle);
color: var(--bs-warning-border-subtle);
}
.btn.btn-warning:hover>span, .btn.btn-warning:hover>svg.nav-icon {
.btn.btn-warning:hover > span,
.btn.btn-warning:hover > svg.nav-icon {
fill: var(--bs-warning-bg-subtle);
stroke: var(--bs-warning-bg-subtle);
color: var(--bs-warning-border-subtle);
}
.btn-outline-success svg.nav-icon {
@@ -526,8 +544,125 @@ $tiers: (
stroke: var(--bs-success);
}
.btn.btn-outline-success:hover, .btn.btn-outline-success:hover>svg.nav-icon {
.btn.btn-outline-success:hover,
.btn.btn-outline-success:hover svg.nav-icon {
fill: var(--bs-success-border-subtle);
stroke: var(--bs-success-border-subtle);
color: var(--bs-success-border-subtle);
}
/* --------------------------------------------------
Card Modal Navigation
-------------------------------------------------- */
.card-nav-prev,
.card-nav-next {
transition: all 0.2s ease-in-out;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
padding: 0.375rem 0.5rem;
will-change: opacity;
&:hover:not(:disabled) {
background-color: var(--bs-secondary);
border-color: var(--bs-secondary);
color: white;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
&:active:not(:disabled) { transform: translateY(0); }
&:disabled { cursor: not-allowed; }
}
@media (max-width: 768px) {
.card-nav-prev,
.card-nav-next {
min-width: 40px;
padding: 0.5rem;
}
}
/* --------------------------------------------------
Swipe Animation
-------------------------------------------------- */
/* Smooth the hero image morph */
::view-transition-group(card-hero) {
animation-duration: 350ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Fade the old image out quickly so it doesn't ghost */
::view-transition-old(card-hero) {
display: none;
}
/* Fade the new image in after it's in position */
::view-transition-new(card-hero) {
animation-duration: 350ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Suppress the default full-page crossfade so only the card morphs */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
/* Sliding out (old content) */
::view-transition-old(.modal-content) {
animation: slide-out 200ms ease-in forwards;
}
/* Sliding in (new content) */
::view-transition-new(.modal-content) {
animation: slide-in 200ms ease-out forwards;
}
/* Direction-aware — set via dataset.navDirection */
#cardModal[data-nav-direction="next"]::view-transition-old(.modal-content) {
animation: slide-out-left 200ms ease-in forwards;
}
#cardModal[data-nav-direction="next"]::view-transition-new(.modal-content) {
animation: slide-in-right 200ms ease-out forwards;
}
#cardModal[data-nav-direction="prev"]::view-transition-old(.modal-content) {
animation: slide-out-right 200ms ease-in forwards;
}
#cardModal[data-nav-direction="prev"]::view-transition-new(.modal-content) {
animation: slide-in-left 200ms ease-out forwards;
}
/* The silhouette fades out while the colour image blooms in */
::view-transition-old(pokemon-reveal) {
animation: 300ms ease-in both fade-to-white;
}
::view-transition-new(pokemon-reveal) {
animation: 500ms ease-out both bloom-in;
}
@keyframes fade-to-white {
to { opacity: 0; filter: brightness(3); }
}
@keyframes bloom-in {
from { opacity: 0; filter: brightness(2) saturate(0); transform: scale(0.95); }
to { opacity: 1; filter: brightness(1) saturate(1); transform: scale(1); }
}
/* --------------------------------------------------
Input Fix (Safari)
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
height: 1rem;
width: 1rem;
display: block;
background-repeat: no-repeat;
background-size: 1rem;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAAn0lEQVR42u3UMQrDMBBEUZ9WfQqDmm22EaTyjRMHAlM5K+Y7lb0wnUZPIKHlnutOa+25Z4D++MRBX98MD1V/trSppLKHqj9TTBWKcoUqffbUcbBBEhTjBOV4ja4l4OIAZThEOV6jHO8ARXD+gPPvKMABinGOrnu6gTNUawrcQKNCAQ7QeTxORzle3+sDfjJpPCqhJh7GixZq4rHcc9l5A9qZ+WeBhgEuAAAAAElFTkSuQmCC);
}
-------------------------------------------------- */

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');
@@ -25,3 +25,28 @@ cardModal.addEventListener('hide.bs.modal', () => {
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 });

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

@@ -0,0 +1,234 @@
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);
const allDates = [...new Set(filtered.map(r => r.calculatedAt))]
.sort((a, b) => new Date(a) - new Date(b));
const labels = allDates.map(formatDate);
const lookup = {};
for (const row of filtered) {
if (!lookup[row.condition]) lookup[row.condition] = {};
lookup[row.condition][row.calculatedAt] = Number(row.marketPrice);
}
// Check specifically whether the active condition has any data points
const activeConditionDates = allDates.filter(
date => lookup[activeCondition]?.[date] != null
);
const activeConditionHasData = activeConditionDates.length > 0;
const datasets = CONDITIONS.map(condition => {
const isActive = condition === activeCondition;
const colors = CONDITION_COLORS[condition];
const data = allDates.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);
// Show empty state if no data at all, or if the active condition specifically has no data
if (!hasData || !activeConditionHasData) {
setEmptyState(true);
return;
}
setEmptyState(false);
chartInstance.data.labels = labels;
chartInstance.data.datasets = datasets;
chartInstance.update('none');
}
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);
if (!hasData || !activeConditionHasData) {
setEmptyState(true);
return;
}
setEmptyState(false);
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

@@ -1,35 +1,43 @@
---
---
<button type="button" class="btn btn-info p-2 rounded-circle" aria-label="Back to Top" id="btn-back-to-top" onclick="dataLayer.push({'event': 'backToTop'});">
<span class="top-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M232 64C201.1 64 176 89.1 176 120L176 320.6C172.8 322.6 169.6 324.8 166.5 327.1L144 344C123.9 359.1 112 382.8 112 408L112 427.5C112 480.5 141.1 529.2 187.7 554.3L196 558.8C217 570.1 240.4 576 264.3 576L400 576C461.9 576 512 525.9 512 464L512 368C512 332.7 483.3 304 448 304C445.2 304 442.4 304.2 439.7 304.5C428.7 285.1 407.9 272 384 272C378.7 272 373.5 272.7 368.5 273.9C357.7 253.7 336.5 240 312 240C303.5 240 295.4 241.7 288 244.7L288 120C288 89.1 262.9 64 232 64zM208 120C208 106.7 218.7 96 232 96C245.3 96 256 106.7 256 120L256 277.5C256 284.6 260.6 290.8 267.4 292.8C274.2 294.8 281.4 292.2 285.4 286.3C291.2 277.6 301 272 312.1 272C327.7 272 340.7 283.2 343.5 297.9C344.5 303 347.9 307.4 352.7 309.5C357.5 311.6 363 311.3 367.5 308.6C372.3 305.7 378 304 384.1 304C398.8 304 411.3 314 415 327.6C416.2 332 419.2 335.7 423.3 337.7C427.4 339.7 432.1 339.9 436.4 338.3C440 336.9 444 336.1 448.2 336.1C465.9 336.1 480.2 350.4 480.2 368.1L480.2 464.1C480.2 508.3 444.4 544.1 400.2 544.1L264.5 544.1C246 544.1 227.7 539.5 211.4 530.7L211.4 530.7L203.1 526.2C166.6 506.6 144 468.7 144 427.5L144 408C144 392.9 151.1 378.7 163.2 369.6L176 360L176 408C176 416.8 183.2 424 192 424C200.8 424 208 416.8 208 408L208 120z"/></svg></span>
<button
type="button"
class="btn btn-info p-2 rounded-circle"
aria-label="Back to Top"
aria-hidden="true"
id="btn-back-to-top"
style="display:none"
>
<span class="top-icon">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path d="M232 64C201.1 64 176 89.1 176 120L176 320.6C172.8 322.6 169.6 324.8 166.5 327.1L144 344C123.9 359.1 112 382.8 112 408L112 427.5C112 480.5 141.1 529.2 187.7 554.3L196 558.8C217 570.1 240.4 576 264.3 576L400 576C461.9 576 512 525.9 512 464L512 368C512 332.7 483.3 304 448 304C445.2 304 442.4 304.2 439.7 304.5C428.7 285.1 407.9 272 384 272C378.7 272 373.5 272.7 368.5 273.9C357.7 253.7 336.5 240 312 240C303.5 240 295.4 241.7 288 244.7L288 120C288 89.1 262.9 64 232 64zM208 120C208 106.7 218.7 96 232 96C245.3 96 256 106.7 256 120L256 277.5C256 284.6 260.6 290.8 267.4 292.8C274.2 294.8 281.4 292.2 285.4 286.3C291.2 277.6 301 272 312.1 272C327.7 272 340.7 283.2 343.5 297.9C344.5 303 347.9 307.4 352.7 309.5C357.5 311.6 363 311.3 367.5 308.6C372.3 305.7 378 304 384.1 304C398.8 304 411.3 314 415 327.6C416.2 332 419.2 335.7 423.3 337.7C427.4 339.7 432.1 339.9 436.4 338.3C440 336.9 444 336.1 448.2 336.1C465.9 336.1 480.2 350.4 480.2 368.1L480.2 464.1C480.2 508.3 444.4 544.1 400.2 544.1L264.5 544.1C246 544.1 227.7 539.5 211.4 530.7L211.4 530.7L203.1 526.2C166.6 506.6 144 468.7 144 427.5L144 408C144 392.9 151.1 378.7 163.2 369.6L176 360L176 408C176 416.8 183.2 424 192 424C200.8 424 208 416.8 208 408L208 120z"/>
</svg>
</span>
</button>
<script>
//Get the button
let mybutton = document.getElementById("btn-back-to-top");
const mybutton = document.getElementById("btn-back-to-top");
// When the user scrolls down 20px from the top of the document, show the button
window.onscroll = function () {
scrollFunction();
};
function scrollFunction() {
if (
document.body.scrollTop > 20 ||
document.documentElement.scrollTop > 20
) {
mybutton.style.display = "block";
} else {
mybutton.style.display = "none";
function setButtonVisibility(visible: boolean) {
if (!mybutton) return;
mybutton.style.display = visible ? "block" : "none";
mybutton.setAttribute("aria-hidden", visible ? "false" : "true");
}
}
// When the user clicks on the button, scroll to the top of the document
mybutton.addEventListener("click", backToTop);
function backToTop() {
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
}
function scrollFunction() {
const scrolled = document.body.scrollTop > 20 || document.documentElement.scrollTop > 20;
setButtonVisibility(scrolled);
}
function backToTop() {
dataLayer.push({ event: "backToTop" });
window.scrollTo({ top: 0, behavior: "smooth" });
}
if (mybutton) {
mybutton.addEventListener("click", backToTop);
}
window.addEventListener("scroll", scrollFunction);
</script>

View File

@@ -2,7 +2,7 @@
import BackToTop from "./BackToTop.astro"
---
<div class="row mb-4">
<div class="col-md-3 display-sm-none">
<div class="col-md-2">
<div class="h5 d-none">Inventory management placeholder</div>
<div class="offcanvas offcanvas-start" data-bs-backdrop="static" tabindex="-1" id="filterBar" aria-labelledby="filterBarLabel">
<div class="offcanvas-header">
@@ -14,17 +14,365 @@ import BackToTop from "./BackToTop.astro"
</div>
</div>
</div>
<div class="col-sm-12 col-md-9 mt-0">
<div id="activeFilters" class="mb-2 d-flex align-items-center justify-content-end small"></div>
<div id="cardGrid" class="row g-xxl-3 g-2 row-cols-2 row-cols-lg-3 row-cols-xxl-4 row-cols-xxxl-5"></div>
<div id="notfound"></div>
</div>
</div>
<div class="modal fade card-modal" id="cardModal" tabindex="-1" aria-labelledby="cardModalLabel" aria-hidden="true" transition:name="">
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
<div class="modal-content">
Loading...
<div class="col-sm-12 col-md-10 mt-0">
<div class="d-flex flex-row align-items-center mb-2">
<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>
<div id="notfound" aria-live="polite"></div>
</div>
</div>
<BackToTop>
<div class="modal card-modal" id="cardModal" tabindex="-1" aria-labelledby="cardModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
<div class="modal-content p-2">Loading...</div>
</div>
</div>
<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"/>
</svg>
</button>
<button id="modalNextBtn" class="modal-nav-btn modal-nav-next d-none" aria-label="Next 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="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
<BackToTop />
<script is:inline>
(function () {
// ── Sort dropdown ─────────────────────────────────────────────────────────
// Plain JS toggle — no dependency on Bootstrap's Dropdown JS initialising.
// Uses event delegation so it works after OOB swaps repopulate #sortBy.
document.addEventListener('click', (e) => {
const sortBy = document.getElementById('sortBy');
// Toggle the menu when the button is clicked
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;
}
// Handle sort option selection
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;
}
// Click outside — close any open sort menu
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');
}
});
// ── Global helpers ────────────────────────────────────────────────────────
window.copyImage = async function(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);
if (navigator.clipboard && navigator.clipboard.write) {
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');
} 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) {
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;
// ── Register cards as HTMX loads them ────────────────────────────────────
const cardGrid = document.getElementById('cardGrid');
const gridObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
const triggers = node.querySelectorAll
? node.querySelectorAll('[data-card-id]')
: [];
for (const el of triggers) {
const id = Number(el.getAttribute('data-card-id'));
if (id && !cardIndex.includes(id)) cardIndex.push(id);
}
if (node.dataset?.cardId) {
const id = Number(node.dataset.cardId);
if (id && !cardIndex.includes(id)) cardIndex.push(id);
}
}
}
});
gridObserver.observe(cardGrid, { childList: true, subtree: true });
// ── Navigation helpers ────────────────────────────────────────────────────
function getAdjacentIds() {
const idx = cardIndex.indexOf(currentCardId);
return {
prev: idx > 0 ? cardIndex[idx - 1] : null,
next: idx < cardIndex.length - 1 ? cardIndex[idx + 1] : null,
idx,
total: cardIndex.length,
};
}
function updateNavButtons(modal) {
const prevBtn = document.getElementById('modalPrevBtn');
const nextBtn = document.getElementById('modalNextBtn');
if (!modal || !modal.classList.contains('show')) {
prevBtn.classList.add('d-none');
nextBtn.classList.add('d-none');
return;
}
const { prev, next } = getAdjacentIds();
prevBtn.classList.toggle('d-none', prev === null);
nextBtn.classList.toggle('d-none', next === null);
}
// ── Trigger infinite scroll sentinel ─────────────────────────────────────
function tryTriggerSentinel() {
const sentinel = cardGrid.querySelector('[hx-trigger="revealed"]');
if (!sentinel) return;
if (typeof htmx !== 'undefined') {
htmx.trigger(sentinel, 'revealed');
} else {
sentinel.scrollIntoView({ behavior: 'instant', block: 'end' });
}
}
// ── Fire card-modal:swapped so the partial's script can init the chart ────
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;
currentCardId = cardId;
const modal = document.getElementById('cardModal');
const url = `/partials/card-modal?cardId=${cardId}`;
const { idx, total } = getAdjacentIds();
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) {
modal.dataset.navDirection = direction;
await document.startViewTransition(doSwap).finished;
delete modal.dataset.navDirection;
} else {
await doSwap();
}
isNavigating = false;
const { idx: newIdx, total: newTotal } = getAdjacentIds();
if (newIdx >= newTotal - 3) tryTriggerSentinel();
}
function navigatePrev() {
const { prev } = getAdjacentIds();
if (prev) loadCard(prev, 'prev');
}
function navigateNext() {
const { next } = getAdjacentIds();
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;
if (e.key === 'ArrowLeft') { e.preventDefault(); navigatePrev(); }
if (e.key === 'ArrowRight') { e.preventDefault(); navigateNext(); }
});
// ── Touch / swipe ─────────────────────────────────────────────────────────
let touchStartX = 0;
let touchStartY = 0;
const SWIPE_THRESHOLD = 50;
document.getElementById('cardModal').addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
}, { passive: true });
document.getElementById('cardModal').addEventListener('touchend', (e) => {
const dx = e.changedTouches[0].clientX - touchStartX;
const dy = e.changedTouches[0].clientY - touchStartY;
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dy) > Math.abs(dx)) return;
if (dx < 0) navigateNext();
else navigatePrev();
}, { passive: true });
// ── HTMX card-modal opens ─────────────────────────────────────────────────
document.body.addEventListener('htmx:beforeRequest', async (e) => {
if (e.detail.elt.getAttribute('hx-target') !== '#cardModal') return;
const cardEl = e.detail.elt.closest('[data-card-id]');
if (cardEl) currentCardId = Number(cardEl.getAttribute('data-card-id'));
if (!document.startViewTransition) return;
e.preventDefault();
const url = e.detail.requestConfig.path;
const target = document.getElementById('cardModal');
const sourceImg = cardEl?.querySelector('img');
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 = 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 = transitionName;
if (!destImg.complete) {
await new Promise(resolve => {
destImg.addEventListener('load', resolve, { once: true });
destImg.addEventListener('error', resolve, { once: true });
});
}
}
});
await transition.finished;
updateNavButtons(target);
initChartAfterSwap(target);
} catch (err) {
console.error('[card-modal] transition failed:', err);
e.detail.elt.dispatchEvent(new MouseEvent('click', { bubbles: true }));
} finally {
if (sourceImg) {
sourceImg.style.viewTransitionName = '';
sourceImg.style.opacity = '';
}
const destImg = target.querySelector('img.card-image');
if (destImg) destImg.style.viewTransitionName = '';
}
});
// ── 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);
});
})();
</script>

View File

@@ -1,14 +0,0 @@
---
import { SignedIn, SignedOut, UserButton, SignInButton, SignUpButton } from "@clerk/astro/components";
---
<div class="row">
<SignedOut>
<div class="col-3">
<SignInButton mode="modal" />
<SignUpButton mode="modal" />
</div>
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</div>

View File

@@ -21,13 +21,16 @@ const energyMap = {
"Fire": fire,
"Water": water,
"Steel": steel,
"Metal": steel,
"Colorless": colorless,
"Fighting": fighting,
"Psychic": psychic,
"Electric": electric,
"Lightning": electric,
};
const svg = energyMap[energy as keyof typeof energyMap] ?? "";
if (!svg && energy) console.warn(`No energy icon found for: ${energy}`);
---
<div class="energy-icon shadow-filter" set:html={svg}></div>
<div class="energy-icon shadow-filter" role="img" aria-label={energy} set:html={svg}></div>

View File

@@ -0,0 +1,14 @@
---
import first from "/src/svg/edition/firstEdition.svg?raw";
const { edition } = Astro.props;
const editionMap = {
"1st Edition Holofoil": first,
"1st Edition": first,
};
const svg = editionMap[edition as keyof typeof editionMap] ?? "";
---
<div class="edition-icon shadow-filter" set:html={svg}></div>

View File

@@ -1,15 +1,17 @@
---
import EnergyWheel from './EnergyWheel.astro';
import '/src/assets/css/main.scss';
---
<footer class="bd-footer py-4 py-md-5 mt-0 bottom-0 bg-body-tertiary">
<div class="container py-4 py-md-5 px-4 px-md-3 text-body-secondary">
<div class="row">
<div class="col-3 mb-3">
</div>
<div class="col mb-3 align-items-end">
<a class="btn btn-outline-success rounded p-2 float-end" href="/contact">Contact Us <svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".25" d="M112 176L404 176C411.9 206.7 431 233 456.6 250.2L320 353.9L112 196.1L112 176zM112 256.3L305.5 403.1L320 414.1L334.5 403.1L509.2 270.6C515.3 271.5 521.6 272 528 272L528 464L112 464L112 256.3z"/><path d="M528 64C572.2 64 608 99.8 608 144C608 188.2 572.2 224 528 224C483.8 224 448 188.2 448 144C448 99.8 483.8 64 528 64zM88 128L401 128C400.3 133.2 400 138.6 400 144C400 155 401.4 165.8 404 176L112 176L112 196.1L320 353.9L456.6 250.3C472.1 260.7 489.9 267.8 509.2 270.7L334.5 403.2L320 414.2L305.5 403.2L112 256.4L112 464.1L528 464.1L528 272.1C545 272.1 561.2 268.8 576 262.8L576 512.1L64 512.1L64 128.1L88 128.1z"/></svg></a>
---
<footer class="bd-footer py-4 py-md-5 mt-0 bg-body-tertiary">
<div class="container py-4 py-md-5 px-4 px-md-3 text-body-secondary">
<div class="row justify-content-end">
<div class="col mb-3">
<a class="btn btn-outline-success rounded p-2 float-end" href="/contact">
Contact Us
<svg aria-hidden="true" class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path opacity=".25" d="M112 176L404 176C411.9 206.7 431 233 456.6 250.2L320 353.9L112 196.1L112 176zM112 256.3L305.5 403.1L320 414.1L334.5 403.1L509.2 270.6C515.3 271.5 521.6 272 528 272L528 464L112 464L112 256.3z"/>
<path d="M528 64C572.2 64 608 99.8 608 144C608 188.2 572.2 224 528 224C483.8 224 448 188.2 448 144C448 99.8 483.8 64 528 64zM88 128L401 128C400.3 133.2 400 138.6 400 144C400 155 401.4 165.8 404 176L112 176L112 196.1L320 353.9L456.6 250.3C472.1 260.7 489.9 267.8 509.2 270.7L334.5 403.2L320 414.2L305.5 403.2L112 256.4L112 464.1L528 464.1L528 272.1C545 272.1 561.2 268.8 576 262.8L576 512.1L64 512.1L64 128.1L88 128.1z"/>
</svg>
</a>
</div>
</div>
</div>

View File

@@ -1,32 +1,10 @@
---
import '/src/assets/css/main.scss';
export const prerender = false;
---
<script is:inline>
const afterUpdate = (e) => {
const start = document.querySelector('#start');
if (start) {
const val = Number(start.value) || 0;
start.value = (val + 20).toString();
}
// delete the triggering element
if (e && e.detail && e.detail.elt) {
e.detail.elt.remove();
}
};
const beforeSearch = (e) => {
const start = document.querySelector('#start');
if (start) {
start.value = '0';
document.querySelector('#cardGrid').innerHTML = '';
window.scrollTo({ top: 0, behavior: 'instant' });
}
};
</script>
<nav class="navbar navbar-expand sticky-top bg-dark" data-bs-theme="dark">
<div class="container container-fluid">
<nav class="navbar navbar-expand sticky-top bg-dark" data-bs-theme="dark" aria-label="Main navigation">
<div class="container">
<a class="navbar-brand d-flex" href="/">
<span class="h3 d-none d-md-flex">Rigid's App Thing</span><span class="h3 d-md-none d-flex m-auto">RAT</span>
<span class="h3 d-none d-md-flex">Rigid's App Thing</span><span aria-hidden="true" class="h3 d-md-none d-flex m-auto">RAT</span>
</a>
<slot name="navItems"/>
<slot name="searchInput"/>

View File

@@ -1,10 +1,16 @@
---
import '/src/assets/css/main.scss';
---
<div class="navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item d-flex">
<a class="nav-link btn btn-warning rounded p-2" href="/pokemon"><span class="d-inline-block d-md-none">Cards</span> <svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M256 519.9L256 576L576 576L576 128L378.8 128C408.7 239.7 438.6 351.3 468.5 463C397.7 482 326.8 501 256 519.9z"/><path d="M43.5 113L352.6 30.2L468.6 462.9L159.5 545.7z"/></svg></a>
</li>
</ul>
</div>
<div class="navbar-collapse" id="navbarNav" aria-labelledby="navbarToggler">
<ul class="navbar-nav ms-auto">
<li class="nav-item d-flex">
<a class="nav-link btn btn-warning rounded p-2" href="/pokemon" aria-label="Cards">
<span class="d-inline-block d-md-none" aria-hidden="true">Cards</span>
<svg aria-hidden="true" class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path opacity=".4" d="M256 519.9L256 576L576 576L576 128L378.8 128C408.7 239.7 438.6 351.3 468.5 463C397.7 482 326.8 501 256 519.9z"/>
<path d="M43.5 113L352.6 30.2L468.6 462.9L159.5 545.7z"/>
</svg>
</a>
</li>
</ul>
</div>

View File

@@ -1,7 +1,6 @@
---
import '/src/assets/css/main.scss';
---
---
<header class="header-top w-100">
<div class="header-wrap">
<div class="header-content">

View File

@@ -45,6 +45,7 @@ const rarityMap = {
};
const svg = rarityMap[rarity as keyof typeof rarityMap] ?? "";
if (!svg && rarity) console.warn(`No rarity icon found for: ${rarity}`);
---
<div class="rarity-icon shadow-filter" set:html={svg}></div>
<div class="rarity-icon shadow-filter" role="img" aria-label={rarity} set:html={svg}></div>

View File

@@ -1,5 +1,5 @@
---
import { SignedIn } from "@clerk/astro/components";
import { Show } from '@clerk/astro/components'
---
<script is:inline>
const afterUpdate = (e) => {
@@ -14,6 +14,8 @@ import { SignedIn } from "@clerk/astro/components";
}
};
const beforeSearch = (e) => {
const notfound = document.getElementById('notfound');
if (notfound) notfound.innerHTML = '';
const start = document.querySelector('#start');
if (start) {
start.value = '0';
@@ -23,16 +25,16 @@ import { SignedIn } from "@clerk/astro/components";
};
</script>
<SignedIn>
<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"><span class="d-block d-md-none filter-icon mt-1"><svg 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>
<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="hidden" name="sort" id="sortInput" value="" />
<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" value="" onclick="const q = document.querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });">
<svg 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 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>
</SignedIn>
</Show>

View File

@@ -123,6 +123,7 @@ import mega_evolutions from "/src/svg/set/mega_evolutions.svg?raw";
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";
const { set } = Astro.props;
@@ -130,7 +131,7 @@ const setMap = {
"JU": jungle,
"FO": fossil,
"B2": base_set_2,
"TR": battle_styles,
"TR": team_rocket,
"G1": gym_heroes,
"G2": gym_challenge,
"SI": southern_islands,
@@ -254,6 +255,7 @@ const setMap = {
};
const svg = setMap[set as keyof typeof setMap] ?? "";
if (!svg && set) console.warn(`No set icon found for: ${set}`);
---
<div class="set-icon shadow-filter" set:html={svg}></div>
<div class="set-icon shadow-filter" role="img" aria-label={set} set:html={svg}></div>

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),
]);
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

@@ -1,13 +1,11 @@
---
import '/src/assets/css/main.scss';
const { title } = Astro.props;
---
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<script is:inline>
window.dataLayer = window.dataLayer || [];
</script>
<!-- Google Tag Manager -->
<script is:inline>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
@@ -17,9 +15,10 @@ import '/src/assets/css/main.scss';
<!-- End Google Tag Manager -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="htmx-config" content='{"historyCacheSize": 50}'/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<title>Rigid's App Thing</title>
<title>{title}</title>
</head>
<body>
<!-- Google Tag Manager (noscript) -->
@@ -39,6 +38,8 @@ import '/src/assets/css/main.scss';
</div>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.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>

View File

@@ -7,8 +7,11 @@ const isProtectedRoute = createRouteMatcher([
]);
export const onRequest = clerkMiddleware((auth, context) => {
if (!auth().userId && isProtectedRoute(context.request)) {
// Redirect unauthenticated users to the sign-in page
return auth().redirectToSignIn();
const { isAuthenticated, redirectToSignIn } = auth()
if (!isAuthenticated && isProtectedRoute(context.request)) {
// Add custom logic to run before redirecting
return redirectToSignIn()
}
});

View File

@@ -1,13 +1,10 @@
---
export const prerender = false;
import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import NavBar from '../components/NavBar.astro';
export const prerender = false;
import pokedexList from '../data/pokedex.json';
import Footer from '../components/Footer.astro';
const searchParams = Astro.url.searchParams;
const query = searchParams.get('q') || '*';
import pokedexList from '../data/pokedex.json';
// Get random # (00011025)
const randomNumber = String(Math.floor(Math.random() * 1025) + 1).padStart(4, "0");
@@ -21,52 +18,163 @@ const pokemon = pokedexList.find(p => p["#"] === randomNumber);
// If not found (rare), fallback
const pokemonName = pokemon?.Name || "Unknown Pokémon";
---
<Layout>
<Layout title="404 - Page Not Found">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<div class="col-12 col-md-6">
<h1 class="mb-4">404 - Page Not Found</h1>
<h1 class="mb-4">404<br/>Page Not Found</h1>
<h4>Sorry, the page you are looking for does not exist.</h4>
<p class="copy-big my-4">
Return to the <a href="/">home page</a> or search for another <a href="/pokemon">Pokémon</a>.
</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>
<div class="p-0 ratio ratio-1x1 position-relative overflow-hidden d-flex justify-items-center">
<img class="whos-that-pokemon position-absolute h-100" src="/404/lines.gif">
<img class="whos-that-pokemon position-absolute h-100" src="/404/lines.gif" alt="" />
<div class="d-flex flex-col-reverse flex-lg-row">
<div class="">
<img class="w-100 starburst top-0 bottom-0 left-0 right-0" src="/404/glow.png">
<div class="d-flex flex-column-reverse flex-lg-row">
<div>
<img class="w-100 starburst top-0 bottom-0 left-0 right-0" src="/404/glow.png" alt="" />
<!-- ✨ Name is placed in a data attribute for later use -->
<img class="m-auto position-absolute w-50 top-0 left-25 bottom-10 right-0 d-block img-fluid masked-image top-50 start-50 translate-middle" src={pokedexImage} alt={pokemonName} data-name={pokemonName} onclick="dataLayer.push({'event': '404reveal','pokemonName': this.getAttribute('data-name')});"/>
<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 pokemon-clickable"
src={pokedexImage}
alt=""
data-name={pokemonName}
role="button"
tabindex="0"
draggable="false"
aria-label="Reveal the Pokémon"
/>
</div>
</div>
</div>
<!-- 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>
<script>
const img = document.querySelector('.masked-image');
const nameEl = document.querySelector('#pokemon-name');
img?.addEventListener('click', () => {
img.classList.remove('masked-image');
nameEl.textContent = img.dataset.name || "Unknown Pokémon";
nameEl.classList.remove('opacity-0');
});
</script>
</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 = () => {
// 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');
// 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) {
doReveal();
return;
}
img.style.viewTransitionName = 'pokemon-reveal';
document.startViewTransition(() => {
doReveal();
}).finished.then(() => {
img.style.viewTransitionName = '';
});
}
img?.addEventListener('click', revealPokemon);
img?.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
revealPokemon();
}
});
playAgainBtn?.addEventListener('click', () => {
window.location.reload();
});
</script>

View File

@@ -1,42 +1,92 @@
---
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';
export const prerender = false;
---
<Layout>
<Layout title="Contact Us">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<h1>Contact Us</h1>
<div class="col-12">
<h1>Contact Us</h1>
</div>
<div class="col-12 col-md-8 col-lg-6">
<form action="https://docs.google.com/forms/u/0/d/e/1FAIpQLSc4F7VZjZ6ImWnNRqzMLyAWnyGQdEC3Nr2xtbzugewky239kg/formResponse" method="POST" id="contactForm">
<!-- Name input -->
<div class="mb-3">
<label for="name" class="form-label">Full Name</label>
<input type="text" class="form-control" id="name" name="entry.563494744" required>
</div>
<form action="https://docs.google.com/forms/u/0/d/e/1FAIpQLSc4F7VZjZ6ImWnNRqzMLyAWnyGQdEC3Nr2xtbzugewky239kg/formResponse" method="POST" id="contactForm" target="hidden-iframe">
<!-- Email address input -->
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="entry.577942868" aria-describedby="emailHelp" required>
<div id="emailHelp" class="form-text">We'll never share your email with anyone else.</div>
</div>
<!-- Honeypot field to deter spam -->
<div style="display:none" aria-hidden="true">
<label for="honeypot">Leave this field blank</label>
<input type="text" id="honeypot" name="honeypot" tabindex="-1" autocomplete="off" />
</div>
<!-- Message textarea -->
<div class="mb-3">
<label for="message" class="form-label">Message</label>
<textarea class="form-control" id="message" name="entry.1640055664" rows="4" required></textarea>
</div>
<!-- Name input -->
<div class="mb-3">
<label for="name" class="form-label">Full Name</label>
<input type="text" class="form-control" id="name" name="entry.563494744" required />
</div>
<!-- Submit button -->
<button type="submit" class="btn btn-light">Submit</button>
<!-- Email address input -->
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="entry.577942868" aria-describedby="emailHelp" required />
<div id="emailHelp" class="form-text">We'll never share your email with anyone else.</div>
</div>
<!-- Message textarea -->
<div class="mb-3">
<label for="message" class="form-label">Message</label>
<textarea class="form-control" id="message" name="entry.1640055664" rows="4" required></textarea>
</div>
<!-- Submit button -->
<button type="submit" class="btn btn-light" id="submitBtn">Submit</button>
</form>
<!-- Hidden iframe absorbs the Google Forms redirect -->
<iframe name="hidden-iframe" style="display:none" aria-hidden="true"></iframe>
<!-- Success message (hidden until submission) -->
<div id="successMsg" class="alert alert-success mt-3 d-none" role="alert">
Thanks for reaching out! We'll get back to you soon.
</div>
</div>
</div>
<Footer slot="footer" />
</Layout>
<script>
const form = document.getElementById('contactForm') as HTMLFormElement | null;
const submitBtn = document.getElementById('submitBtn') as HTMLButtonElement | null;
const successMsg = document.getElementById('successMsg');
const honeypot = document.getElementById('honeypot') as HTMLInputElement | null;
const iframe = document.querySelector('iframe[name="hidden-iframe"]') as HTMLIFrameElement | null;
form?.addEventListener('submit', (e) => {
// Honeypot check — bail silently if filled in by a bot
if (honeypot?.value) {
e.preventDefault();
return;
}
if (!submitBtn || !successMsg) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Sending...';
});
// iframe load fires after Google Forms redirects into it — treat as success
iframe?.addEventListener('load', () => {
if (!form || !submitBtn || !successMsg) return;
// Ignore the initial empty load before any submission
if (!submitBtn.disabled) return;
form.reset();
form.classList.add('d-none');
successMsg.classList.remove('d-none');
dataLayer.push({ event: 'contact_form_submit' });
});
</script>

View File

@@ -1,32 +1,48 @@
---
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';
export const prerender = false;
import { Waitlist as WaitlistAstro } from '@clerk/astro/components';
import { SignIn as SignInAstro } from '@clerk/astro/components';
import { Sign } from 'node:crypto';
import { Show, SignInButton, SignUpButton, SignOutButton } from '@clerk/astro/components'
---
<Layout>
<Layout title="Rigid's App Thing">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<h1>Rigid's App Thing</h1>
<h5 class="text-secondary">(working title)</h5>
<div class="col-12 col-md-7 mb-2">
<h4 class="mt-3">Welcome!</h4>
<div class="col-12">
<h1>Rigid's App Thing</h1>
<p class="text-secondary">(working title)</p>
</div>
<div class="col-12 col-md-6 mb-2">
<h2 class="mt-3">Welcome!</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.</p>
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.
</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!
</p>
<a href="/pokemon" class="btn btn-warning mt-2">Take me to the cards</a>
<Show when="signed-in">
<a href="/pokemon" class="btn btn-warning mt-2">Take me to the cards</a>
</Show>
</div>
<div class="col-12 col-md-4 offset-md-1">
<div class="col-12 col-md-6 d-flex justify-content-end align-items-start mt-3">
<div class="d-flex gap-3">
<Show when="signed-out">
<SignInButton asChild mode="modal">
<button class="btn btn-success">Sign In</button>
</SignInButton>
<SignUpButton asChild mode="modal">
<button class="btn btn-dark">Request Access</button>
</SignUpButton>
</Show>
<Show when="signed-in">
<SignOutButton asChild>
<button class="btn btn-danger">Sign Out</button>
</SignOutButton>
</Show>
</div>
</div>
</div>
<Footer slot="footer" />

View File

@@ -4,7 +4,11 @@ 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;
@@ -12,8 +16,6 @@ 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: {
@@ -24,9 +26,7 @@ const card = await db.query.cards.findFirst({
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,
@@ -34,189 +34,339 @@ 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
.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())));
})();
// ── Fetch price history + compute volatility ──────────────────────────────
const cardSkus = card?.prices?.length
? await db.select().from(skus).where(eq(skus.cardId, cardId))
: [];
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)
: [];
// Rolling 30-day cutoff for volatility calculation
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);
}
// ── Price history for chart (full history, not windowed) ──────────────────
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);
// ── Determine which range buttons to show ────────────────────────────────
const now = Date.now();
const oldestDate = historyRows.length
? Math.min(...historyRows
.filter(r => r.calculatedAt)
.map(r => new Date(r.calculatedAt!).getTime()))
: now;
const dataSpanDays = (now - oldestDate) / 86_400_000;
const showRanges = {
'1m': dataSpanDays >= 1,
'3m': dataSpanDays >= 60,
'6m': dataSpanDays >= 180,
'1y': dataSpanDays >= 365,
'all': dataSpanDays >= 400,
};
const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
const conditionAttributes = (price: any) => {
const volatility = (() => {
const current = price?.marketPrice;
const low = price?.lowestPrice;
const high = price?.highestPrice;
const median = price?.medianPrice;
const condition: string = price?.condition || "Near Mint";
const vol = volatilityByCondition[condition] ?? { label: '—', monthlyVol: 0 };
if (current === null || low === null || high === null) return "—";
const range = Number(high) - Number(low);
if (range <= 0) return "Low";
const position = (Number(current) - Number(low)) / range;
if (position > 0.76) return "High";
if (position > 0.49) return "Medium";
return "Low";
const volatilityClass = (() => {
switch (vol.label) {
case "High": return "alert-danger";
case "Medium": return "alert-warning";
case "Low": return "alert-success";
default: return "alert-dark";
}
})();
const volatilityClass =
volatility === "High" ? "alert-danger" :
volatility === "Medium" ? "alert-warning" :
volatility === "Low" ? "alert-success" :
"";
const volatilityDisplay = vol.label === '—'
? '—'
: `${vol.label} (${(vol.monthlyVol * 100).toFixed(0)}%)`;
const condition: string = price?.condition || "Near Mint";
return {
"Near Mint": { label: "nav-nm", volatility: volatility, volatilityClass: volatilityClass, class:"show active" },
"Lightly Played": { label: "nav-lp", volatility: volatility, volatilityClass: volatilityClass },
"Moderately Played": { label: "nav-mp", volatility: volatility, volatilityClass: volatilityClass },
"Heavily Played": { label: "nav-hp", volatility: volatility, volatilityClass: volatilityClass },
"Damaged": { label: "nav-dmg", volatility: volatility, volatilityClass: 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];
};
const ebaySearchUrl = (card: any) => {
return `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&LH_Sold=1&Graded=No&_dcat=183454`;
};
const altSearchUrl = (card: any) => {
return `https://alt.xyz/browse?query=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&sortBy=newest_first`;
};
---
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
<div class="modal-content">
<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-secondary 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>
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body pt-0">
<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 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-row justify-content-between mt-2">
<div class="p text-secondary">{card?.set?.setCode}</div>
<div class="p text-secondary">Illustrator: {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-md-inline">Near Mint</span><span class="d-md-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-md-inline">Lightly Played</span><span class="d-md-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-md-inline">Moderately Played</span><span class="d-md-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-md-inline">Heavily Played</span><span class="d-md-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-md-inline">Damaged</span><span class="d-md-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-md-inline">Inventory</span><span class="d-md-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-md-flex">
<div class="d-flex flex-row flex-md-column gap-1 col-12 col-md-2 mb-0">
<div class="alert alert-secondary rounded p-2 flex-fill mb-1">
<h6>Market Price</h6>
<p class="pb-0">${price.marketPrice}</p>
</div>
<div class="alert alert-secondary rounded p-2 flex-fill mb-1">
<h6>Lowest Price</h6>
<p class="pb-0">${price.lowestPrice}</p>
</div>
<div class="alert alert-secondary rounded p-2 flex-fill mb-1">
<h6>Highest Price</h6>
<p class="pb-0">${price.highestPrice}</p>
</div>
<div class={`alert alert-secondary rounded p-2 flex-fill mb-1 ${attributes?.volatilityClass}`}>
<h6>Volatility</h6>
<p class="pb-0">{attributes?.volatility}</p>
</div>
</div>
<div class="d-flex flex-column gap-1 col-12 col-md-10 mb-0 me-2">
<div class="alert alert-secondary rounded p-2 mb-1">
<h6>Latest Sales</h6>
</div>
<div class="alert alert-secondary rounded p-2 mb-1">
<h6>Placeholder for graph</h6>
</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">
<!-- Card image column -->
<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>
<!-- 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">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-0">
<h6 class="mb-auto">Highest Price</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>Volatility</span>
<span
class="volatility-info"
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'>Monthly Volatility</div>
<div class='small'>
<p class="mb-1">
<strong>What this measures:</strong> how much the market price tends to move day-to-day,
scaled up to a monthly expectation.
</p>
<p class="mb-0">
A card with <strong>30% volatility</strong> typically swings ±30% over a 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">
<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">
{showRanges['1m'] && <button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button>}
{showRanges['3m'] && <button type="button" class="btn btn-dark price-range-btn" data-range="3m">3M</button>}
{showRanges['6m'] && <button type="button" class="btn btn-dark price-range-btn" data-range="6m">6M</button>}
{showRanges['1y'] && <button type="button" class="btn btn-dark price-range-btn" data-range="1y">1Y</button>}
{showRanges['all'] && <button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>}
</div>
</div>
</div>
</div>
<div class="col-sm-12 col-md-2 mt-0 mt-md-5">
<a class="btn btn-secondary mb-2 w-100" href={`https://www.tcgplayer.com/product/${card?.productId}`} target="_blank" onclick="dataLayer.push({'event': 'tcgplayerClick', 'tcgplayerUrl': this.getAttribute('href')});"><img src="/vendors/tcgplayer.webp"> TCGPlayer</a>
<a class="btn btn-secondary mb-2 w-100" href={`${ebaySearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'ebayClick', 'ebayUrl': this.getAttribute('href')});"><span set:html={ebay} /></a>
</div>
</div>
<div class="text-end my-0"><small class="text-body-secondary">Prices last changed: {timeAgo(calculatedAt)}</small></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 class="text-end my-0"><small class="text-body-tertiary">Prices last changed: {timeAgo(calculatedAt)}</small></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script is:inline>
async function copyImage(img) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
// draw the real image pixels
ctx.drawImage(img, 0, 0);
// convert to blob
canvas.toBlob(async (blob) => {
await navigator.clipboard.write([
new ClipboardItem({ "image/png": blob })
]);
console.log("Copied image via canvas.");
});
}
</script>

View File

@@ -1,9 +1,12 @@
---
import { client } from '../../db/typesense';
import RarityIcon from '../../components/RarityIcon.astro';
import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
export const prerender = false;
import * as util from 'util';
// all the facet fields we want to use for filtering
const facetFields:any = {
"productLineName": "Product Line",
@@ -14,13 +17,31 @@ const facetFields:any = {
"energyType": "Energy Type"
}
// ── Allowed sort values ───────────────────────────────────────────────────
// Maps the client-supplied key to the actual Typesense sort_by string.
// Never pass raw user input directly to sort_by.
// Note: price sorting uses nmMarketPrice — a field you need to denormalize
// onto your card document in your Typesense indexing step (NM market price
// as an integer in cents, e.g. nmMarketPrice: 499 = $4.99).
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;
const filters = Array.from(formData.entries())
.filter(([key, value]) => key !== 'q' && key !== 'start')
.filter(([key, value]) => key !== 'q' && key !== 'start' && key !== 'sort')
.reduce((acc, [key, value]) => {
if (!acc[key]) {
acc[key] = [];
@@ -34,13 +55,13 @@ 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}` : ''}`;
};
@@ -54,7 +75,7 @@ let searchArray = [{
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(*)',
}];
@@ -77,7 +98,7 @@ if (start === 0) {
const searchRequests = { searches: searchArray };
const commonSearchParams = {
q: query,
query_by: 'productLineName,productName,setName,number,rarityName,Artist',
query_by: 'content'
};
// use typesense to search for cards matching the query and return the productIds of the results
@@ -114,10 +135,23 @@ const facetNames = (name:string) => {
}
const facets = searchResults.results.slice(1).map((result: any) => {
return result.facet_counts[0];
const facet = result.facet_counts[0];
if (!facet) return facet;
// Sort: checked items first, then alphabetically
facet.counts = facet.counts.sort((a: any, b: any) => {
const aChecked = filters[facet.field_name]?.includes(a.value) ?? false;
const bChecked = filters[facet.field_name]?.includes(b.value) ?? false;
if (aChecked && !bChecked) return -1;
if (!aChecked && bChecked) return 1;
return a.value.localeCompare(b.value);
});
return facet;
});
---
{(start === 0) &&
<div id="facetContainer" hx-swap-oob="true">
@@ -144,14 +178,30 @@ const facets = searchResults.results.slice(1).map((result: any) => {
</div>
))}
</div>
<div id="activeFilters" class="mb-2 d-flex align-items-center justify-content-end small" hx-swap-oob="true">
<div id="sortBy" class="mb-2 d-flex align-items-center justify-content-start small" hx-swap-oob="true">
<div class="dropdown">
<button class="btn btn-sm btn-dark dropdown-toggle small" data-bs-toggle="dropdown" aria-expanded="false">Sort by</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item sort-option small" 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 small" 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 small" href="#" data-sort="marketPrice:desc" data-label="Price: High to Low">Price: High to Low</a></li>
<li><a class="dropdown-item sort-option small" href="#" data-sort="marketPrice:asc" data-label="Price: Low to High">Price: Low to High</a></li>
<li><a class="dropdown-item sort-option small" href="#" data-sort="number:asc" data-label="Card Number: Ascending">Card Number: Ascending</a></li>
<li><a class="dropdown-item sort-option small" href="#" data-sort="number:desc" data-label="Card Number: Descending">Card Number: Descending</a></li>
</ul>
</div>
<span id="sortLabel" class="ms-2 text-secondary">{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>
<div id="totalResults" class="mb-2 ms-5 text-secondary small" hx-swap-oob="true">
{totalHits} {totalHits === 1 ? ' result' : ' results'}
</div>
<div id="activeFilters" class="mb-2 d-flex align-items-center small ms-auto" 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 remove-filter">{value}</li>
))
))}
</ul>
@@ -159,7 +209,6 @@ const facets = searchResults.results.slice(1).map((result: any) => {
}
</div>
<script define:vars={{ totalHits, filters, facets }} is:inline>
// Filter the facet values to make things like Set easier to find
@@ -213,8 +262,8 @@ const facets = searchResults.results.slice(1).map((result: any) => {
<div class="inventory-button position-relative float-end shadow-filter text-center d-none">
<div class="inventory-label pt-2">+/-</div>
</div>
<div 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'});">
<img src={`/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 card-image image-grow w-100" onerror="this.onerror=null;this.src='/cards/default.jpg'"/>
<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) => (
@@ -226,11 +275,11 @@ const facets = searchResults.results.slice(1).map((result: any) => {
</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-md-flex">{card.setName}</div>
<div class="text-secondary">{card.number}</div>
<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>{card.variant}</div><span class="d-none">{card.productId}</span>
<div class="text-body-tertiary">{card.variant}</div><span class="d-none">{card.productId}</span>
</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' }
});
};

View File

@@ -1,14 +1,14 @@
---
export const prerender = false;
import Layout from '../layouts/Main.astro';
import Search from '../components/Search.astro';
import CardGrid from "../components/CardGrid.astro";
import NavBar from '../components/NavBar.astro';
export const prerender = false;
---
<Layout>
<Layout title="Card Search">
<NavBar slot="navbar">
<Search slot="searchInput" />
</NavBar>
</NavBar>
<CardGrid slot="page" />
</Layout>

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><image width="48" height="48" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAQAAAD9CzEMAAAFRklEQVR42u2XXYhVVRTH5+PemcnsC0NjaPqizOghc5Iw08gs7CUTihT6UAqqB0v8wiyxkcIIwwoKRQrH0Qn0ITWNyszIZmxGyzDDMs3R7CG1B2lm7pyv/Wu17mLfe5ghL9d5dC3OOf+z9t7rv/Zea5+7b8UFGQQhrZWIGh401xmy4rQKucxSrWpE5+M6q84VD6hZ31qG+xof73A2u33R7qA92BUeDkngU5fhJmut0pmUueaNbjNbSUiLGiJiaGa0ZaYMgukcDXCo9BGdinZGbWF7vMOdcIQxXtowCtWSXNfI9bKFGhC6BBopNM9BxH3JEWkVanhRjHVUU/O/JN65xqKRB61UsVaZbtPFloKSazEi2vkByIFb4LmrLG8Du7cyVE164YQTwK2A8y7vtbfP9W0WuAB+iHaEnW4nj1sJ9ycwdrlLp66AHKEMxEbsJS+jBN8DRL7lsKCcS6w9hk7nKQZ232Kdzc1+NT4mqBeYLHgCKupjRN6p3LrCjgiJS96O9KuqohrYEGm9fOjmui3maKw2nFY8QdD9ir5Q63LFnbYhLqYLuoVioWZygPhbQgjgOsSg8ckA1il+Sl1NElSv6Am1/iFogyJTnVDyu8IUQVauGYm6rxd4uR9wEhimAYWCGhCbT/fNwNl8PyHcrs/VDvpQ6He3xZ/8BIQZbXszH63oSEGrFK0UtJ63eBc4rJZXBU316JSiZU7rVoM2Cn0Rv64NCJZZRCuARxX9LWg0H/A9i5nGM7JYdzGHLazyM3kS+M3GnY4hPirAZ1YfWhKXarU5W1Pm06vPz4BfeURQWsfwHXCtbrblZpsNLsT9GZ4FXssbleWXCha5Ba7VEQjJLut+ja34fHsfwlCyeWSWZXQUUc5Ak+jA5Gkkebo8bDOrS+gGTvpB+3hJ903WW6YxRdc0o7YFfGSUa/Divk7ok8deJLvSSr2DkBwRKgFwhuHSsJ1v5V5rm+R2mfUx4D3BdWqplWut93uEZp7lFm35JoEwR0WgOZgUQTJDQCs97PEDXofC90sGHuS42l/BCIwYfmakIqsYzaCWa4Mm2S10kBsn5hbRCqYg1IzS7WSuTBuUYClmtTnMZZd/q9V6eQiRKIGpeYLVCJ1EQzfz5L6BfyyyaixK24sT0wS+tYurZPnm2ftzHJXZaxbeVoK4HbojAYzjSrk3aYVN1VRXpiY+Pk3gvzGdQBtD7LAxQu25COL9ShAI/DixAd7lEnZr3OcgULRGv0hp3QaE5Gcg8ICbzWTqizpsZI3cLyqJoJX3fRYmSmifOIm/B8gTcCCBhHyphgfjja7JNXKCzSXPYCUBD7PWnYnpE+jAZJYSuHoOxeSUw4uDPaXkQAthk43Qh3LEHazmTsSB9LBcjWWmeyPZGR3XH0ygR8zpKrpbXTQZQUH/AnrFcXzMNbvnaSxm14eWQrEOozmCcLI6LszgDiVYZARGK/70lNRK+ohckx9aqLaMmOo81VAhoD3v2OsUJXiH9AGqHZIQrrdDci2ZgQ5hqeO5BvhVDMF0AcKq0R8q+uqM0ZiG6MeaMIIfBXrH5xDL3BWadNfgP8+X+RguodKsV4PDJRZ/iedTP/cX/psD3OBzUNCM/zbpUWWpfZZKJvDnDKWImwT21zmQ6PKst94lEqRPSut0ofriZEVR9d3Ick7pj0oMLYXepYvNwcmdBx0BIgk5AZFoKChGHkI+08+2rD9RlbbYS5NcHw4vwiQMTWX9v+l/nLfneLc42eT2uo5kq1vCfYWdVpb7dLo9SVqzqdSWT+IXq06uStFqQVm/qQZHGEAvyCDJv7+zPKs2IGdiAAAAAElFTkSuQmCC"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB