Compare commits
50 Commits
c28f9a5e84
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85cfd1de64 | ||
|
|
c03a0b36a0 | ||
|
|
5cdf9b1772 | ||
|
|
17465b13c1 | ||
|
|
c61cafecdc | ||
|
|
2b3d5f322e | ||
|
|
53cdddb183 | ||
| 35c8bf25f5 | |||
| 3f9b1accda | |||
| 03e606e152 | |||
| b871385fba | |||
| 4c6922f76b | |||
| 171ce294f4 | |||
|
|
023cd87319 | ||
|
|
04ea65eeeb | ||
|
|
9d9524e654 | ||
| c0120e3e77 | |||
|
|
bc99be51ea | ||
| 660da7cded | |||
| 2a17654c74 | |||
|
|
b06e24d382 | ||
|
|
7b4e06733f | ||
|
|
f72d479c1d | ||
|
|
ee9f7a2561 | ||
|
|
2f17912949 | ||
| a86dc08b50 | |||
|
|
c4ebbfb060 | ||
|
|
9c81a13c69 | ||
| 3a6dbf2ed9 | |||
| e1ab59a2eb | |||
|
|
a8df9c71ee | ||
|
|
835a174da2 | ||
| 485f26de7b | |||
| c10e34cc34 | |||
| d9995e5e10 | |||
| c622c8bd8f | |||
| f03c909745 | |||
| a68ed7f7b8 | |||
|
|
3d46a48a7d | ||
| 1089bcdc20 | |||
|
|
7482cb9e9c | ||
|
|
68bed6ff8e | ||
| f5fcd7b3e7 | |||
|
|
4eed1869a6 | ||
|
|
ce56d08efe | ||
|
|
7fd8a21d1c | ||
| 2fa0be9d23 | |||
|
|
dedd7f8d87 | ||
|
|
091aa72f23 | ||
|
|
692d06c35a |
3
.gitignore
vendored
@@ -26,6 +26,9 @@ pnpm-debug.log*
|
||||
# imges from tcgplayer
|
||||
public/cards/*
|
||||
|
||||
# static assets
|
||||
/static/
|
||||
|
||||
# anything test
|
||||
test.*
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ export default defineConfig({
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
},
|
||||
adapter: node({ mode: "standalone", checkOrigin: false }),
|
||||
output: "server",
|
||||
security: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
2912
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
BIN
public/holofoils/ancient.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/holofoils/angular.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/holofoils/cosmos-bottom-trans.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
public/holofoils/cosmos-bottom.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
public/holofoils/cosmos-middle-trans.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
public/holofoils/cosmos-middle.gif
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
public/holofoils/cosmos-middle.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
public/holofoils/cosmos-top-trans.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/holofoils/cosmos-top.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/holofoils/cosmos.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/holofoils/crossover.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/holofoils/galaxy-source.png
Normal file
|
After Width: | Height: | Size: 561 KiB |
BIN
public/holofoils/galaxy.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
public/holofoils/geometric.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/holofoils/glitter.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
public/holofoils/grain.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
public/holofoils/illusion-mask.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
public/holofoils/illusion.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/holofoils/illusion2.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/holofoils/metal.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/holofoils/rainbow.jpg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/holofoils/stylish.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/holofoils/stylish2.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/holofoils/trainerbg.jpg
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
public/holofoils/trainerbg.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/holofoils/vmaxbg.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
public/holofoils/wave.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
87
scripts/csvprices.ts
Normal 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();
|
||||
30
scripts/list-missing-images.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as schema from '../src/db/schema.ts';
|
||||
import { db, ClosePool } from '../src/db/index.ts';
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
async function findMissingImages() {
|
||||
const cards = await db
|
||||
.select()
|
||||
.from(schema.tcgcards)
|
||||
.where(sql`${schema.tcgcards.sealed} = false`);
|
||||
const missingImages: string[] = [];
|
||||
for (const card of cards) {
|
||||
const imagePath = path.join(process.cwd(), 'static', 'cards', `${card.productId}.jpg`);
|
||||
try {
|
||||
await fs.access(imagePath);
|
||||
} catch (err) {
|
||||
missingImages.push(`${card.productId}\t${card.setId}\t${card.productName}\t${card.number}`);
|
||||
}
|
||||
}
|
||||
return missingImages;
|
||||
}
|
||||
|
||||
const missingImages = await findMissingImages();
|
||||
//console.log("Missing Images:", missingImages.join('\n'));
|
||||
|
||||
fs.writeFile(path.join(process.cwd(), 'missing-images.log'), missingImages.join('\n'));
|
||||
|
||||
await ClosePool();
|
||||
187
scripts/pokemon-helper.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import chalk from 'chalk';
|
||||
import { client } from '../src/db/typesense.ts';
|
||||
import type { DBInstance } from '../src/db/index.ts';
|
||||
import fs from "node:fs/promises";
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
|
||||
const DollarToInt = (dollar: any) => {
|
||||
if (dollar === null) return null;
|
||||
return Math.round(dollar * 100);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const Sleep = (ms: number) => {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
|
||||
export const FileExists = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
await fs.access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const GetNumberOrNull = (value: any): number | null => {
|
||||
const number = Number(value); // Attempt to convert the value to a number
|
||||
if (Number.isNaN(number)) {
|
||||
return null; // Return null if the result is NaN
|
||||
}
|
||||
return number; // Otherwise, return the number
|
||||
}
|
||||
|
||||
|
||||
// Delete and recreate the 'cards' index
|
||||
export const createCardCollection = async () => {
|
||||
try {
|
||||
await client.collections('cards').delete();
|
||||
} catch (error) {
|
||||
// Ignore error, just means collection doesn't exist
|
||||
}
|
||||
await client.collections().create({
|
||||
name: 'cards',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string' },
|
||||
{ name: 'cardId', type: 'int32' },
|
||||
{ name: 'productId', type: 'int32' },
|
||||
{ name: 'variant', type: 'string', facet: true },
|
||||
{ name: 'productName', type: 'string' },
|
||||
{ name: 'productLineName', type: 'string', facet: true },
|
||||
{ name: 'rarityName', type: 'string', facet: true },
|
||||
{ name: 'setName', type: 'string', facet: true },
|
||||
{ name: 'setCode', type: 'string' },
|
||||
{ name: 'cardType', type: 'string', facet: true },
|
||||
{ name: 'energyType', type: 'string', facet: true },
|
||||
{ name: 'number', type: 'string', sort: true },
|
||||
{ name: 'Artist', type: 'string' },
|
||||
{ name: 'sealed', type: 'bool' },
|
||||
{ name: 'releaseDate', type: 'int32' },
|
||||
{ name: 'marketPrice', type: 'int32', optional: true, sort: true },
|
||||
{ name: 'content', type: 'string', token_separators: ['/'] },
|
||||
{ name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
|
||||
],
|
||||
});
|
||||
console.log(chalk.green('Collection "cards" created successfully.'));
|
||||
}
|
||||
|
||||
// Delete and recreate the 'skus' index
|
||||
export const createSkuCollection = async () => {
|
||||
try {
|
||||
await client.collections('skus').delete();
|
||||
} catch (error) {
|
||||
// Ignore error, just means collection doesn't exist
|
||||
}
|
||||
await client.collections().create({
|
||||
name: 'skus',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string' },
|
||||
{ name: 'condition', type: 'string' },
|
||||
{ name: 'highestPrice', type: 'int32', optional: true },
|
||||
{ name: 'lowestPrice', type: 'int32', optional: true },
|
||||
{ name: 'marketPrice', type: 'int32', optional: true },
|
||||
]
|
||||
});
|
||||
console.log(chalk.green('Collection "skus" created successfully.'));
|
||||
}
|
||||
|
||||
|
||||
export const upsertCardCollection = async (db:DBInstance) => {
|
||||
const pokemon = await db.query.cards.findMany({
|
||||
with: { set: true, tcgdata: true, prices: true },
|
||||
});
|
||||
await client.collections('cards').documents().import(pokemon.map(card => {
|
||||
// Use the NM SKU price matching the card's variant (kept fresh by syncPrices)
|
||||
// Fall back to any NM sku, then to tcgdata price
|
||||
const nmSku = card.prices.find(p => p.condition === 'Near Mint' && p.variant === card.variant)
|
||||
?? card.prices.find(p => p.condition === 'Near Mint');
|
||||
const marketPrice = nmSku?.marketPrice
|
||||
? DollarToInt(nmSku.marketPrice)
|
||||
: card.tcgdata?.marketPrice
|
||||
? DollarToInt(card.tcgdata.marketPrice)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: card.cardId.toString(),
|
||||
cardId: card.cardId,
|
||||
productId: card.productId,
|
||||
variant: card.variant,
|
||||
productName: card.productName,
|
||||
productLineName: card.productLineName,
|
||||
rarityName: card.rarityName,
|
||||
setName: card.set?.setName || "",
|
||||
setCode: card.set?.setCode || "",
|
||||
cardType: card.cardType || "",
|
||||
energyType: card.energyType || "",
|
||||
number: card.number,
|
||||
Artist: card.artist || "",
|
||||
sealed: card.sealed,
|
||||
content: [card.productName, card.productLineName, card.set?.setName || "", card.set?.setCode || "", card.number, card.rarityName, card.artist || ""].join(' '),
|
||||
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
|
||||
...(marketPrice !== null && { marketPrice }),
|
||||
sku_id: card.prices.map(price => price.skuId.toString())
|
||||
};
|
||||
}), { action: 'upsert' });
|
||||
console.log(chalk.green('Collection "cards" indexed successfully.'));
|
||||
}
|
||||
|
||||
export const upsertSkuCollection = async (db:DBInstance) => {
|
||||
const skus = await db.query.skus.findMany();
|
||||
await client.collections('skus').documents().import(skus.map(sku => ({
|
||||
id: sku.skuId.toString(),
|
||||
condition: sku.condition,
|
||||
highestPrice: DollarToInt(sku.highestPrice),
|
||||
lowestPrice: DollarToInt(sku.lowestPrice),
|
||||
marketPrice: DollarToInt(sku.marketPrice),
|
||||
})), { action: 'upsert' });
|
||||
console.log(chalk.green('Collection "skus" indexed successfully.'));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const UpdateVariants = async (db:DBInstance) => {
|
||||
const updates = await db.execute(sql`update cards as c
|
||||
set
|
||||
product_name = a.product_name, product_line_name = a.product_line_name, product_url_name = a.product_url_name, rarity_name = a.rarity_name,
|
||||
sealed = a.sealed, set_id = a.set_id, card_type = a.card_type, energy_type = a.energy_type, number = a.number, artist = a.artist
|
||||
from (
|
||||
select t.product_id, b.variant,
|
||||
coalesce(o.product_name, regexp_replace(regexp_replace(coalesce(nullif(t.product_name, ''), t.product_url_name),' \\\\(.*\\\\)',''),' - .*$','')) as product_name,
|
||||
coalesce(o.product_line_name, t.product_line_name) as product_line_name, coalesce(o.product_url_name, t.product_url_name) as product_url_name,
|
||||
coalesce(o.rarity_name, t.rarity_name) as rarity_name, coalesce(o.sealed, t.sealed) as sealed, coalesce(o.set_id, t.set_id) as set_id,
|
||||
coalesce(o.card_type, t.card_type) as card_type, coalesce(o.energy_type, t.energy_type) as energy_type,
|
||||
coalesce(o.number, t.number) as number, coalesce(o.artist, t.artist) as artist
|
||||
from tcg_cards t
|
||||
join (select distinct product_id, variant from skus) b on t.product_id = b.product_id
|
||||
left join tcg_overrides o on t.product_id = o.product_id
|
||||
) a
|
||||
where c.product_id = a.product_id and c.variant = a.variant and
|
||||
(
|
||||
c.product_name is distinct from a.product_name or c.product_line_name is distinct from a.product_line_name or
|
||||
c.product_url_name is distinct from a.product_url_name or c.rarity_name is distinct from a.rarity_name or
|
||||
c.sealed is distinct from a.sealed or c.set_id is distinct from a.set_id or c.card_type is distinct from a.card_type or
|
||||
c.energy_type is distinct from a.energy_type or c."number" is distinct from a."number" or c.artist is distinct from a.artist
|
||||
)
|
||||
`);
|
||||
console.log(`Updated ${updates.rowCount} rows in cards table`);
|
||||
|
||||
const inserts = await db.execute(sql`insert into cards (product_id, variant, product_name, product_line_name, product_url_name, rarity_name, sealed, set_id, card_type, energy_type, "number", artist)
|
||||
select t.product_id, b.variant,
|
||||
coalesce(o.product_name, regexp_replace(regexp_replace(coalesce(nullif(t.product_name, ''), t.product_url_name),' \\\\(.*\\\\)',''),' - .*$','')) as product_name,
|
||||
coalesce(o.product_line_name, t.product_line_name) as product_line_name, coalesce(o.product_url_name, t.product_url_name) as product_url_name, coalesce(o.rarity_name, t.rarity_name) as rarity_name,
|
||||
coalesce(o.sealed, t.sealed) as sealed, coalesce(o.set_id, t.set_id) as set_id, coalesce(o.card_type, t.card_type) as card_type,
|
||||
coalesce(o.energy_type, t.energy_type) as energy_type, coalesce(o.number, t.number) as number, coalesce(o.artist, t.artist) as artist
|
||||
from tcg_cards t
|
||||
join (select distinct product_id, variant from skus) b on t.product_id = b.product_id
|
||||
left join tcg_overrides o on t.product_id = o.product_id
|
||||
where not exists (select 1 from cards where product_id=t.product_id and variant=b.variant)
|
||||
`);
|
||||
console.log(`Inserted ${inserts.rowCount} rows into cards table`);
|
||||
|
||||
}
|
||||
147
scripts/preload-pricehistory.ts
Normal 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 {};
|
||||
@@ -1,14 +1,15 @@
|
||||
import 'dotenv/config';
|
||||
import * as schema from '../src/db/schema.ts';
|
||||
import { db, poolConnection } from '../src/db/index.ts';
|
||||
import { db, ClosePool } from '../src/db/index.ts';
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import chalk from 'chalk';
|
||||
import * as helper from './pokemon-helper.ts';
|
||||
//import util from 'util';
|
||||
|
||||
|
||||
async function syncTcgplayer() {
|
||||
async function syncTcgplayer(cardSets:string[] = []) {
|
||||
|
||||
const productLines = [ "pokemon", "pokemon-japan" ];
|
||||
|
||||
@@ -29,44 +30,21 @@ async function syncTcgplayer() {
|
||||
|
||||
const setNames = data.results[0].aggregations.setName;
|
||||
for (const setName of setNames) {
|
||||
console.log(chalk.blue(`Syncing product line "${productLine}" with setName "${setName.urlValue}"...`));
|
||||
await syncProductLine(productLine, "setName", setName.urlValue);
|
||||
let processSet = true;
|
||||
if (cardSets.length > 0) {
|
||||
processSet = cardSets.some(set => setName.value.toLowerCase().includes(set.toLowerCase()));
|
||||
}
|
||||
if (processSet) {
|
||||
console.log(chalk.blue(`Syncing product line "${productLine}" with setName "${setName.urlValue}"...`));
|
||||
await syncProductLine(productLine, "setName", setName.urlValue);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
console.log(chalk.green('✓ All TCGPlayer data synchronized successfully!'));
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function cleanProductName(name: string): string {
|
||||
// remove TCGPlayer crap
|
||||
name = name.replace(/ - .*$/, '');
|
||||
name = name.replace(/ \[.*\]/, '');
|
||||
name = name.replace(/ \(.*\)/, '');
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getNumberOrNull(value: any): number | null {
|
||||
const number = Number(value); // Attempt to convert the value to a number
|
||||
if (Number.isNaN(number)) {
|
||||
return null; // Return null if the result is NaN
|
||||
}
|
||||
return number; // Otherwise, return the number
|
||||
}
|
||||
|
||||
async function syncProductLine(productLine: string, field: string, fieldValue: string) {
|
||||
let start = 0;
|
||||
@@ -130,10 +108,10 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
|
||||
for (const item of data.results[0].results) {
|
||||
|
||||
// // Check if productId already exists and skip if it does (to avoid hitting the API too much)
|
||||
// if (allProductIds.has(item.productId)) {
|
||||
// continue;
|
||||
// }
|
||||
// Check if productId already exists and skip if it does (to avoid hitting the API too much)
|
||||
if (allProductIds.size > 0 && allProductIds.has(item.productId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(chalk.blue(` - ${item.productName} (ID: ${item.productId})`));
|
||||
|
||||
@@ -171,7 +149,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
cardTypeB: item.customAttributes.cardTypeB || null,
|
||||
energyType: detailData.customAttributes.energyType?.[0] || null,
|
||||
flavorText: detailData.customAttributes.flavorText || null,
|
||||
hp: getNumberOrNull(item.customAttributes.hp),
|
||||
hp: helper.GetNumberOrNull(item.customAttributes.hp),
|
||||
number: detailData.customAttributes.number || '',
|
||||
releaseDate: detailData.customAttributes.releaseDate ? new Date(detailData.customAttributes.releaseDate) : null,
|
||||
resistance: item.customAttributes.resistance || null,
|
||||
@@ -184,8 +162,9 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
maxFulfillableQuantity: detailData.maxFulfillableQuantity,
|
||||
medianPrice: detailData.medianPrice,
|
||||
totalListings: item.totalListings,
|
||||
Artist: detailData.formattedAttributes.Artist || null,
|
||||
}).onDuplicateKeyUpdate({
|
||||
artist: detailData.formattedAttributes.Artist || null,
|
||||
}).onConflictDoUpdate({
|
||||
target: schema.tcgcards.productId,
|
||||
set: {
|
||||
productName: detailData.productName,
|
||||
//productName: cleanProductName(item.productName),
|
||||
@@ -208,7 +187,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
cardTypeB: item.customAttributes.cardTypeB || null,
|
||||
energyType: detailData.customAttributes.energyType?.[0] || null,
|
||||
flavorText: detailData.customAttributes.flavorText || null,
|
||||
hp: getNumberOrNull(item.customAttributes.hp),
|
||||
hp: helper.GetNumberOrNull(item.customAttributes.hp),
|
||||
number: detailData.customAttributes.number || '',
|
||||
releaseDate: detailData.customAttributes.releaseDate ? new Date(detailData.customAttributes.releaseDate) : null,
|
||||
resistance: item.customAttributes.resistance || null,
|
||||
@@ -221,18 +200,21 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
maxFulfillableQuantity: detailData.maxFulfillableQuantity,
|
||||
medianPrice: detailData.medianPrice,
|
||||
totalListings: item.totalListings,
|
||||
Artist: detailData.formattedAttributes.Artist || null,
|
||||
artist: detailData.formattedAttributes.Artist || null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
console.log(`item: ${item.setId}\tdetail: ${detailData.setId}`);
|
||||
console.log(`item: ${item.setCode}\tdetail: ${detailData.setCode}`);
|
||||
console.log(`item: ${item.setName}\tdetail: ${detailData.setName}`);
|
||||
// set is...
|
||||
await db.insert(schema.sets).values({
|
||||
setId: detailData.setId,
|
||||
setCode: detailData.setCode,
|
||||
setName: detailData.setName,
|
||||
setUrlName: detailData.setUrlName,
|
||||
}).onDuplicateKeyUpdate({
|
||||
}).onConflictDoUpdate({
|
||||
target: schema.sets.setId,
|
||||
set: {
|
||||
setCode: detailData.setCode,
|
||||
setName: detailData.setName,
|
||||
@@ -249,7 +231,8 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
condition: skuItem.condition,
|
||||
language: skuItem.language,
|
||||
variant: skuItem.variant,
|
||||
}).onDuplicateKeyUpdate({
|
||||
}).onConflictDoUpdate({
|
||||
target: schema.skus.skuId,
|
||||
set: {
|
||||
condition: skuItem.condition,
|
||||
language: skuItem.language,
|
||||
@@ -259,8 +242,8 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
}
|
||||
|
||||
// get image if it doesn't already exist
|
||||
const imagePath = path.join(process.cwd(), 'public', 'cards', `${item.productId}.jpg`);
|
||||
if (!await fileExists(imagePath)) {
|
||||
const imagePath = path.join(process.cwd(), 'static', 'cards', `${item.productId}.jpg`);
|
||||
if (!await helper.FileExists(imagePath)) {
|
||||
const imageResponse = await fetch(`https://tcgplayer-cdn.tcgplayer.com/product/${item.productId}_in_1000x1000.jpg`);
|
||||
if (imageResponse.ok) {
|
||||
const buffer = await imageResponse.arrayBuffer();
|
||||
@@ -272,7 +255,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
}
|
||||
|
||||
// be nice to the API and not send too many requests in a short time
|
||||
await sleep(300);
|
||||
await helper.Sleep(300);
|
||||
|
||||
}
|
||||
|
||||
@@ -282,8 +265,21 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
|
||||
// clear the log file
|
||||
await fs.rm('missing_images.log', { force: true });
|
||||
let allProductIds = new Set();
|
||||
|
||||
const allProductIds = new Set(await db.select({ productId: schema.cards.productId }).from(schema.cards).then(rows => rows.map(row => row.productId)));
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0) {
|
||||
allProductIds = new Set(await db.select({ productId: schema.cards.productId }).from(schema.cards).then(rows => rows.map(row => row.productId)));
|
||||
await syncTcgplayer();
|
||||
}
|
||||
else {
|
||||
await syncTcgplayer(args);
|
||||
}
|
||||
|
||||
await syncTcgplayer();
|
||||
await poolConnection.end();
|
||||
// update the card table with new/updated variants
|
||||
await helper.UpdateVariants(db);
|
||||
|
||||
// index the card updates
|
||||
await helper.upsertCardCollection(db);
|
||||
|
||||
await ClosePool();
|
||||
|
||||
@@ -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 './pokemon-helper.ts';
|
||||
|
||||
|
||||
async function preloadSearchIndex() {
|
||||
const pokemon = await db.query.cards.findMany({
|
||||
with: { set: true, tcgdata: true, prices: true },
|
||||
});
|
||||
|
||||
// Ensure the collection exists before importing documents
|
||||
await createCollection(client);
|
||||
|
||||
await client.collections('cards').documents().import(pokemon.map(card => ({
|
||||
id: card.cardId.toString(),
|
||||
cardId: card.cardId,
|
||||
productId: card.productId,
|
||||
variant: card.variant,
|
||||
productName: card.productName,
|
||||
productLineName: card.productLineName,
|
||||
rarityName: card.rarityName,
|
||||
setName: card.set?.setName || "",
|
||||
cardType: card.cardType || "",
|
||||
energyType: card.energyType || "",
|
||||
number: card.number,
|
||||
Artist: card.Artist || "",
|
||||
sealed: card.sealed,
|
||||
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.'));
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { db, poolConnection } from '../src/db/index.ts';
|
||||
import { db, ClosePool } from '../src/db/index.ts';
|
||||
import { sql, inArray, eq } from 'drizzle-orm';
|
||||
import { skus, processingSkus } from '../src/db/schema.ts';
|
||||
import { client } from '../src/db/typesense.ts';
|
||||
import { skus, processingSkus, priceHistory, salesHistory } from '../src/db/schema.ts';
|
||||
import { toSnakeCase } from 'drizzle-orm/casing';
|
||||
import * as helper from './pokemon-helper.ts';
|
||||
|
||||
|
||||
const DollarToInt = (dollar: any) => {
|
||||
if (dollar === null) return null;
|
||||
return Math.round(dollar * 100);
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function resetProcessingTable() {
|
||||
// Use sql.raw to execute the TRUNCATE TABLE statement
|
||||
await db.execute(sql.raw('TRUNCATE TABLE processingSkus;'));
|
||||
await db.execute(sql.raw('TRUNCATE TABLE pokemon.processing_skus;'));
|
||||
await db.insert(processingSkus).select(db.select({skuId: skus.skuId}).from(skus));
|
||||
}
|
||||
|
||||
async function syncPrices() {
|
||||
const batchSize = 1000;
|
||||
// const skuIndex = client.collections('skus');
|
||||
const updatedCards = new Set<number>();
|
||||
|
||||
await resetProcessingTable();
|
||||
console.log(chalk.green('Processing table reset and populated with current SKUs.'));
|
||||
@@ -59,6 +52,15 @@ async function syncPrices() {
|
||||
console.error(chalk.yellow(`Expected ${batchSize} SKUs, got ${skuData.length}`));
|
||||
}
|
||||
|
||||
if (skuData.length === 0) {
|
||||
console.error(chalk.red('0 SKUs, skipping DB updates.'));
|
||||
// remove skus from the 'working' processingSkus table
|
||||
await db.delete(processingSkus).where(inArray(processingSkus.skuId, skuIds));
|
||||
// be nice to the API and not send too many requests in a short time
|
||||
await helper.Sleep(200);
|
||||
continue;
|
||||
}
|
||||
|
||||
const skuUpdates = skuData.map((sku: any) => { return {
|
||||
skuId: sku.skuId,
|
||||
cardId: 0,
|
||||
@@ -72,41 +74,91 @@ async function syncPrices() {
|
||||
marketPrice: sku.marketPrice,
|
||||
priceCount: null,
|
||||
}});
|
||||
await db.insert(skus).values(skuUpdates).onDuplicateKeyUpdate({
|
||||
const skuRows = await db.insert(skus).values(skuUpdates).onConflictDoUpdate({
|
||||
target: skus.skuId,
|
||||
set: {
|
||||
calculatedAt: sql`values(${skus.calculatedAt})`,
|
||||
highestPrice: sql`values(${skus.highestPrice})`,
|
||||
lowestPrice: sql`values(${skus.lowestPrice})`,
|
||||
marketPrice: sql`values(${skus.marketPrice})`,
|
||||
calculatedAt: sql.raw(`excluded.${toSnakeCase(skus.calculatedAt.name)}`),
|
||||
highestPrice: sql.raw(`excluded.${toSnakeCase(skus.highestPrice.name)}`),
|
||||
lowestPrice: sql.raw(`excluded.${toSnakeCase(skus.lowestPrice.name)}`),
|
||||
marketPrice: sql.raw(`excluded.${toSnakeCase(skus.marketPrice.name)}`),
|
||||
},
|
||||
setWhere: sql`skus.market_price is distinct from excluded.market_price`,
|
||||
}).returning();
|
||||
|
||||
if (skuRows && skuRows.length > 0) {
|
||||
const skuHistory = skuRows.filter(row => row.calculatedAt != null).map(row => { return {
|
||||
skuId: row.skuId,
|
||||
calculatedAt: new Date(row.calculatedAt?.toISOString().slice(0, 10)||0),
|
||||
marketPrice: row.marketPrice,
|
||||
}});
|
||||
if (skuHistory && skuHistory.length > 0) {
|
||||
await db.insert(priceHistory).values(skuHistory).onConflictDoUpdate({
|
||||
target: [priceHistory.skuId,priceHistory.calculatedAt],
|
||||
set: {
|
||||
marketPrice: sql.raw(`excluded.${toSnakeCase(skus.marketPrice.name)}`),
|
||||
}
|
||||
});
|
||||
console.log(chalk.cyan(`${skuRows.length} history rows added.`));
|
||||
}
|
||||
});
|
||||
for (const productId of skuRows.filter(row => row.calculatedAt != null).map(row => row.productId)) {
|
||||
updatedCards.add(productId);
|
||||
}
|
||||
}
|
||||
|
||||
// remove skus from the 'working' processingSkus table
|
||||
await db.delete(processingSkus).where(inArray(processingSkus.skuId, skuIds));
|
||||
|
||||
// be nice to the API and not send too many requests in a short time
|
||||
await sleep(100);
|
||||
await helper.Sleep(200);
|
||||
}
|
||||
|
||||
return updatedCards;
|
||||
}
|
||||
|
||||
async function indexPrices() {
|
||||
const skus = await db.query.skus.findMany();
|
||||
|
||||
await client.collections('skus').documents().import(skus.map(sku => ({
|
||||
id: sku.skuId.toString(),
|
||||
condition: sku.condition,
|
||||
highestPrice: DollarToInt(sku.highestPrice),
|
||||
lowestPrice: DollarToInt(sku.lowestPrice),
|
||||
marketPrice: DollarToInt(sku.marketPrice),
|
||||
})), { action: 'upsert' });
|
||||
|
||||
const updateLatestSales = async (updatedCards: Set<number>) => {
|
||||
for (const productId of updatedCards.values()) {
|
||||
console.log(`Getting sale history for ${productId}`)
|
||||
const salesResponse = await fetch(`https://mpapi.tcgplayer.com/v2/product/${productId}/latestsales`,{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36'
|
||||
},
|
||||
body: JSON.stringify({ conditions:[], languages:[1], limit:25, listType:"All", variants:[] }),
|
||||
});
|
||||
if (!salesResponse.ok) {
|
||||
console.error('Error fetching sale history:', salesResponse.statusText);
|
||||
process.exit(1);
|
||||
}
|
||||
const salesData = await salesResponse.json();
|
||||
for (const sale of salesData.data) {
|
||||
const skuData = await db.query.skus.findFirst({ where: { productId: productId, variant: sale.variant, condition: sale.condition } });
|
||||
if (skuData) {
|
||||
await db.insert(salesHistory).values({
|
||||
skuId: skuData.skuId,
|
||||
orderDate: new Date(sale.orderDate),
|
||||
title: sale.title,
|
||||
customListingId: sale.customListingId,
|
||||
language: sale.language,
|
||||
listingType: sale.listingType,
|
||||
purchasePrice: sale.purchasePrice,
|
||||
quantity: sale.quantity,
|
||||
shippingPrice: sale.shippingPrice
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
await helper.Sleep(500);
|
||||
}
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
await syncPrices();
|
||||
await indexPrices();
|
||||
await poolConnection.end();
|
||||
const updatedCards = await syncPrices();
|
||||
await helper.upsertSkuCollection(db);
|
||||
await helper.upsertCardCollection(db);
|
||||
//console.log(updatedCards);
|
||||
//console.log(updatedCards.size);
|
||||
//await updateLatestSales(updatedCards);
|
||||
await ClosePool();
|
||||
const end = Date.now();
|
||||
const duration = (end - start) / 1000;
|
||||
console.log(chalk.green(`Price sync completed in ${duration.toFixed(2)} seconds.`));
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import 'dotenv/config';
|
||||
import { db, poolConnection } from '../src/db/index.ts';
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
async function syncVariants() {
|
||||
const updates = await db.execute(sql`update cards as c
|
||||
join tcgcards t on c.productId = t.productId
|
||||
join (select distinct productId, variant from skus) b on c.productId = b.productId and c.variant = b.variant
|
||||
left join tcg_overrides o on c.productId = o.productId
|
||||
set c.productName = coalesce(o.productName, regexp_replace(regexp_replace(coalesce(nullif(t.productName, ''), t.productUrlName),' \\\\(.*\\\\)',''),' - .*$','')),
|
||||
c.productLineName = coalesce(o.productLineName, t.productLineName), c.productUrlName = coalesce(o.productUrlName, t.productUrlName), c.rarityName = coalesce(o.rarityName, t.rarityName),
|
||||
c.sealed = coalesce(o.sealed, t.sealed), c.setId = coalesce(o.setId, t.setId), c.cardType = coalesce(o.cardType, t.cardType),
|
||||
c.energyType = coalesce(o.energyType, t.energyType), c.number = coalesce(o.number, t.number), c.Artist = coalesce(o.Artist, t.Artist)`);
|
||||
console.log(`Updated ${updates[0].affectedRows} rows in cards table`);
|
||||
|
||||
const inserts = await db.execute(sql`insert into cards (productId, variant, productName, productLineName, productUrlName, rarityName, sealed, setId, cardType, energyType, number, Artist)
|
||||
select t.productId, b.variant,
|
||||
coalesce(o.productName, regexp_replace(regexp_replace(coalesce(nullif(t.productName, ''), t.productUrlName),' \\\\(.*\\\\)',''),' - .*$','')) as productName,
|
||||
coalesce(o.productLineName, t.productLineName) as productLineName, coalesce(o.productUrlName, t.productUrlName) as productUrlName, coalesce(o.rarityName, t.rarityName) as rarityName,
|
||||
coalesce(o.sealed, t.sealed) as sealed, coalesce(o.setId, t.setId) as setId, coalesce(o.cardType, t.cardType) as cardType,
|
||||
coalesce(o.energyType, t.energyType) as energyType, coalesce(o.number, t.number) as number, coalesce(o.Artist, t.Artist) as Artist
|
||||
from tcgcards t
|
||||
join (select distinct productId, variant from skus) b on t.productId = b.productId
|
||||
left join tcg_overrides o on t.productId = o.productId
|
||||
where not exists (select 1 from cards where productId=t.productId and variant=b.variant)
|
||||
`);
|
||||
console.log(`Inserted ${inserts[0].affectedRows} rows into cards table`);
|
||||
|
||||
}
|
||||
|
||||
await syncVariants();
|
||||
await poolConnection.end();
|
||||
@@ -22,7 +22,7 @@
|
||||
@import 'bootstrap/scss/alert';
|
||||
@import 'bootstrap/scss/badge';
|
||||
// @import 'bootstrap/scss/breadcrumb';
|
||||
// @import 'bootstrap/scss/button-group';
|
||||
@import 'bootstrap/scss/button-group';
|
||||
@import 'bootstrap/scss/buttons';
|
||||
@import 'bootstrap/scss/card';
|
||||
// @import 'bootstrap/scss/carousel';
|
||||
@@ -41,7 +41,7 @@
|
||||
// @import 'bootstrap/scss/spinners';
|
||||
@import 'bootstrap/scss/tables';
|
||||
@import 'bootstrap/scss/toasts';
|
||||
// @import 'bootstrap/scss/tooltip';
|
||||
@import 'bootstrap/scss/tooltip';
|
||||
@import 'bootstrap/scss/transitions';
|
||||
|
||||
// Optional helpers
|
||||
|
||||
2115
src/assets/css/_card.scss
Normal file
349
src/assets/css/_holofoil-integration.scss
Normal file
@@ -0,0 +1,349 @@
|
||||
// =============================================================================
|
||||
// HOLOFOIL INTEGRATION
|
||||
// _holofoil-integration.scss
|
||||
// =============================================================================
|
||||
|
||||
@import "card";
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 1. WRAPPER NORMALISATION
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
%holofoil-wrapper-base {
|
||||
--card-aspect: 0.718;
|
||||
--card-radius: 4.55% / 3.5%;
|
||||
|
||||
--pointer-x: 50%;
|
||||
--pointer-y: 50%;
|
||||
--background-x: 50%;
|
||||
--background-y: 50%;
|
||||
--pointer-from-center: 0;
|
||||
--pointer-from-top: 0.5;
|
||||
--pointer-from-left: 0.5;
|
||||
--card-scale: 1;
|
||||
--card-opacity: 0;
|
||||
|
||||
--grain: url('/public/holofoils/grain.webp');
|
||||
--glitter: url('/public/holofoils/glitter.png');
|
||||
--glittersize: 25%;
|
||||
--space: 5%;
|
||||
--angle: 133deg;
|
||||
--imgsize: cover;
|
||||
|
||||
--red: #f80e35;
|
||||
--yellow: #eedf10;
|
||||
--green: #21e985;
|
||||
--blue: #0dbde9;
|
||||
--violet: #c929f1;
|
||||
|
||||
--clip: inset(9.85% 8% 52.85% 8%);
|
||||
--clip-invert: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 47.15%, 91.5% 47.15%, 91.5% 9.85%, 8% 9.85%, 8% 47.15%, 0 50%);
|
||||
--clip-stage: polygon(91.5% 9.85%, 57% 9.85%, 54% 12%, 17% 12%, 16% 14%, 12% 16%, 8% 16%, 8% 47.15%, 92% 47.15%);
|
||||
--clip-stage-invert: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 47.15%, 91.5% 47.15%, 91.5% 9.85%, 57% 9.85%, 54% 12%, 17% 12%, 16% 14%, 12% 16%, 8% 16%, 8% 47.15%, 0 50%);
|
||||
--clip-trainer: inset(14.5% 8.5% 48.2% 8.5%);
|
||||
--clip-borders: inset(2.8% 4% round 2.55% / 1.5%);
|
||||
|
||||
--sunpillar-clr-1: var(--sunpillar-1);
|
||||
--sunpillar-clr-2: var(--sunpillar-2);
|
||||
--sunpillar-clr-3: var(--sunpillar-3);
|
||||
--sunpillar-clr-4: var(--sunpillar-4);
|
||||
--sunpillar-clr-5: var(--sunpillar-5);
|
||||
--sunpillar-clr-6: var(--sunpillar-6);
|
||||
|
||||
// NOTE: no overflow:hidden here -- that would clip the lift/scale transform
|
||||
// on .image-grow. Overflow is handled by the child .holo-shine/.holo-glare.
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
border-radius: var(--card-radius);
|
||||
}
|
||||
|
||||
%holofoil-energy-glows {
|
||||
&[data-energy="Water"] { --card-glow: hsl(192, 97%, 60%); }
|
||||
&[data-energy="Fire"] { --card-glow: hsl(9, 81%, 59%); }
|
||||
&[data-energy="Grass"] { --card-glow: hsl(96, 81%, 65%); }
|
||||
&[data-energy="Lightning"] { --card-glow: hsl(54, 87%, 63%); }
|
||||
&[data-energy="Psychic"] { --card-glow: hsl(281, 62%, 58%); }
|
||||
&[data-energy="Fighting"] { --card-glow: rgb(145, 90, 39); }
|
||||
&[data-energy="Darkness"] { --card-glow: hsl(189, 77%, 27%); }
|
||||
&[data-energy="Metal"] { --card-glow: hsl(184, 20%, 70%); }
|
||||
&[data-energy="Dragon"] { --card-glow: hsl(51, 60%, 35%); }
|
||||
&[data-energy="Fairy"] { --card-glow: hsl(323, 100%, 89%); }
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 2. SHINE + GLARE CHILD DIVS
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
%shine-base {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--card-radius);
|
||||
overflow: hidden; // clipping lives here, not on the parent
|
||||
z-index: 3;
|
||||
will-change: transform, opacity, background-image, background-size,
|
||||
background-position, background-blend-mode, filter;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--card-radius);
|
||||
}
|
||||
}
|
||||
|
||||
%glare-base {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--card-radius);
|
||||
z-index: 4;
|
||||
transform: translateZ(0);
|
||||
overflow: hidden;
|
||||
will-change: transform, opacity, background-image, background-size,
|
||||
background-position, background-blend-mode, filter;
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 3. MODES
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// -- 3a. GRID -----------------------------------------------------------------
|
||||
// No idle animation. Effect is invisible until hover.
|
||||
|
||||
.image-grow,
|
||||
.card-image-wrap {
|
||||
@extend %holofoil-wrapper-base;
|
||||
@extend %holofoil-energy-glows;
|
||||
|
||||
// No effect if the image fell back to default.jpg
|
||||
&[data-default="true"] {
|
||||
.holo-shine,
|
||||
.holo-glare { display: none !important; }
|
||||
}
|
||||
|
||||
.holo-shine { @extend %shine-base; }
|
||||
.holo-glare { @extend %glare-base; }
|
||||
}
|
||||
|
||||
|
||||
// -- 3b. GRID HOVER -----------------------------------------------------------
|
||||
// The existing main.scss .image-grow:hover handles lift + scale.
|
||||
// We layer the holo effect on top without overriding transform or transition.
|
||||
|
||||
.image-grow:hover,
|
||||
.image-grow[data-holo-active] {
|
||||
--card-opacity: 0.45;
|
||||
}
|
||||
|
||||
|
||||
// -- 3c. MODAL ----------------------------------------------------------------
|
||||
// Sweeps once per minute. Peaks at 0.35.
|
||||
// Pointer tracking bumps opacity to 0.45 while hovering.
|
||||
|
||||
@keyframes holo-modal-pulse {
|
||||
0% {
|
||||
--card-opacity: 0;
|
||||
--pointer-x: 50%; --pointer-y: 50%;
|
||||
--background-x: 50%; --background-y: 50%;
|
||||
--pointer-from-center: 0; --pointer-from-left: 0.5; --pointer-from-top: 0.5;
|
||||
}
|
||||
4% { --card-opacity: 0; }
|
||||
8% {
|
||||
--card-opacity: 0.35;
|
||||
--pointer-x: 25%; --pointer-y: 15%;
|
||||
--background-x: 38%; --background-y: 28%;
|
||||
--pointer-from-center: 0.85; --pointer-from-left: 0.25; --pointer-from-top: 0.15;
|
||||
}
|
||||
25% {
|
||||
--pointer-x: 70%; --pointer-y: 30%;
|
||||
--background-x: 64%; --background-y: 34%;
|
||||
--pointer-from-center: 0.9; --pointer-from-left: 0.70; --pointer-from-top: 0.30;
|
||||
}
|
||||
45% {
|
||||
--pointer-x: 80%; --pointer-y: 70%;
|
||||
--background-x: 74%; --background-y: 68%;
|
||||
--pointer-from-center: 0.88; --pointer-from-left: 0.80; --pointer-from-top: 0.70;
|
||||
}
|
||||
65% {
|
||||
--pointer-x: 35%; --pointer-y: 80%;
|
||||
--background-x: 38%; --background-y: 76%;
|
||||
--pointer-from-center: 0.8; --pointer-from-left: 0.35; --pointer-from-top: 0.80;
|
||||
}
|
||||
85% {
|
||||
--card-opacity: 0.35;
|
||||
--pointer-x: 25%; --pointer-y: 15%;
|
||||
--background-x: 38%; --background-y: 28%;
|
||||
--pointer-from-center: 0.85;
|
||||
}
|
||||
90% { --card-opacity: 0; }
|
||||
100% {
|
||||
--card-opacity: 0;
|
||||
--pointer-x: 50%; --pointer-y: 50%;
|
||||
--background-x: 50%; --background-y: 50%;
|
||||
--pointer-from-center: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image-wrap.holo-modal-mode {
|
||||
--card-opacity: 0;
|
||||
|
||||
.holo-shine,
|
||||
.holo-glare {
|
||||
animation: holo-modal-pulse 60s ease-in-out infinite;
|
||||
animation-delay: var(--shimmer-delay, -2s);
|
||||
}
|
||||
|
||||
&[data-holo-active] {
|
||||
--card-opacity: 0.45;
|
||||
.holo-shine,
|
||||
.holo-glare { animation-play-state: paused; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 4. RARITY -> CLIP-PATH BRIDGE
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
.image-grow,
|
||||
.card-image-wrap {
|
||||
|
||||
// No effect on common/uncommon or unrecognised wrapper
|
||||
&[data-rarity="common"],
|
||||
&[data-rarity="uncommon"],
|
||||
&:not([data-rarity]) {
|
||||
.holo-shine,
|
||||
.holo-glare { display: none; }
|
||||
}
|
||||
|
||||
// Standard holo — artwork area only
|
||||
&[data-rarity="rare holo"] {
|
||||
.holo-shine { clip-path: var(--clip); }
|
||||
&[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); }
|
||||
&[data-subtypes^="supporter"] .holo-shine,
|
||||
&[data-subtypes^="item"] .holo-shine { clip-path: var(--clip-trainer); }
|
||||
}
|
||||
|
||||
// Cosmos holo
|
||||
&[data-rarity="rare holo cosmos"] {
|
||||
.holo-shine { clip-path: var(--clip); }
|
||||
&[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); }
|
||||
&[data-subtypes^="supporter"] .holo-shine { clip-path: var(--clip-trainer); }
|
||||
}
|
||||
|
||||
&[data-rarity="radiant rare"] { .holo-shine { clip-path: var(--clip-borders); } }
|
||||
&[data-rarity="amazing rare"] { .holo-shine { clip-path: var(--clip); } }
|
||||
|
||||
&[data-rarity="trainer gallery rare holo"],
|
||||
&[data-rarity="rare holo"][data-trainer-gallery="true"] {
|
||||
.holo-shine { clip-path: var(--clip-borders); }
|
||||
}
|
||||
|
||||
&[data-rarity="rare shiny"] {
|
||||
.holo-shine { clip-path: var(--clip); }
|
||||
&[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); }
|
||||
}
|
||||
|
||||
// Reverse holo by rarity — borders only
|
||||
&[data-rarity$="reverse holo"] { .holo-shine { clip-path: var(--clip-invert); } }
|
||||
// Reverse Holofoil variant — borders only
|
||||
&[data-variant="Reverse Holofoil"] { .holo-shine { clip-path: var(--clip-invert); } }
|
||||
|
||||
// True holofoil variants + full-bleed rarities — no clip
|
||||
&[data-variant="Holofoil"],
|
||||
&[data-variant="1st Edition Holofoil"],
|
||||
&[data-variant="Unlimited Holofoil"],
|
||||
&[data-rarity="rare ultra"],
|
||||
&[data-rarity="rare holo v"],
|
||||
&[data-rarity="rare holo vmax"],
|
||||
&[data-rarity="rare holo vstar"],
|
||||
&[data-rarity="rare shiny v"],
|
||||
&[data-rarity="rare shiny vmax"],
|
||||
&[data-rarity="rare rainbow"],
|
||||
&[data-rarity="rare rainbow alt"],
|
||||
&[data-rarity="rare secret"] {
|
||||
.holo-shine { clip-path: none; }
|
||||
}
|
||||
|
||||
// Foil variant shine/glare — clip handled above per variant type
|
||||
&[data-variant="Holofoil"],
|
||||
&[data-variant="Reverse Holofoil"],
|
||||
&[data-variant="1st Edition Holofoil"],
|
||||
&[data-variant="Unlimited Holofoil"] {
|
||||
.holo-shine {
|
||||
background-image:
|
||||
radial-gradient(
|
||||
circle at var(--pointer-x) var(--pointer-y),
|
||||
#fff 5%, #000 50%, #fff 80%
|
||||
),
|
||||
linear-gradient(
|
||||
var(--foil-angle, -45deg),
|
||||
#000 15%, #fff, #000 85%
|
||||
);
|
||||
background-blend-mode: soft-light, difference;
|
||||
background-size: 120% 120%, 200% 200%;
|
||||
background-position:
|
||||
center center,
|
||||
calc(100% * var(--pointer-from-left)) calc(100% * var(--pointer-from-top));
|
||||
filter: brightness(var(--foil-brightness, 0.4)) contrast(1.3) saturate(var(--foil-saturation, 0.5));
|
||||
mix-blend-mode: color-dodge;
|
||||
opacity: calc((var(--card-opacity) * 0.9) - (var(--pointer-from-center) * 0.1));
|
||||
}
|
||||
|
||||
.holo-glare {
|
||||
opacity: calc(var(--card-opacity) * 0.5);
|
||||
background-image: radial-gradient(
|
||||
farthest-corner circle at var(--pointer-x) var(--pointer-y),
|
||||
hsla(0, 0%, 100%, 0.5) 10%,
|
||||
hsla(0, 0%, 100%, 0.25) 30%,
|
||||
hsla(0, 0%, 0%, 0.4) 90%
|
||||
);
|
||||
filter: brightness(0.7) contrast(1.2);
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 5. DEFAULT HOLO SHINE / GLARE
|
||||
// Fallback for rarities not explicitly handled above.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
.image-grow,
|
||||
.card-image-wrap {
|
||||
&[data-rarity]:not([data-rarity="common"]):not([data-rarity="uncommon"]) {
|
||||
|
||||
.holo-shine {
|
||||
background-image:
|
||||
repeating-linear-gradient(110deg,
|
||||
var(--violet), var(--blue), var(--green), var(--yellow), var(--red),
|
||||
var(--violet), var(--blue), var(--green), var(--yellow), var(--red)
|
||||
);
|
||||
background-position:
|
||||
calc(((50% - var(--background-x)) * 2.6) + 50%)
|
||||
calc(((50% - var(--background-y)) * 3.5) + 50%);
|
||||
background-size: 400% 400%;
|
||||
filter: brightness(0.7) contrast(0.9) saturate(0.8);
|
||||
mix-blend-mode: color-dodge;
|
||||
opacity: calc(var(--card-opacity) * 0.6);
|
||||
}
|
||||
|
||||
.holo-glare {
|
||||
background-image: radial-gradient(
|
||||
farthest-corner circle at var(--pointer-x) var(--pointer-y),
|
||||
hsla(0, 0%, 100%, 0.35) 10%,
|
||||
hsla(0, 0%, 100%, 0.15) 30%,
|
||||
hsla(0, 0%, 0%, 0.35) 90%
|
||||
);
|
||||
opacity: calc(var(--card-opacity) * 0.4);
|
||||
mix-blend-mode: overlay;
|
||||
filter: brightness(0.7) contrast(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/* --------------------------------------------------
|
||||
Bootstrap overrides
|
||||
Bootstrap Overrides
|
||||
-------------------------------------------------- */
|
||||
|
||||
$grid-breakpoints: (
|
||||
@@ -21,21 +21,47 @@ $container-max-widths: (
|
||||
xxxl: 1840px
|
||||
) !default;
|
||||
|
||||
|
||||
@import "_bootstrap";
|
||||
|
||||
// ── Holofoil ──────────────────────────────────────────────────────────────
|
||||
//@import "_holofoil-integration"; // also pulls in _card.scss
|
||||
|
||||
/* --------------------------------------------------
|
||||
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 +97,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 +165,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 +187,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 +244,6 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
/* Tiered Colors */
|
||||
$tiers: (
|
||||
nm: rgba(156, 204, 102, 1),
|
||||
lp: rgba(211, 225, 86, 1),
|
||||
@@ -221,11 +271,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);
|
||||
@@ -239,25 +295,22 @@ $tiers: (
|
||||
.card-image {
|
||||
aspect-ratio: 23 / 32;
|
||||
object-fit: cover;
|
||||
z-index: 998;
|
||||
z-index: 1;
|
||||
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 +322,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 +339,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,22 +357,18 @@ $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;
|
||||
right: 5vw;
|
||||
display: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.top-icon svg {
|
||||
width: 2rem;
|
||||
height:2rem;
|
||||
height: 2rem;
|
||||
fill: var(--bs-info-bg-subtle);
|
||||
stroke: var(--bs-info-bg-subtle);
|
||||
}
|
||||
@@ -323,43 +378,50 @@ $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;
|
||||
z-index: 2;
|
||||
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 +438,7 @@ $tiers: (
|
||||
font-size: 0.9rem !important;
|
||||
}
|
||||
|
||||
/* Price Label */
|
||||
.price-label {
|
||||
font-size: 0.69rem;
|
||||
font-weight: 600;
|
||||
@@ -384,37 +447,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.79rem;
|
||||
}
|
||||
@media (min-width: 996px) {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
font-size: 0.89rem;
|
||||
}
|
||||
@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 +476,7 @@ $tiers: (
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
/* Sticky (mobile-bottom / desktop-top) search bar */
|
||||
/* Sticky Search Bar */
|
||||
.search-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
@@ -441,7 +488,6 @@ $tiers: (
|
||||
@media (min-width: 768px) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
float: right;
|
||||
min-width: 45vw;
|
||||
max-width: 45vw;
|
||||
transform: rotate(0);
|
||||
@@ -454,71 +500,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 +549,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);
|
||||
}
|
||||
-------------------------------------------------- */
|
||||
|
||||
280
src/assets/js/holofoil-init.js
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* holofoil-init.js
|
||||
* -----------------------------------------------------------------------------
|
||||
* Instruments .image-grow and .card-image-wrap with the holofoil effect system.
|
||||
*
|
||||
* GRID (.image-grow)
|
||||
* Effect is invisible at rest. On hover, pointer tracking drives the shine
|
||||
* and glare layers. The card lift/scale comes from main.scss as before.
|
||||
*
|
||||
* MODAL (.card-image-wrap)
|
||||
* Effect sweeps autonomously once per minute via CSS animation.
|
||||
* Pointer tracking takes over while the user hovers the image.
|
||||
*
|
||||
* DEFAULT FALLBACK
|
||||
* If data-default="true" is set (onerror in the Astro markup), no effect
|
||||
* is applied -- even if the attribute appears after stamp() has run.
|
||||
* -----------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
(function HolofoilSystem() {
|
||||
|
||||
'use strict';
|
||||
|
||||
// -- Constants --------------------------------------------------------------
|
||||
|
||||
const SHIMMER_SEL = [
|
||||
'.image-grow[data-rarity]',
|
||||
'.image-grow[data-variant="Holofoil"]',
|
||||
'.image-grow[data-variant="1st Edition Holofoil"]',
|
||||
'.image-grow[data-variant="Unlimited Holofoil"]',
|
||||
'.image-grow[data-variant="Reverse Holofoil"]',
|
||||
'.card-image-wrap[data-rarity]',
|
||||
'.card-image-wrap[data-variant="Holofoil"]',
|
||||
'.card-image-wrap[data-variant="1st Edition Holofoil"]',
|
||||
'.card-image-wrap[data-variant="Unlimited Holofoil"]',
|
||||
'.card-image-wrap[data-variant="Reverse Holofoil"]',
|
||||
].join(',');
|
||||
|
||||
const ALL_WRAPPERS_SEL = '.image-grow, .card-image-wrap';
|
||||
|
||||
// Foil variant visual randomisation
|
||||
const FOIL_ANGLE_MIN = -65, FOIL_ANGLE_MAX = -25;
|
||||
const FOIL_BRITE_MIN = 0.18, FOIL_BRITE_MAX = 0.32;
|
||||
const FOIL_SAT_MIN = 0.40, FOIL_SAT_MAX = 0.75;
|
||||
|
||||
const SKIP_RARITIES = new Set(['common', 'uncommon', '']);
|
||||
|
||||
|
||||
// -- Helpers ----------------------------------------------------------------
|
||||
|
||||
const rand = (min, max) => parseFloat((Math.random() * (max - min) + min).toFixed(2));
|
||||
const clamp01 = n => Math.max(0, Math.min(1, n));
|
||||
|
||||
function pointerVars(x, y, rect) {
|
||||
const fromLeft = clamp01((x - rect.left) / rect.width);
|
||||
const fromTop = clamp01((y - rect.top) / rect.height);
|
||||
const fromCenter = clamp01(Math.sqrt((fromLeft - 0.5) ** 2 + (fromTop - 0.5) ** 2) * 2);
|
||||
return {
|
||||
px: fromLeft * 100,
|
||||
py: fromTop * 100,
|
||||
fromLeft,
|
||||
fromTop,
|
||||
fromCenter,
|
||||
bgX: 50 + (fromLeft - 0.5) * 30,
|
||||
bgY: 50 + (fromTop - 0.5) * 30,
|
||||
};
|
||||
}
|
||||
|
||||
function applyPointerVars(el, v) {
|
||||
el.style.setProperty('--pointer-x', v.px.toFixed(1) + '%');
|
||||
el.style.setProperty('--pointer-y', v.py.toFixed(1) + '%');
|
||||
el.style.setProperty('--pointer-from-left', v.fromLeft.toFixed(3));
|
||||
el.style.setProperty('--pointer-from-top', v.fromTop.toFixed(3));
|
||||
el.style.setProperty('--pointer-from-center', v.fromCenter.toFixed(3));
|
||||
el.style.setProperty('--background-x', v.bgX.toFixed(1) + '%');
|
||||
el.style.setProperty('--background-y', v.bgY.toFixed(1) + '%');
|
||||
}
|
||||
|
||||
const isHoloVariant = v => ['Holofoil', 'Reverse Holofoil', '1st Edition Holofoil', 'Unlimited Holofoil'].includes(v);
|
||||
const isModalWrapper = el => el.classList.contains('card-image-wrap');
|
||||
const isDefault = el => el.dataset.default === 'true';
|
||||
|
||||
|
||||
// -- Child injection --------------------------------------------------------
|
||||
|
||||
function injectChildren(el) {
|
||||
if (el.querySelector('.holo-shine')) return;
|
||||
const shine = document.createElement('div');
|
||||
shine.className = 'holo-shine';
|
||||
const glare = document.createElement('div');
|
||||
glare.className = 'holo-glare';
|
||||
el.appendChild(shine);
|
||||
el.appendChild(glare);
|
||||
}
|
||||
|
||||
|
||||
// -- Default image guard ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Watch for the onerror handler in the Astro markup setting data-default="true"
|
||||
* after stamp() has already run. Hide the effect children immediately when seen.
|
||||
*/
|
||||
function watchForDefault(el) {
|
||||
if (isDefault(el)) return;
|
||||
|
||||
var observer = new MutationObserver(function() {
|
||||
if (isDefault(el)) {
|
||||
var shine = el.querySelector('.holo-shine');
|
||||
var glare = el.querySelector('.holo-glare');
|
||||
if (shine) shine.style.display = 'none';
|
||||
if (glare) glare.style.display = 'none';
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(el, { attributes: true, attributeFilter: ['data-default'] });
|
||||
}
|
||||
|
||||
|
||||
// -- Stamp ------------------------------------------------------------------
|
||||
|
||||
function stamp(el) {
|
||||
if (el.dataset.holoInit) return;
|
||||
|
||||
// Skip if already a default fallback image
|
||||
if (isDefault(el)) {
|
||||
el.dataset.holoInit = 'skip';
|
||||
return;
|
||||
}
|
||||
|
||||
const rarity = (el.dataset.rarity || '').toLowerCase();
|
||||
const variant = el.dataset.variant || '';
|
||||
|
||||
const hasHoloRarity = rarity && !SKIP_RARITIES.has(rarity);
|
||||
const hasHoloVariant = isHoloVariant(variant);
|
||||
|
||||
if (!hasHoloRarity && !hasHoloVariant) {
|
||||
el.dataset.holoInit = 'skip';
|
||||
return;
|
||||
}
|
||||
|
||||
injectChildren(el);
|
||||
|
||||
// Per-card foil visual randomisation (angle/brightness/saturation)
|
||||
if (hasHoloVariant) {
|
||||
el.style.setProperty('--foil-angle', Math.round(rand(FOIL_ANGLE_MIN, FOIL_ANGLE_MAX)) + 'deg');
|
||||
el.style.setProperty('--foil-brightness', rand(FOIL_BRITE_MIN, FOIL_BRITE_MAX).toFixed(2));
|
||||
el.style.setProperty('--foil-saturation', rand(FOIL_SAT_MIN, FOIL_SAT_MAX ).toFixed(2));
|
||||
}
|
||||
|
||||
// Modal-only: set a stable delay offset for the autonomous CSS animation
|
||||
if (isModalWrapper(el)) {
|
||||
el.classList.add('holo-modal-mode');
|
||||
el.style.setProperty('--shimmer-delay', rand(-8, 0) + 's');
|
||||
}
|
||||
|
||||
watchForDefault(el);
|
||||
|
||||
el.dataset.holoInit = '1';
|
||||
}
|
||||
|
||||
function stampAll(root) {
|
||||
(root || document).querySelectorAll(ALL_WRAPPERS_SEL).forEach(stamp);
|
||||
}
|
||||
|
||||
|
||||
// -- Pointer tracking -------------------------------------------------------
|
||||
|
||||
const pointerState = new WeakMap();
|
||||
|
||||
function onPointerEnter(e) {
|
||||
const el = e.currentTarget;
|
||||
if (el.dataset.holoInit !== '1' || isDefault(el)) return;
|
||||
|
||||
el.dataset.holoActive = '1';
|
||||
if (!pointerState.has(el)) pointerState.set(el, { rafId: null });
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
const el = e.currentTarget;
|
||||
if (el.dataset.holoInit !== '1') return;
|
||||
|
||||
const state = pointerState.get(el);
|
||||
if (!state) return;
|
||||
|
||||
if (state.rafId) cancelAnimationFrame(state.rafId);
|
||||
state.rafId = requestAnimationFrame(function() {
|
||||
const rect = el.getBoundingClientRect();
|
||||
applyPointerVars(el, pointerVars(e.clientX, e.clientY, rect));
|
||||
state.rafId = null;
|
||||
});
|
||||
}
|
||||
|
||||
function onPointerLeave(e) {
|
||||
const el = e.currentTarget;
|
||||
if (el.dataset.holoInit !== '1') return;
|
||||
|
||||
const state = pointerState.get(el);
|
||||
if (state && state.rafId) { cancelAnimationFrame(state.rafId); state.rafId = null; }
|
||||
|
||||
delete el.dataset.holoActive;
|
||||
|
||||
if (isModalWrapper(el)) {
|
||||
// Let the CSS animation resume driving --card-opacity
|
||||
el.style.removeProperty('--card-opacity');
|
||||
}
|
||||
}
|
||||
|
||||
function attachListeners(el) {
|
||||
if (el.dataset.holoListeners) return;
|
||||
el.addEventListener('pointerenter', onPointerEnter, { passive: true });
|
||||
el.addEventListener('pointermove', onPointerMove, { passive: true });
|
||||
el.addEventListener('pointerleave', onPointerLeave, { passive: true });
|
||||
el.dataset.holoListeners = '1';
|
||||
}
|
||||
|
||||
function attachAllListeners(root) {
|
||||
(root || document).querySelectorAll(SHIMMER_SEL).forEach(function(el) {
|
||||
stamp(el);
|
||||
if (el.dataset.holoInit === '1') attachListeners(el);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// -- MutationObserver: react to HTMX / infinite scroll ----------------------
|
||||
|
||||
function observeGrid() {
|
||||
var grid = document.getElementById('cardGrid');
|
||||
if (!grid) return;
|
||||
|
||||
new MutationObserver(function(mutations) {
|
||||
for (var i = 0; i < mutations.length; i++) {
|
||||
var nodes = mutations[i].addedNodes;
|
||||
for (var j = 0; j < nodes.length; j++) {
|
||||
var node = nodes[j];
|
||||
if (node.nodeType !== 1) continue;
|
||||
if (node.matches && node.matches(ALL_WRAPPERS_SEL)) {
|
||||
stamp(node);
|
||||
if (node.dataset.holoInit === '1') attachListeners(node);
|
||||
}
|
||||
if (node.querySelectorAll) {
|
||||
node.querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) {
|
||||
stamp(el);
|
||||
if (el.dataset.holoInit === '1') attachListeners(el);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}).observe(grid, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
function observeModal() {
|
||||
var modal = document.getElementById('cardModal');
|
||||
if (!modal) return;
|
||||
|
||||
new MutationObserver(function() {
|
||||
modal.querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) {
|
||||
stamp(el);
|
||||
if (el.dataset.holoInit === '1') attachListeners(el);
|
||||
});
|
||||
}).observe(modal, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
|
||||
// -- Bootstrap --------------------------------------------------------------
|
||||
|
||||
function init() {
|
||||
stampAll();
|
||||
attachAllListeners();
|
||||
observeGrid();
|
||||
observeModal();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -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 });
|
||||
251
src/assets/js/priceChart.js
Normal file
@@ -0,0 +1,251 @@
|
||||
import Chart from 'chart.js/auto';
|
||||
|
||||
const CONDITIONS = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
|
||||
|
||||
const CONDITION_COLORS = {
|
||||
"Near Mint": { active: 'rgba(156, 204, 102, 1)', muted: 'rgba(156, 204, 102, 0.67)' },
|
||||
"Lightly Played": { active: 'rgba(211, 225, 86, 1)', muted: 'rgba(211, 225, 86, 0.67)' },
|
||||
"Moderately Played": { active: 'rgba(255, 238, 87, 1)', muted: 'rgba(255, 238, 87, 0.67)' },
|
||||
"Heavily Played": { active: 'rgba(255, 201, 41, 1)', muted: 'rgba(255, 201, 41, 0.67)' },
|
||||
"Damaged": { active: 'rgba(255, 167, 36, 1)', muted: 'rgba(255, 167, 36, 0.67)' },
|
||||
};
|
||||
|
||||
const RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '1y': 365, 'all': Infinity };
|
||||
|
||||
let chartInstance = null;
|
||||
let allHistory = [];
|
||||
let activeCondition = "Near Mint";
|
||||
let activeRange = '1m';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
const d = new Date(Number(year), Number(month) - 1, Number(day));
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function setEmptyState(isEmpty) {
|
||||
const modal = document.getElementById('cardModal');
|
||||
const empty = modal?.querySelector('#priceHistoryEmpty');
|
||||
const canvasWrapper = empty?.nextElementSibling;
|
||||
if (!empty || !canvasWrapper) return;
|
||||
empty.classList.toggle('d-none', !isEmpty);
|
||||
canvasWrapper.classList.toggle('d-none', isEmpty);
|
||||
}
|
||||
|
||||
function buildChartData(history, rangeKey) {
|
||||
const cutoff = RANGE_DAYS[rangeKey] === Infinity
|
||||
? new Date(0)
|
||||
: new Date(Date.now() - RANGE_DAYS[rangeKey] * 86_400_000);
|
||||
|
||||
const filtered = history.filter(r => new Date(r.calculatedAt) >= cutoff);
|
||||
|
||||
// Always build the full date axis for the selected window, even if sparse.
|
||||
// Generate one label per day in the range so the x-axis reflects the
|
||||
// chosen period rather than collapsing to only the days that have data.
|
||||
const dataDateSet = new Set(filtered.map(r => r.calculatedAt));
|
||||
const allDates = [...dataDateSet].sort((a, b) => new Date(a) - new Date(b));
|
||||
|
||||
// If we have real data, expand the axis to span from cutoff → today so
|
||||
// empty stretches at the start/end of a range are visible.
|
||||
let axisLabels = allDates;
|
||||
if (allDates.length > 0 && RANGE_DAYS[rangeKey] !== Infinity) {
|
||||
const start = new Date(cutoff);
|
||||
const end = new Date();
|
||||
const expanded = [];
|
||||
// Step through every day in the window
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
expanded.push(d.toISOString().split('T')[0]);
|
||||
}
|
||||
axisLabels = expanded;
|
||||
}
|
||||
|
||||
const labels = axisLabels.map(formatDate);
|
||||
|
||||
const lookup = {};
|
||||
for (const row of filtered) {
|
||||
if (!lookup[row.condition]) lookup[row.condition] = {};
|
||||
lookup[row.condition][row.calculatedAt] = Number(row.marketPrice);
|
||||
}
|
||||
|
||||
const activeConditionHasData = allDates.some(
|
||||
date => lookup[activeCondition]?.[date] != null
|
||||
);
|
||||
|
||||
const datasets = CONDITIONS.map(condition => {
|
||||
const isActive = condition === activeCondition;
|
||||
const colors = CONDITION_COLORS[condition];
|
||||
const data = axisLabels.map(date => lookup[condition]?.[date] ?? null);
|
||||
return {
|
||||
label: condition,
|
||||
data,
|
||||
borderColor: isActive ? colors.active : colors.muted,
|
||||
borderWidth: isActive ? 2 : 1,
|
||||
pointRadius: isActive ? 2.5 : 0,
|
||||
pointHoverRadius: isActive ? 5 : 3,
|
||||
pointBackgroundColor: isActive ? colors.active : colors.muted,
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
spanGaps: true,
|
||||
order: isActive ? 0 : 1,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets,
|
||||
hasData: allDates.length > 0,
|
||||
activeConditionHasData,
|
||||
};
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (!chartInstance) return;
|
||||
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
|
||||
|
||||
// Always push the new labels/datasets to the chart so the x-axis
|
||||
// reflects the selected time window — even when there's no data for
|
||||
// the active condition. Then toggle the empty state overlay on top.
|
||||
chartInstance.data.labels = labels;
|
||||
chartInstance.data.datasets = datasets;
|
||||
chartInstance.update('none');
|
||||
|
||||
// Show the empty state overlay if the active condition has no points
|
||||
// in this window, but leave the (empty) chart visible underneath so
|
||||
// the axis communicates the selected period.
|
||||
setEmptyState(!hasData || !activeConditionHasData);
|
||||
}
|
||||
|
||||
function initPriceChart(canvas) {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
chartInstance = null;
|
||||
}
|
||||
|
||||
try {
|
||||
allHistory = JSON.parse(canvas.dataset.history ?? '[]');
|
||||
} catch (err) {
|
||||
console.error('Failed to parse price history:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allHistory.length) {
|
||||
setEmptyState(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
|
||||
|
||||
// Render the chart regardless — show empty state overlay if needed
|
||||
setEmptyState(!hasData || !activeConditionHasData);
|
||||
|
||||
chartInstance = new Chart(canvas.getContext('2d'), {
|
||||
type: 'line',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||
titleColor: 'rgba(255, 255, 255, 0.9)',
|
||||
bodyColor: 'rgba(255, 255, 255, 0.75)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
labelColor: (ctx) => {
|
||||
const colors = CONDITION_COLORS[ctx.dataset.label];
|
||||
return {
|
||||
borderColor: colors.active,
|
||||
backgroundColor: colors.active,
|
||||
};
|
||||
},
|
||||
label: (ctx) => {
|
||||
const isActive = ctx.dataset.label === activeCondition;
|
||||
const price = ctx.parsed.y != null ? `$${ctx.parsed.y.toFixed(2)}` : '—';
|
||||
return isActive
|
||||
? ` ${ctx.dataset.label}: ${price} ◀`
|
||||
: ` ${ctx.dataset.label}: ${price}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||
ticks: {
|
||||
color: 'rgba(255, 255, 255, 0.4)',
|
||||
maxTicksLimit: 6,
|
||||
maxRotation: 0,
|
||||
},
|
||||
border: { color: 'rgba(255, 255, 255, 0.1)' },
|
||||
},
|
||||
y: {
|
||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||
ticks: {
|
||||
color: 'rgba(255, 255, 255, 0.4)',
|
||||
callback: (val) => `$${Number(val).toFixed(2)}`,
|
||||
},
|
||||
border: { color: 'rgba(255, 255, 255, 0.1)' },
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initFromCanvas(canvas) {
|
||||
activeCondition = "Near Mint";
|
||||
activeRange = '1m';
|
||||
const modal = document.getElementById('cardModal');
|
||||
modal?.querySelectorAll('.price-range-btn').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.range === '1m');
|
||||
});
|
||||
initPriceChart(canvas);
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const modal = document.getElementById('cardModal');
|
||||
if (!modal) return;
|
||||
|
||||
modal.addEventListener('card-modal:swapped', () => {
|
||||
const canvas = modal.querySelector('#priceHistoryChart');
|
||||
if (canvas) initFromCanvas(canvas);
|
||||
});
|
||||
|
||||
modal.addEventListener('hidden.bs.modal', () => {
|
||||
if (chartInstance) { chartInstance.destroy(); chartInstance = null; }
|
||||
allHistory = [];
|
||||
});
|
||||
|
||||
document.addEventListener('shown.bs.tab', (e) => {
|
||||
if (!modal.contains(e.target)) return;
|
||||
const target = e.target?.getAttribute('data-bs-target');
|
||||
const conditionMap = {
|
||||
'#nav-nm': 'Near Mint',
|
||||
'#nav-lp': 'Lightly Played',
|
||||
'#nav-mp': 'Moderately Played',
|
||||
'#nav-hp': 'Heavily Played',
|
||||
'#nav-dmg': 'Damaged',
|
||||
};
|
||||
if (target && conditionMap[target]) {
|
||||
activeCondition = conditionMap[target];
|
||||
updateChart();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target?.closest('.price-range-btn');
|
||||
if (!btn || !modal.contains(btn)) return;
|
||||
modal.querySelectorAll('.price-range-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
activeRange = btn.dataset.range ?? '1m';
|
||||
updateChart();
|
||||
});
|
||||
}
|
||||
|
||||
setup();
|
||||
@@ -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>
|
||||
@@ -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,396 @@ 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-column gap-1 flex-md-row align-items-center mb-3 w-100 justify-content-start">
|
||||
<div id="sortBy"></div>
|
||||
<div id="totalResults"></div>
|
||||
<div id="activeFilters"></div>
|
||||
</div>
|
||||
<div id="cardGrid" aria-live="polite" class="row g-xxl-3 g-2 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5"></div>
|
||||
<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 src="src/assets/js/holofoil-init.js" is:inline></script> -->
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
|
||||
// ── Sort dropdown ─────────────────────────────────────────────────────────
|
||||
document.addEventListener('click', (e) => {
|
||||
const sortBy = document.getElementById('sortBy');
|
||||
|
||||
const btn = e.target.closest('#sortBy [data-bs-toggle="dropdown"]');
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const menu = btn.nextElementSibling;
|
||||
menu.classList.toggle('show');
|
||||
btn.setAttribute('aria-expanded', menu.classList.contains('show'));
|
||||
return;
|
||||
}
|
||||
|
||||
const opt = e.target.closest('#sortBy .sort-option');
|
||||
if (opt) {
|
||||
e.preventDefault();
|
||||
const menu = opt.closest('.dropdown-menu');
|
||||
const btn2 = menu?.previousElementSibling;
|
||||
menu?.classList.remove('show');
|
||||
if (btn2) btn2.setAttribute('aria-expanded', 'false');
|
||||
|
||||
const sortInput = document.getElementById('sortInput');
|
||||
if (sortInput) sortInput.value = opt.dataset.sort;
|
||||
document.getElementById('sortLabel').textContent = opt.dataset.label;
|
||||
document.querySelectorAll('.sort-option').forEach(o => o.classList.remove('active'));
|
||||
opt.classList.add('active');
|
||||
|
||||
const start = document.getElementById('start');
|
||||
if (start) start.value = '0';
|
||||
document.getElementById('searchform').dispatchEvent(
|
||||
new Event('submit', { bubbles: true, cancelable: true })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const menu = document.querySelector('#sortBy .dropdown-menu.show');
|
||||
if (menu) {
|
||||
menu.classList.remove('show');
|
||||
const btn3 = menu.previousElementSibling;
|
||||
if (btn3) btn3.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Language toggle ───────────────────────────────────────────────────────
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.language-btn');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
|
||||
const input = document.getElementById('languageInput');
|
||||
if (input) input.value = btn.dataset.lang;
|
||||
|
||||
const start = document.getElementById('start');
|
||||
if (start) start.value = '0';
|
||||
|
||||
document.getElementById('searchform').dispatchEvent(
|
||||
new Event('submit', { bubbles: true, cancelable: true })
|
||||
);
|
||||
});
|
||||
|
||||
// ── Global helpers ────────────────────────────────────────────────────────
|
||||
window.copyImage = async function(img) {
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
|
||||
// Load with crossOrigin so toBlob() stays untainted
|
||||
await new Promise((resolve) => {
|
||||
const clean = new Image();
|
||||
clean.crossOrigin = 'anonymous';
|
||||
clean.onload = () => { ctx.drawImage(clean, 0, 0); resolve(); };
|
||||
clean.onerror = () => { ctx.drawImage(img, 0, 0); resolve(); };
|
||||
clean.src = img.src;
|
||||
});
|
||||
|
||||
const blob = await new Promise((resolve, reject) => {
|
||||
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
|
||||
});
|
||||
|
||||
if (isIOS) {
|
||||
const file = new File([blob], 'card.png', { type: 'image/png' });
|
||||
await navigator.share({ files: [file] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigator.clipboard && navigator.clipboard.write) {
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||
showCopyToast('📋 Image copied!', '#198754');
|
||||
} else {
|
||||
const url = img.src;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showCopyToast('📋 Image URL copied!', '#198754');
|
||||
} else {
|
||||
const input = document.createElement('input');
|
||||
input.value = url;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(input);
|
||||
showCopyToast('📋 Image URL copied!', '#198754');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error('Failed:', err);
|
||||
showCopyToast('❌ Copy failed', '#dc3545');
|
||||
}
|
||||
};
|
||||
|
||||
function showCopyToast(message, color) {
|
||||
const toast = document.createElement('div');
|
||||
toast.textContent = message;
|
||||
toast.style.cssText = `
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
background: ${color}; color: white; padding: 10px 20px;
|
||||
border-radius: 8px; font-size: 14px; z-index: 9999;
|
||||
opacity: 0; transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
requestAnimationFrame(() => toast.style.opacity = '1');
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.addEventListener('transitionend', () => toast.remove());
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
const cardIndex = [];
|
||||
let currentCardId = null;
|
||||
let isNavigating = false;
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
document.getElementById('modalPrevBtn').addEventListener('click', navigatePrev);
|
||||
document.getElementById('modalNextBtn').addEventListener('click', navigateNext);
|
||||
|
||||
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(); }
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
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 = '';
|
||||
}
|
||||
});
|
||||
|
||||
const cardModal = document.getElementById('cardModal');
|
||||
cardModal.addEventListener('shown.bs.modal', () => {
|
||||
updateNavButtons(cardModal);
|
||||
initChartAfterSwap(cardModal);
|
||||
});
|
||||
cardModal.addEventListener('hidden.bs.modal', () => {
|
||||
currentCardId = null;
|
||||
updateNavButtons(null);
|
||||
});
|
||||
|
||||
// ── AdSense re-init on infinite scroll ───────────────────────────────────
|
||||
document.addEventListener('htmx:afterSwap', () => {
|
||||
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
14
src/components/FirstEditionIcon.astro
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
7
src/components/LeftSidebarDesktop.astro
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="d-none d-xl-block sticky-top mt-5" style="top: 70px;">
|
||||
<ins class="adsbygoogle"
|
||||
style="display:block"
|
||||
data-ad-format="autorelaxed"
|
||||
data-ad-client="ca-pub-1140571217687341"
|
||||
data-ad-slot="8889263515"></ins>
|
||||
</div>
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
import '/src/assets/css/main.scss';
|
||||
---
|
||||
|
||||
---
|
||||
<header class="header-top w-100">
|
||||
<div class="header-wrap">
|
||||
<div class="header-content">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,22 @@ import { SignedIn } from "@clerk/astro/components";
|
||||
};
|
||||
</script>
|
||||
|
||||
<SignedIn>
|
||||
<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>
|
||||
<Show when="signed-in">
|
||||
<form class="d-flex ms-2 align-items-center gap-2" role="search" id="searchform" hx-post="/partials/cards" hx-target="#cardGrid" hx-trigger="load, submit" hx-vals='{"start":"0"}' hx-on--after-request="afterUpdate()" hx-on--before-request="beforeSearch()">
|
||||
<a class="btn btn-secondary btn-lg" data-bs-toggle="offcanvas" href="#filterBar" role="button" aria-controls="filterBar" aria-label="filter">
|
||||
<span class="d-block d-md-none filter-icon py-2">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg>
|
||||
</span>
|
||||
<span class="d-none d-md-block">Filters</span>
|
||||
</a>
|
||||
|
||||
<div class="input-group">
|
||||
<input type="hidden" name="start" id="start" value="0" />
|
||||
<input type="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>
|
||||
<input type="hidden" name="start" id="start" value="0" />
|
||||
<input type="hidden" name="sort" id="sortInput" value="" />
|
||||
<input type="hidden" name="language" id="languageInput" value="all" />
|
||||
<input type="search" name="q" class="form-control form-control-lg" placeholder="Search cards..." />
|
||||
<button type="submit" class="btn btn-danger btn-lg border border-danger-subtle border-start-0" aria-label="search" value="" onclick="const q = this.closest('form').querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });">
|
||||
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M432 272C432 183.6 360.4 112 272 112C183.6 112 112 183.6 112 272C112 360.4 183.6 432 272 432C360.4 432 432 360.4 432 272zM401.1 435.1C365.7 463.2 320.8 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272C480 320.8 463.2 365.7 435.1 401.1L569 535C578.4 544.4 578.4 558.1 569 567.5C559.6 575.9 544.4 575.9 536 567.5L401.1 435.1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</SignedIn>
|
||||
|
||||
|
||||
@@ -123,6 +123,8 @@ 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";
|
||||
import perfect_order from "/src/svg/set/perfect_order.svg?raw";
|
||||
|
||||
const { set } = Astro.props;
|
||||
|
||||
@@ -130,7 +132,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,
|
||||
@@ -251,9 +253,11 @@ const setMap = {
|
||||
"ASC": ascended_heroes,
|
||||
"DRI": destined_rivals,
|
||||
"SSP": surging_sparks,
|
||||
"ME03": perfect_order,
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { mysqlTable, int, varchar, boolean, decimal, datetime, index } from "drizzle-orm/mysql-core"
|
||||
//import { mysqlTable, int, varchar, boolean, decimal, datetime, index } from "drizzle-orm/mysql-core"
|
||||
import { integer, varchar, boolean, decimal, timestamp, index, pgSchema, uniqueIndex, primaryKey } from "drizzle-orm/pg-core";
|
||||
|
||||
export const tcgcards = mysqlTable("tcgcards", {
|
||||
productId: int().primaryKey(),
|
||||
export const pokeSchema = pgSchema("pokemon");
|
||||
|
||||
export const tcgcards = pokeSchema.table('tcg_cards', {
|
||||
productId: integer().primaryKey(),
|
||||
productName: varchar({ length: 255 }).notNull(),
|
||||
productLineName: varchar({ length: 255 }).default("").notNull(),
|
||||
productLineUrlName: varchar({ length: 255 }).default("").notNull(),
|
||||
productStatusId: int().default(0).notNull(),
|
||||
productTypeId: int().default(0).notNull(),
|
||||
productStatusId: integer().default(0).notNull(),
|
||||
productTypeId: integer().default(0).notNull(),
|
||||
productUrlName: varchar({ length: 255 }).default("").notNull(),
|
||||
rarityName: varchar({ length: 100 }).default("").notNull(),
|
||||
sealed: boolean().default(false).notNull(),
|
||||
sellerListable: boolean().default(false).notNull(),
|
||||
setId: int(),
|
||||
shippingCategoryId: int(),
|
||||
setId: integer(),
|
||||
shippingCategoryId: integer(),
|
||||
duplicate: boolean().default(false).notNull(),
|
||||
foilOnly: boolean().default(false).notNull(),
|
||||
maxFulfillableQuantity: int(),
|
||||
totalListings: int(),
|
||||
maxFulfillableQuantity: integer(),
|
||||
totalListings: integer(),
|
||||
score: decimal({ precision: 10, scale: 2, mode: 'number' }),
|
||||
lowestPrice: decimal({ precision: 10, scale: 2, mode: 'number' }),
|
||||
lowestPriceWithShipping: decimal({ precision: 10, scale: 2, mode: 'number' }),
|
||||
@@ -30,73 +33,97 @@ export const tcgcards = mysqlTable("tcgcards", {
|
||||
cardTypeB: varchar({ length: 100 }),
|
||||
energyType: varchar({ length: 100 }),
|
||||
flavorText: varchar({ length: 1000 }),
|
||||
hp: int(),
|
||||
hp: integer(),
|
||||
number: varchar({ length: 50 }).default("").notNull(),
|
||||
releaseDate: datetime(),
|
||||
releaseDate: timestamp(),
|
||||
resistance: varchar({ length: 100 }),
|
||||
retreatCost: varchar({ length: 100 }),
|
||||
stage: varchar({ length: 100 }),
|
||||
weakness: varchar({ length: 100 }),
|
||||
Artist: varchar({ length: 255 }),
|
||||
artist: varchar({ length: 255 }),
|
||||
});
|
||||
|
||||
export const cards = mysqlTable("cards", {
|
||||
cardId: int().notNull().primaryKey().autoincrement(),
|
||||
productId: int().notNull(),
|
||||
export const cards = pokeSchema.table('cards', {
|
||||
cardId: integer().notNull().primaryKey().generatedAlwaysAsIdentity(),
|
||||
productId: integer().notNull(),
|
||||
variant: varchar({ length: 100 }).notNull(),
|
||||
productName: varchar({ length: 255 }),
|
||||
productLineName: varchar({ length: 255 }),
|
||||
productUrlName: varchar({ length: 255 }).default("").notNull(),
|
||||
rarityName: varchar({ length: 100 }),
|
||||
sealed: boolean().default(false).notNull(),
|
||||
setId: int(),
|
||||
setId: integer(),
|
||||
cardType: varchar({ length: 100 }),
|
||||
energyType: varchar({ length: 100 }),
|
||||
number: varchar({ length: 50 }),
|
||||
Artist: varchar({ length: 255 }),
|
||||
artist: varchar({ length: 255 }),
|
||||
},
|
||||
(table) => [
|
||||
index("card_productIdIdx").on(table.productId, table.variant),
|
||||
index('idx_card_product_id').on(table.productId, table.variant),
|
||||
]);
|
||||
|
||||
export const tcg_overrides = mysqlTable("tcg_overrides", {
|
||||
productId: int().primaryKey(),
|
||||
export const tcg_overrides = pokeSchema.table('tcg_overrides', {
|
||||
productId: integer().primaryKey(),
|
||||
productName: varchar({ length: 255 }),
|
||||
productLineName: varchar({ length: 255 }),
|
||||
productUrlName: varchar({ length: 255 }).default("").notNull(),
|
||||
productUrlName: varchar({ length: 255 }).default('').notNull(),
|
||||
rarityName: varchar({ length: 100 }),
|
||||
sealed: boolean().default(false).notNull(),
|
||||
setId: int(),
|
||||
setId: integer(),
|
||||
cardType: varchar({ length: 100 }),
|
||||
energyType: varchar({ length: 100 }),
|
||||
number: varchar({ length: 50 }),
|
||||
Artist: varchar({ length: 255 }),
|
||||
artist: varchar({ length: 255 }),
|
||||
});
|
||||
|
||||
export const sets = mysqlTable("sets", {
|
||||
setId: int().primaryKey(),
|
||||
export const sets = pokeSchema.table('sets', {
|
||||
setId: integer().primaryKey(),
|
||||
setName: varchar({ length: 255 }).notNull(),
|
||||
setUrlName: varchar({ length: 255 }).notNull(),
|
||||
setCode: varchar({ length: 100 }).notNull(),
|
||||
});
|
||||
|
||||
export const skus = mysqlTable("skus", {
|
||||
skuId: int().primaryKey(),
|
||||
cardId: int().default(0).notNull(),
|
||||
productId: int().notNull(),
|
||||
export const skus = pokeSchema.table('skus', {
|
||||
skuId: integer().primaryKey(),
|
||||
cardId: integer().default(0).notNull(),
|
||||
productId: integer().notNull(),
|
||||
condition: varchar({ length: 255 }).notNull(),
|
||||
language: varchar({ length: 100 }).notNull(),
|
||||
variant: varchar({ length: 100 }).notNull(),
|
||||
calculatedAt: datetime(),
|
||||
calculatedAt: timestamp(),
|
||||
highestPrice: decimal({ precision: 10, scale: 2 }),
|
||||
lowestPrice: decimal({ precision: 10, scale: 2 }),
|
||||
marketPrice: decimal({ precision: 10, scale: 2 }),
|
||||
priceCount: int(),
|
||||
priceCount: integer(),
|
||||
},
|
||||
(table) => [
|
||||
index("productIdIdx").on(table.productId, table.variant),
|
||||
index('idx_product_id_condition').on(table.productId, table.variant, table.condition),
|
||||
]);
|
||||
|
||||
export const processingSkus = mysqlTable("processingSkus", {
|
||||
skuId: int().primaryKey(),
|
||||
export const priceHistory = pokeSchema.table('price_history', {
|
||||
skuId: integer().notNull(),
|
||||
calculatedAt: timestamp().notNull(),
|
||||
marketPrice: decimal({ precision: 10, scale: 2 }),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ name: 'pk_price_history', columns: [table.skuId, table.calculatedAt] })
|
||||
]);
|
||||
|
||||
export const salesHistory = pokeSchema.table('sales_history',{
|
||||
skuId: integer().notNull(),
|
||||
orderDate: timestamp().notNull(),
|
||||
title: varchar({ length: 255 }),
|
||||
customListingId: varchar({ length: 255 }),
|
||||
language: varchar({ length: 100 }),
|
||||
listingType: varchar({ length: 100 }),
|
||||
purchasePrice: decimal({ precision: 10, scale: 2 }),
|
||||
quantity: integer(),
|
||||
shippingPrice: decimal({ precision: 10, scale: 2 })
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ name: 'pk_sales_history', columns: [table.skuId, table.orderDate] })
|
||||
]);
|
||||
|
||||
export const processingSkus = pokeSchema.table('processing_skus', {
|
||||
skuId: integer().primaryKey(),
|
||||
});
|
||||
|
||||
@@ -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,11 @@ 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}'/>
|
||||
<meta name="google-adsense-account" content="ca-pub-1140571217687341">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<title>Rigid's App Thing</title>
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Google Tag Manager (noscript) -->
|
||||
@@ -39,6 +39,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>
|
||||
@@ -1,14 +1,45 @@
|
||||
// src/middleware.ts
|
||||
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server';
|
||||
import type { AstroMiddlewareRequest, AstroMiddlewareResponse } from 'astro';
|
||||
import { clerkMiddleware, createRouteMatcher, clerkClient } from '@clerk/astro/server';
|
||||
|
||||
const isProtectedRoute = createRouteMatcher([
|
||||
'/pokemon',
|
||||
]);
|
||||
const isProtectedRoute = createRouteMatcher(['/pokemon']);
|
||||
const isAdminRoute = createRouteMatcher(['/admin']);
|
||||
|
||||
export const onRequest = clerkMiddleware((auth, context) => {
|
||||
if (!auth().userId && isProtectedRoute(context.request)) {
|
||||
// Redirect unauthenticated users to the sign-in page
|
||||
return auth().redirectToSignIn();
|
||||
const TARGET_ORG_ID = "org_3Baav9czkRLLlC7g89oJWqRRulK";
|
||||
|
||||
export const onRequest = clerkMiddleware(async (auth, context) => {
|
||||
const { isAuthenticated, userId, redirectToSignIn } = auth();
|
||||
|
||||
if (!isAuthenticated && isProtectedRoute(context.request)) {
|
||||
return redirectToSignIn();
|
||||
}
|
||||
|
||||
if (isAdminRoute(context.request)) {
|
||||
if (!isAuthenticated || !userId) {
|
||||
return redirectToSignIn();
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await clerkClient(context); // pass context here
|
||||
const memberships = await client.organizations.getOrganizationMembershipList({
|
||||
organizationId: TARGET_ORG_ID,
|
||||
});
|
||||
|
||||
console.log("Total memberships found:", memberships.data.length);
|
||||
console.log("Current userId:", userId);
|
||||
console.log("Memberships:", JSON.stringify(memberships.data.map(m => ({
|
||||
userId: m.publicUserData?.userId,
|
||||
role: m.role,
|
||||
})), null, 2));
|
||||
|
||||
const userMembership = memberships.data.find(
|
||||
(m) => m.publicUserData?.userId === userId
|
||||
);
|
||||
|
||||
if (!userMembership || userMembership.role !== "org:admin") {
|
||||
return context.redirect("/");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Clerk membership check failed:", e);
|
||||
return context.redirect("/");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 # (0001–1025)
|
||||
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>
|
||||
18
src/pages/admin.astro
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import Layout from '../layouts/Main.astro';
|
||||
import NavItems from '../components/NavItems.astro';
|
||||
import NavBar from '../components/NavBar.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
---
|
||||
<Layout title="Admin Panel">
|
||||
<NavBar slot="navbar">
|
||||
<NavItems slot="navItems" />
|
||||
</NavBar>
|
||||
<div class="row mb-4" slot="page">
|
||||
<div class="col-12">
|
||||
<h1>Admin Panel</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Footer slot="footer" />
|
||||
</Layout>
|
||||
95
src/pages/api/upload.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// src/pages/api/upload.ts
|
||||
import type { APIRoute } from 'astro';
|
||||
import { parse, stringify, transform } from 'csv';
|
||||
import { Readable } from 'stream';
|
||||
import { client } from '../../db/typesense';
|
||||
import chalk from 'chalk';
|
||||
import { db, ClosePool } from '../../db/index';
|
||||
|
||||
// Define the transformation logic
|
||||
const transformer = transform({ parallel: 1 }, async function(this: any, row: any, callback: any) {
|
||||
try {
|
||||
// Specific query bsaed on tcgcollector CSV
|
||||
const query = String(Object.values(row)[1]);
|
||||
const setname = String(Object.values(row)[4]).replace(/Wizards of the coast promos/ig,'WoTC Promo');
|
||||
const cardNumber = String(Object.values(row)[7]);
|
||||
console.log(`${query} ${cardNumber} : ${setname}`);
|
||||
|
||||
// Use Typesense to find the card because we can easily use the combined fields
|
||||
let cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `setName:\`${setname}\` && number:${cardNumber}` });
|
||||
if (cards.hits?.length === 0) {
|
||||
// Try without card number
|
||||
cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `setName:\`${setname}\`` });
|
||||
}
|
||||
if (cards.hits?.length === 0) {
|
||||
// Try without set name
|
||||
cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `number:${cardNumber}` });
|
||||
}
|
||||
if (cards.hits?.length === 0) {
|
||||
// I give up, just output the values from the csv
|
||||
console.log(chalk.red(' - not found'));
|
||||
const newRow = { ...row };
|
||||
newRow.Variant = '';
|
||||
newRow.marketPrice = '';
|
||||
this.push(newRow);
|
||||
}
|
||||
else {
|
||||
for (const card of cards.hits?.map((hit: any) => hit.document) ?? []) {
|
||||
console.log(chalk.blue(` - ${card.cardId} : ${card.productName} : ${card.number}`), chalk.yellow(`${card.setName}`), chalk.green(`${card.variant}`));
|
||||
const variant = await db.query.cards.findFirst({
|
||||
with: { prices: true, tcgdata: true },
|
||||
where: { cardId: card.cardId }
|
||||
});
|
||||
const newRow = { ...row };
|
||||
newRow.Variant = variant?.variant;
|
||||
newRow.marketPrice = variant?.prices.find(p => p.condition === 'Near Mint')?.marketPrice;
|
||||
this.push(newRow);
|
||||
}
|
||||
}
|
||||
|
||||
callback();
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
const inputStream = Readable.from(file.stream());
|
||||
|
||||
if (!file) {
|
||||
return new Response('No file uploaded', { status: 400 });
|
||||
}
|
||||
|
||||
// Pipe the streams: Read -> Parse -> Transform -> Stringify -> Write
|
||||
const outputStream = inputStream
|
||||
.on('error', (error) => console.error('Input stream error:', error))
|
||||
.pipe(parse({ columns: true, trim: true }))
|
||||
.on('error', (error) => console.error('Parse error:', error))
|
||||
.pipe(transformer)
|
||||
.on('error', (error) => console.error('Transform error:', error))
|
||||
.pipe(stringify({ header: true }))
|
||||
.on('error', (error) => console.error('Stringify error:', error));
|
||||
|
||||
// outputStream.on('finish', () => {
|
||||
// ClosePool();
|
||||
// }).on('error', (error) => {
|
||||
// ClosePool();
|
||||
// });
|
||||
|
||||
|
||||
return new Response(outputStream as any, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-Disposition': 'attachment; filename=transformed.csv',
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing CSV stream:', error);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -1,32 +1,58 @@
|
||||
---
|
||||
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 { Show, SignInButton, SignUpButton, SignOutButton, GoogleOneTap, UserAvatar, UserButton, UserProfile } 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-6 col-xl-7 mb-2">
|
||||
<h4 class="mt-3">Welcome!</h4>
|
||||
<p class="mt-2">
|
||||
This single-page web application is currently in a closed beta. Access to the beta will be limited, and the selection process will be highly curated. You are welcome to request access - if you do not get into the beta, don't worry! After the closed beta is complete, the app will move into a more open beta.</p>
|
||||
</p>
|
||||
<p class="my-2">
|
||||
If you would like to join the waitlist, please enter your email address. You will receive an email with instructions on how to access the app when it becomes available to you.
|
||||
</p>
|
||||
<p class="my-2">
|
||||
If you aren't interested in joining the waitlist, that is okay too! 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>
|
||||
<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 col-xl-4 offset-xl-1">
|
||||
<WaitlistAstro />
|
||||
<div class="col-12 col-md-6 mb-2">
|
||||
<h2 class="mt-3">The Pokémon card tracker you actually want.</h2>
|
||||
<p class="mt-2">
|
||||
Browse real market prices and condition data across 70,000+ cards! No more
|
||||
juggling multiple tabs or guessing what your cards are worth.
|
||||
</p>
|
||||
<p class="my-2">
|
||||
We're now open to everyone. Create a free account to get started —
|
||||
collection and inventory management tools are coming soon as part of a
|
||||
premium plan.
|
||||
</p>
|
||||
<Show when="signed-in">
|
||||
<a href="/pokemon" class="btn btn-warning mt-2">Take me to the cards!</a>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 d-flex justify-content-end align-items-start mt-3">
|
||||
<div class="d-flex gap-3 mx-auto">
|
||||
<Show when="signed-out">
|
||||
<div class="card border p-5 w-100">
|
||||
<SignUpButton asChild mode="modal">
|
||||
<button class="btn btn-success w-100 mb-2">Create free account</button>
|
||||
</SignUpButton>
|
||||
<SignInButton asChild mode="modal">
|
||||
<p class="text-center text-secondary my-2">Already have an account?</p>
|
||||
<button class="btn btn-outline-light w-100">Sign in</button>
|
||||
</SignInButton>
|
||||
<p class="text-center h6 text-light mt-2 mb-0">Free to join!</p>
|
||||
</div>
|
||||
<GoogleOneTap />
|
||||
</Show>
|
||||
<Show when="signed-in">
|
||||
<div class="w-100">
|
||||
<SignOutButton asChild>
|
||||
<button class="btn btn-danger mt-2 ms-auto float-end">Sign Out</button>
|
||||
</SignOutButton>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer slot="footer" />
|
||||
|
||||
26
src/pages/myprices.astro
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import Layout from '../layouts/Main.astro';
|
||||
import NavItems from '../components/NavItems.astro';
|
||||
import NavBar from '../components/NavBar.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
|
||||
---
|
||||
<Layout title="Rigid's App Thing">
|
||||
<NavBar slot="navbar">
|
||||
<NavItems slot="navItems" />
|
||||
</NavBar>
|
||||
<div class="row mb-4" slot="page">
|
||||
<div class="col-12">
|
||||
<h1>Rigid's App Thing</h1>
|
||||
<p class="text-secondary">(working title)</p>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<!-- src/components/FileUploader.astro -->
|
||||
<form action="/api/upload" method="post" enctype="multipart/form-data">
|
||||
<input type="file" name="file" accept=".csv" required />
|
||||
<button type="submit">Upload CSV</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<Footer slot="footer" />
|
||||
</Layout>
|
||||
@@ -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,323 @@ function timeAgo(date: Date | null) {
|
||||
hour: 3600,
|
||||
minute: 60
|
||||
};
|
||||
|
||||
for (const [unit, value] of Object.entries(intervals)) {
|
||||
const count = Math.floor(seconds / value);
|
||||
if (count >= 1) return `${count} ${unit}${count > 1 ? "s" : ""} ago`;
|
||||
}
|
||||
|
||||
return "just now";
|
||||
}
|
||||
|
||||
// Get the most recent calculatedAt across all prices
|
||||
const calculatedAt = (() => {
|
||||
if (!card?.prices?.length) return null;
|
||||
|
||||
// Extract all valid calculatedAt timestamps
|
||||
const dates = card.prices
|
||||
.map(p => p.calculatedAt)
|
||||
.filter(d => d) // remove null/undefined
|
||||
.map(d => new Date(d));
|
||||
|
||||
.filter(d => d)
|
||||
.map(d => new Date(d!));
|
||||
if (!dates.length) return null;
|
||||
|
||||
// Return the most recent one
|
||||
return new Date(Math.max(...dates.map(d => d.getTime())));
|
||||
})();
|
||||
|
||||
// ── Spread-based volatility (high - low) / low ────────────────────────────
|
||||
// Log-return volatility was unreliable because marketPrice is a smoothed daily
|
||||
// value, not transaction-driven. The 30-day high/low spread is a more honest
|
||||
// proxy for price movement over the period.
|
||||
|
||||
const volatilityByCondition: Record<string, { label: string; spread: number }> = {};
|
||||
|
||||
for (const price of card?.prices ?? []) {
|
||||
const condition = price.condition;
|
||||
const low = Number(price.lowestPrice);
|
||||
const high = Number(price.highestPrice);
|
||||
const market = Number(price.marketPrice);
|
||||
|
||||
if (!low || !high || !market || market <= 0) {
|
||||
volatilityByCondition[condition] = { label: '—', spread: 0 };
|
||||
continue;
|
||||
}
|
||||
|
||||
const spread = (high - low) / market;
|
||||
|
||||
const label = spread >= 0.50 ? 'High'
|
||||
: spread >= 0.25 ? 'Medium'
|
||||
: 'Low';
|
||||
|
||||
volatilityByCondition[condition] = { label, spread: Math.round(spread * 100) / 100 };
|
||||
}
|
||||
|
||||
// ── Price history for chart ───────────────────────────────────────────────
|
||||
const cardSkus = card?.prices?.length
|
||||
? await db.select().from(skus).where(eq(skus.productId, card.productId))
|
||||
: [];
|
||||
|
||||
const skuIds = cardSkus.map(s => s.skuId);
|
||||
|
||||
const historyRows = skuIds.length
|
||||
? await db
|
||||
.select({
|
||||
skuId: priceHistory.skuId,
|
||||
calculatedAt: priceHistory.calculatedAt,
|
||||
marketPrice: priceHistory.marketPrice,
|
||||
condition: skus.condition,
|
||||
})
|
||||
.from(priceHistory)
|
||||
.innerJoin(skus, eq(priceHistory.skuId, skus.skuId))
|
||||
.where(inArray(priceHistory.skuId, skuIds))
|
||||
.orderBy(priceHistory.calculatedAt)
|
||||
: [];
|
||||
|
||||
const priceHistoryForChart = historyRows.map(row => ({
|
||||
condition: row.condition,
|
||||
calculatedAt: row.calculatedAt
|
||||
? new Date(row.calculatedAt).toISOString().split('T')[0]
|
||||
: null,
|
||||
marketPrice: row.marketPrice,
|
||||
})).filter(r => r.calculatedAt !== null);
|
||||
|
||||
const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
|
||||
|
||||
const conditionAttributes = (price: any) => {
|
||||
const volatility = (() => {
|
||||
const 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: '—', spread: 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.spread * 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">
|
||||
<div
|
||||
class="card-image-wrap rounded-4"
|
||||
data-energy={card?.energyType}
|
||||
data-rarity={card?.rarityName}
|
||||
data-variant={card?.variant}
|
||||
data-name={card?.productName}
|
||||
>
|
||||
<img
|
||||
src={`/static/cards/${card?.productId}.jpg`}
|
||||
class="card-image w-100 img-fluid rounded-4"
|
||||
alt={card?.productName}
|
||||
crossorigin="anonymous"
|
||||
onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow, .card-image-wrap')?.setAttribute('data-default','true')"
|
||||
onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="position-absolute top-50 start-0 d-inline"><FirstEditionIcon edition={card?.variant} /></span>
|
||||
<span class="position-absolute bottom-0 start-0 d-inline"><SetIcon set={card?.set?.setCode} /></span>
|
||||
<span class="position-absolute top-0 end-0 d-inline"><EnergyIcon energy={card?.energyType} /></span>
|
||||
<span class="rarity-icon-large position-absolute bottom-0 end-0 d-inline"><RarityIcon rarity={card?.rarityName} /></span>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between mt-2">
|
||||
<div class="text-secondary">{card?.set?.setCode}</div>
|
||||
<div class="text-secondary">Illus<span class="d-none d-lg-inline">trator</span>: {card?.artist}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs + price data column -->
|
||||
<div class="col-sm-12 col-md-7">
|
||||
<ul class="nav nav-tabs nav-fill border-0 me-3 mb-2" id="myTab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link nm active" id="nm-tab" data-bs-toggle="tab" data-bs-target="#nav-nm" type="button" role="tab" aria-controls="nav-nm" aria-selected="true">
|
||||
<span class="d-none d-xxl-inline">Near Mint</span><span class="d-xxl-none">NM</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link lp" id="lp-tab" data-bs-toggle="tab" data-bs-target="#nav-lp" type="button" role="tab" aria-controls="nav-lp" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Lightly Played</span><span class="d-xxl-none">LP</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link mp" id="mp-tab" data-bs-toggle="tab" data-bs-target="#nav-mp" type="button" role="tab" aria-controls="nav-mp" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Moderately Played</span><span class="d-xxl-none">MP</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link hp" id="hp-tab" data-bs-toggle="tab" data-bs-target="#nav-hp" type="button" role="tab" aria-controls="nav-hp" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Heavily Played</span><span class="d-xxl-none">HP</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link dmg" id="dmg-tab" data-bs-toggle="tab" data-bs-target="#nav-dmg" type="button" role="tab" aria-controls="nav-dmg" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Damaged</span><span class="d-xxl-none">DMG</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link vendor d-none" id="vendor-tab" data-bs-toggle="tab" data-bs-target="#nav-vendor" type="button" role="tab" aria-controls="nav-vendor" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Inventory</span><span class="d-xxl-none">+/-</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="myTabContent">
|
||||
{card?.prices.slice().sort((a, b) => conditionOrder.indexOf(a.condition) - conditionOrder.indexOf(b.condition)).map((price) => {
|
||||
const attributes = conditionAttributes(price);
|
||||
return (
|
||||
<div class={`tab-pane fade ${attributes?.label} ${attributes?.class ?? ''}`} id={`${attributes?.label}`} role="tabpanel" tabindex="0">
|
||||
<div class="d-flex flex-column gap-1">
|
||||
|
||||
<!-- Stat cards -->
|
||||
<div class="d-flex flex-fill flex-row gap-1">
|
||||
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
|
||||
<h6 class="mb-auto">Market Price</h6>
|
||||
<p class="mb-0 mt-1">${price.marketPrice}</p>
|
||||
</div>
|
||||
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
|
||||
<h6 class="mb-auto">Low Price <span class="small p text-secondary">(30 day)</span></h6>
|
||||
<p class="mb-0 mt-1">${price.lowestPrice}</p>
|
||||
</div>
|
||||
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
|
||||
<h6 class="mb-auto">High Price <span class="small p text-secondary">(30 day)</span></h6>
|
||||
<p class="mb-0 mt-1">${price.highestPrice}</p>
|
||||
</div>
|
||||
<div class={`alert rounded p-2 flex-fill d-flex flex-column mb-0 ${attributes?.volatilityClass}`}>
|
||||
<h6 class="mb-auto d-flex justify-content-between align-items-start">
|
||||
<span class="me-1">Volatility</span>
|
||||
<span
|
||||
class="volatility-info float-end mt-0"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
data-bs-container="body"
|
||||
data-bs-custom-class="volatility-popover"
|
||||
data-bs-trigger="hover focus click"
|
||||
data-bs-html="true"
|
||||
data-bs-title={`
|
||||
<div class='tooltip-heading fw-bold mb-1'>30-Day Price Spread</div>
|
||||
<div class='small'>
|
||||
<p class="mb-1">
|
||||
<strong>What this measures:</strong> how wide the gap between the 30-day low and high is,
|
||||
relative to the market price.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
A card with <strong>50%+ spread</strong> has seen significant price swings over the past month.
|
||||
</p>
|
||||
</div>
|
||||
`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 16 16"> <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/> <path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/> </svg>
|
||||
</span>
|
||||
</h6>
|
||||
<p class="mb-0 mt-1">{attributes?.volatility}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table only — chart is outside the tab panes -->
|
||||
<div class="w-100">
|
||||
<div class="alert alert-dark rounded p-2 mb-0 table-responsive d-none">
|
||||
<h6>Latest Verified Sales</h6>
|
||||
<table class="table table-sm mb-0">
|
||||
<caption class="small">Filtered to remove mismatched language variants</caption>
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td> </td><td> </td><td> </td></tr>
|
||||
<tr><td> </td><td> </td><td> </td></tr>
|
||||
<tr><td> </td><td> </td><td> </td></tr>
|
||||
<tr><td> </td><td> </td><td> </td></tr>
|
||||
<tr><td> </td><td> </td><td> </td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0"></div>
|
||||
</div>
|
||||
|
||||
<!-- Chart lives permanently outside tab-content so Bootstrap never hides it. -->
|
||||
<div class="d-block d-lg-flex gap-1 mt-1">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-dark rounded p-2 mb-0">
|
||||
<h6>Market Price History</h6>
|
||||
<div id="priceHistoryEmpty" class="d-none text-secondary text-center py-4">
|
||||
No sales data for the selected period/condition
|
||||
</div>
|
||||
<div class="position-relative" style="height: 200px;">
|
||||
<canvas
|
||||
id="priceHistoryChart"
|
||||
class="price-history-chart"
|
||||
data-card-id={card?.cardId}
|
||||
data-history={JSON.stringify(priceHistoryForChart)}>
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end mt-2" role="group" aria-label="Time range">
|
||||
<button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button>
|
||||
<button type="button" class="btn btn-dark price-range-btn" data-range="3m">3M</button>
|
||||
<button type="button" class="btn btn-dark price-range-btn" data-range="6m">6M</button>
|
||||
<button type="button" class="btn btn-dark price-range-btn" data-range="1y">1Y</button>
|
||||
<button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div 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>
|
||||
@@ -1,12 +1,15 @@
|
||||
---
|
||||
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",
|
||||
//"productLineName": "Product Line",
|
||||
"setName": "Set",
|
||||
"variant": "Variant",
|
||||
"rarityName": "Rarity",
|
||||
@@ -14,13 +17,88 @@ const facetFields:any = {
|
||||
"energyType": "Energy Type"
|
||||
}
|
||||
|
||||
// ── Allowed sort values ───────────────────────────────────────────────────
|
||||
const sortMap: Record<string, string> = {
|
||||
'releaseDate:desc,number:asc': '_text_match:asc,releaseDate:desc,number:asc',
|
||||
'releaseDate:asc,number:asc': '_text_match:asc,releaseDate:asc,number:asc',
|
||||
'marketPrice:desc': 'marketPrice:desc,releaseDate:desc,number:asc',
|
||||
'marketPrice:asc': 'marketPrice:asc,releaseDate:desc,number:asc',
|
||||
'number:asc': '_text_match:asc,number:asc',
|
||||
'number:desc': '_text_match:asc,number:desc',
|
||||
};
|
||||
const DEFAULT_SORT = '_text_match:asc,releaseDate:desc,number:asc';
|
||||
|
||||
// get the query from post request using form data
|
||||
const formData = await Astro.request.formData();
|
||||
const query = formData.get('q')?.toString() || '';
|
||||
const start = Number(formData.get('start')?.toString() || '0');
|
||||
const sortKey = formData.get('sort')?.toString() || '';
|
||||
const resolvedSort = sortMap[sortKey] ?? DEFAULT_SORT;
|
||||
|
||||
// ── Language filter ───────────────────────────────────────────────────────
|
||||
// Expects a `language` field on your card documents in Typesense.
|
||||
// Valid values: 'en', 'jp' — anything else (or 'all') means no filter.
|
||||
const language = formData.get('language')?.toString() || 'all';
|
||||
const languageFilter = language === 'en' ? " && productLineName:=`Pokemon`"
|
||||
: language === 'jp' ? " && productLineName:=`Pokemon Japan`"
|
||||
: '';
|
||||
|
||||
// ── Query alias expansion ─────────────────────────────────────────────────
|
||||
// Intercepts known shorthand queries that can't be handled by Typesense
|
||||
// synonyms alone (e.g. terms that need to match across multiple set names)
|
||||
// and rewrites them into a direct filter, clearing the query so it doesn't
|
||||
// also try to text-match against card names.
|
||||
|
||||
const ALIAS_FILTERS = [
|
||||
// ── Era / set groupings ───────────────────────────────────────────────
|
||||
{ re: /^(e-?reader|e reader)$/i, field: 'setName',
|
||||
values: ['Expedition Base Set', 'Aquapolis', 'Skyridge', 'Battle-e'] },
|
||||
|
||||
{ re: /^neo$/i, field: 'setName',
|
||||
values: ['Neo Genesis', 'Neo Discovery', 'Neo Revelation', 'Neo Destiny'] },
|
||||
|
||||
{ re: /^(wotc|wizards)$/i, field: 'setName',
|
||||
values: ['Base Set', 'Jungle', 'Fossil', 'Base Set 2', 'Team Rocket',
|
||||
'Gym Heroes', 'Gym Challenge', 'Neo Genesis', 'Neo Discovery',
|
||||
'Neo Revelation', 'Neo Destiny', 'Expedition Base Set',
|
||||
'Aquapolis', 'Skyridge', 'Battle-e'] },
|
||||
|
||||
{ re: /^(sun\s*(&|and)\s*moon|s(&|and)m|sm)$/i, field: 'setName',
|
||||
values: ['Sun & Moon', 'Guardians Rising', 'Burning Shadows', 'Crimson Invasion',
|
||||
'Ultra Prism', 'Forbidden Light', 'Celestial Storm', 'Dragon Majesty',
|
||||
'Lost Thunder', 'Team Up', 'Unbroken Bonds', 'Unified Minds',
|
||||
'Hidden Fates', 'Cosmic Eclipse', 'Detective Pikachu'] },
|
||||
|
||||
{ re: /^(sword\s*(&|and)\s*shield|s(&|and)s|swsh)$/i, field: 'setName',
|
||||
values: ['Sword & Shield', 'Rebel Clash', 'Darkness Ablaze', 'Vivid Voltage',
|
||||
'Battle Styles', 'Chilling Reign', 'Evolving Skies', 'Fusion Strike',
|
||||
'Brilliant Stars', 'Astral Radiance', 'Pokemon GO', 'Lost Origin',
|
||||
'Silver Tempest', 'Crown Zenith'] },
|
||||
|
||||
// ── Card type shorthands ──────────────────────────────────────────────
|
||||
{ re: /^trainers?$/i, field: 'cardType', values: ['Trainer'] },
|
||||
{ re: /^supporters?$/i, field: 'cardType', values: ['Supporter'] },
|
||||
{ re: /^stadiums?$/i, field: 'cardType', values: ['Stadium'] },
|
||||
{ re: /^items?$/i, field: 'cardType', values: ['Item'] },
|
||||
{ re: /^(energys?|energies)$/i, field: 'cardType', values: ['Energy'] },
|
||||
|
||||
// ── Rarity shorthands ─────────────────────────────────────────────────
|
||||
{ re: /^promos?$/i, field: 'rarityName', values: ['Promo'] },
|
||||
];
|
||||
|
||||
let resolvedQuery = query;
|
||||
let queryFilter = '';
|
||||
|
||||
for (const alias of ALIAS_FILTERS) {
|
||||
if (alias.re.test(query.trim())) {
|
||||
resolvedQuery = '';
|
||||
queryFilter = `${alias.field}:=[${alias.values.map(s => '`' + s + '`').join(',')}]`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const filters = Array.from(formData.entries())
|
||||
.filter(([key, value]) => key !== 'q' && key !== 'start')
|
||||
.filter(([key]) => key !== 'q' && key !== 'start' && key !== 'sort' && key !== 'language')
|
||||
.reduce((acc, [key, value]) => {
|
||||
if (!acc[key]) {
|
||||
acc[key] = [];
|
||||
@@ -34,27 +112,28 @@ const filterChecked = (field: string, value: string) => {
|
||||
};
|
||||
|
||||
const filterBy = Object.entries(filters).map(([field, values]) => {
|
||||
return `${field}:=[${values.join(',')}]`;
|
||||
return `${field}:=[${values.map(v => '`'+v+'`').join(',')}]`;
|
||||
}).join(' && ');
|
||||
|
||||
const facetFilter = (facet:string) => {
|
||||
const otherFilters = Object.entries(filters)
|
||||
.filter(([field]) => field !== facet)
|
||||
.map(([field, values]) => `${field}:=[${values.join(',')}]`)
|
||||
.map(([field, values]) => `${field}:=[${values.map(v => '`'+v+'`').join(',')}]`)
|
||||
.join(' && ');
|
||||
return `sealed:false${otherFilters ? ` && ${otherFilters}` : ''}`;
|
||||
// Language filter is always included so facet counts stay accurate
|
||||
return `sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${otherFilters ? ` && ${otherFilters}` : ''}`;
|
||||
};
|
||||
|
||||
|
||||
// primary search values (for cards)
|
||||
let searchArray = [{
|
||||
collection: 'cards',
|
||||
filter_by: `sealed:false${filterBy ? ` && ${filterBy}` : ''}`,
|
||||
filter_by: `sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${filterBy ? ` && ${filterBy}` : ''}`,
|
||||
per_page: 20,
|
||||
facet_by: '',
|
||||
max_facet_values: 0,
|
||||
page: Math.floor(start / 20) + 1,
|
||||
sort_by: '_text_match:asc, releaseDate:desc, number:asc',
|
||||
sort_by: resolvedSort,
|
||||
include_fields: '$skus(*)',
|
||||
}];
|
||||
|
||||
@@ -76,8 +155,11 @@ if (start === 0) {
|
||||
|
||||
const searchRequests = { searches: searchArray };
|
||||
const commonSearchParams = {
|
||||
q: query,
|
||||
query_by: 'productLineName,productName,setName,number,rarityName,Artist',
|
||||
q: resolvedQuery,
|
||||
query_by: 'content,setName,setCode,productName,Artist',
|
||||
query_by_weights: '10,6,8,9,8',
|
||||
num_typos: '2,1,0,1,2',
|
||||
prefix: 'true,true,false,false,false',
|
||||
};
|
||||
|
||||
// use typesense to search for cards matching the query and return the productIds of the results
|
||||
@@ -114,10 +196,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 null;
|
||||
|
||||
// Sort: checked items first, then alphabetically
|
||||
facet.counts = facet.counts.sort((a: any, b: any) => {
|
||||
const aChecked = filters[facet.field_name]?.includes(a.value) ?? false;
|
||||
const bChecked = filters[facet.field_name]?.includes(b.value) ?? false;
|
||||
if (aChecked && !bChecked) return -1;
|
||||
if (!aChecked && bChecked) return 1;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
return facet;
|
||||
}).filter(Boolean);
|
||||
|
||||
---
|
||||
|
||||
{(start === 0) &&
|
||||
|
||||
<div id="facetContainer" hx-swap-oob="true">
|
||||
@@ -144,22 +239,41 @@ 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="d-flex flex-fill align-items-center me-auto gap-2" hx-swap-oob="true">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Sort by</button>
|
||||
<ul class="dropdown-menu dropdown-menu-dark">
|
||||
<li><a class="dropdown-item sort-option" href="#" data-sort="releaseDate:desc,number:asc" data-label="Set: Newest to Oldest">Set: Newest to Oldest</a></li>
|
||||
<li><a class="dropdown-item sort-option" href="#" data-sort="releaseDate:asc,number:asc" data-label="Set: Oldest to Newest">Set: Oldest to Newest</a></li>
|
||||
<li><a class="dropdown-item sort-option" href="#" data-sort="marketPrice:desc" data-label="Price: High to Low">Price: High to Low</a></li>
|
||||
<li><a class="dropdown-item sort-option" href="#" data-sort="marketPrice:asc" data-label="Price: Low to High">Price: Low to High</a></li>
|
||||
<li><a class="dropdown-item sort-option" href="#" data-sort="number:asc" data-label="Card Number: Ascending">Card Number: Ascending</a></li>
|
||||
<li><a class="dropdown-item sort-option" href="#" data-sort="number:desc" data-label="Card Number: Descending">Card Number: Descending</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<span id="sortLabel" class="ms-1 text-secondary small">{sortKey ? ({"releaseDate:desc,number:asc":"Set: Newest to Oldest","releaseDate:asc,number:asc":"Set: Oldest to Newest","marketPrice:desc":"Price: High to Low","marketPrice:asc":"Price: Low to High","number:asc":"Card Number: Ascending","number:desc":"Card Number: Descending"}[sortKey] ?? '') : ''}</span>
|
||||
<div class="btn-group btn-group-sm ms-2 flex-shrink-0" role="group" aria-label="Language filter">
|
||||
<button type="button" class={`btn btn-dark language-btn${language === 'all' ? ' active' : ''}`} data-lang="all">All</button>
|
||||
<button type="button" class={`btn btn-dark language-btn${language === 'en' ? ' active' : ''}`} data-lang="en">EN</button>
|
||||
<button type="button" class={`btn btn-dark language-btn${language === 'jp' ? ' active' : ''}`} data-lang="jp">JP</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="totalResults" class="d-flex text-secondary small d-none align-items-center" hx-swap-oob="true">
|
||||
{totalHits} {totalHits === 1 ? ' result' : ' results'}
|
||||
</div>
|
||||
<div id="activeFilters" class="d-flex small ms-auto align-items-center" hx-swap-oob="true">
|
||||
{(Object.entries(filters).length > 0) &&
|
||||
<span class="me-1">Filtered by:</span>
|
||||
<span class="me-1 small">Filtered by:</span>
|
||||
<ul class="list-group list-group-horizontal">
|
||||
{Object.entries(filters).map(([filter, values]) => (
|
||||
values.map((value) => (
|
||||
<li data-facet={filter} data-value={value} class="list-group-item remove-filter">{value}</li>
|
||||
<li data-facet={filter} data-value={value} class="list-group-item small p-2 remove-filter">{value}</li>
|
||||
))
|
||||
))}
|
||||
</ul>
|
||||
<span class="ms-2"><button type="button" class="btn-close" aria-label="Clear all filters" id="clear-all-filters"></button></span>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
<script define:vars={{ totalHits, filters, facets }} is:inline>
|
||||
|
||||
// Filter the facet values to make things like Set easier to find
|
||||
@@ -203,36 +317,43 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
||||
}
|
||||
|
||||
{pokemon.length === 0 && (
|
||||
<div id="notfound" hx-swap-oob="true">
|
||||
Pokemon not found
|
||||
<div id="notfound" class="mt-4 h6" hx-swap-oob="true">
|
||||
No cards found! Please modify your search and try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pokemon.map((card:any) => (
|
||||
<div class="col">
|
||||
<div class="inventory-button position-relative float-end shadow-filter text-center d-none">
|
||||
<div class="inventory-label pt-2">+/-</div>
|
||||
</div>
|
||||
<div 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>
|
||||
<div class="row row-cols-5 gx-1 price-row mb-2">
|
||||
{conditionOrder.map((condition) => (
|
||||
<div class="col price-label ps-1">
|
||||
{ conditionShort(condition) }
|
||||
<br />{formatPrice(condition, card.skus)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="h5 my-0">{card.productName}</div>
|
||||
<div class="d-flex flex-row lh-1 mt-1 justify-content-between">
|
||||
<div class="text-secondary flex-grow-1 d-none d-md-flex">{card.setName}</div>
|
||||
<div class="text-secondary">{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>
|
||||
{pokemon.map((card: any, i: number) => (
|
||||
|
||||
<div class="col equal-height-col">
|
||||
<div class="inventory-button position-relative float-end shadow-filter text-center d-none">
|
||||
<div class="inventory-label pt-2">+/-</div>
|
||||
</div>
|
||||
<div class="card-trigger position-relative" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});">
|
||||
<div class="image-grow rounded-4 card-image h-100" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}>
|
||||
<img src={`/static/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 w-100" onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow')?.setAttribute('data-default','true')"/>
|
||||
<span class="position-absolute top-50 start-0 d-inline medium-icon"><FirstEditionIcon edition={card?.variant} /></span>
|
||||
<div class="holo-shine"></div>
|
||||
<div class="holo-glare"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-cols-5 gx-1 price-row mb-2">
|
||||
{conditionOrder.map((condition) => (
|
||||
<div class="col price-label ps-1">
|
||||
{conditionShort(condition)}
|
||||
<br />{formatPrice(condition, card.skus)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="h5 my-0">{card.productName}</div>
|
||||
<div class="d-flex flex-row lh-1 mt-1 justify-content-between">
|
||||
<div class="text-secondary flex-grow-1"><span class="d-none d-lg-flex">{card.setName}</span><span class="d-flex d-lg-none">{card.setCode}</span></div>
|
||||
<div class="text-body-tertiary">{card.number}</div>
|
||||
<span class="ps-2 small-icon"><RarityIcon rarity={card.rarityName} /></span>
|
||||
</div>
|
||||
<div class="text-body-tertiary">{card.variant}</div>
|
||||
<span class="d-none">{card.productId}</span>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
{start + 20 < totalHits &&
|
||||
<div hx-post="/partials/cards" hx-trigger="revealed" hx-include="#searchform" hx-target="#cardGrid" hx-swap="beforeend" hx-on--after-request="afterUpdate(event)">
|
||||
|
||||
71
src/pages/partials/price-history.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
1
src/svg/edition/firstEdition.svg
Normal 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 |
1
src/svg/set/perfect_order.svg
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"include": [".astro/types.d.ts", "src/**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
|
||||