Compare commits
37 Commits
091aa72f23
...
feat/holog
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6299c07b87 | ||
| 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 |
@@ -11,6 +11,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
server: {
|
||||||
|
allowedHosts: true,
|
||||||
|
},
|
||||||
adapter: node({ mode: "standalone", checkOrigin: false }),
|
adapter: node({ mode: "standalone", checkOrigin: false }),
|
||||||
output: "server",
|
output: "server",
|
||||||
security: {
|
security: {
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import { defineConfig } from 'drizzle-kit';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
out: './drizzle', // Directory for migration files
|
out: './drizzle', // Directory for migration files
|
||||||
schema: './src/db/schema.ts', // Path to your schema file
|
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: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL!, // Use the URL from your .env file
|
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": {
|
"scripts": {
|
||||||
"dev": "astro dev --host 0.0.0.0",
|
"dev": "astro dev --host 0.0.0.0",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview --host 0.0.0.0",
|
||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^9.5.4",
|
"@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",
|
"@clerk/themes": "^2.4.55",
|
||||||
"astro": "^5.17.1",
|
"astro": "^5.17.1",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"csv": "^6.4.1",
|
||||||
"dotenv": "^17.2.4",
|
"dotenv": "^17.2.4",
|
||||||
"drizzle-orm": "^1.0.0-beta.15-859cf75",
|
"drizzle-orm": "^1.0.0-beta.15-859cf75",
|
||||||
"mysql2": "^3.16.3",
|
"pg": "^8.20.0",
|
||||||
"sass": "^1.97.3",
|
"sass": "^1.97.3",
|
||||||
"typesense": "^3.0.1"
|
"typesense": "^3.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bootstrap": "^5.2.10",
|
"@types/bootstrap": "^5.2.10",
|
||||||
"@types/node": "^25.2.1",
|
"@types/node": "^25.2.1",
|
||||||
|
"@types/pg": "^8.18.0",
|
||||||
"drizzle-kit": "^1.0.0-beta.15-859cf75",
|
"drizzle-kit": "^1.0.0-beta.15-859cf75",
|
||||||
"typescript": "^5.9.3"
|
"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(), 'public', 'cards', `${card.productId}.jpg`);
|
||||||
|
try {
|
||||||
|
await fs.access(imagePath);
|
||||||
|
} catch (err) {
|
||||||
|
missingImages.push(`${card.productId}\t${card.setId}\t${card.productName}\t${card.number}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missingImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingImages = await findMissingImages();
|
||||||
|
//console.log("Missing Images:", missingImages.join('\n'));
|
||||||
|
|
||||||
|
fs.writeFile(path.join(process.cwd(), 'missing-images.log'), missingImages.join('\n'));
|
||||||
|
|
||||||
|
await ClosePool();
|
||||||
177
scripts/pokemon-helper.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
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: 'cardType', type: 'string', facet: true },
|
||||||
|
{ name: 'energyType', type: 'string', facet: true },
|
||||||
|
{ name: 'number', type: 'string', sort: true },
|
||||||
|
{ name: 'Artist', type: 'string' },
|
||||||
|
{ name: 'sealed', type: 'bool' },
|
||||||
|
{ name: 'releaseDate', type: 'int32' },
|
||||||
|
{ name: 'marketPrice', type: 'int32', optional: true, sort: true },
|
||||||
|
{ name: 'content', type: 'string', token_separators: ['/'] },
|
||||||
|
{ name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
|
||||||
|
],
|
||||||
|
});
|
||||||
|
console.log(chalk.green('Collection "cards" created successfully.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete and recreate the 'skus' index
|
||||||
|
export const createSkuCollection = async () => {
|
||||||
|
try {
|
||||||
|
await client.collections('skus').delete();
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore error, just means collection doesn't exist
|
||||||
|
}
|
||||||
|
await client.collections().create({
|
||||||
|
name: 'skus',
|
||||||
|
fields: [
|
||||||
|
{ name: 'id', type: 'string' },
|
||||||
|
{ name: 'condition', type: 'string' },
|
||||||
|
{ name: 'highestPrice', type: 'int32', optional: true },
|
||||||
|
{ name: 'lowestPrice', type: 'int32', optional: true },
|
||||||
|
{ name: 'marketPrice', type: 'int32', optional: true },
|
||||||
|
]
|
||||||
|
});
|
||||||
|
console.log(chalk.green('Collection "skus" created successfully.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const upsertCardCollection = async (db:DBInstance) => {
|
||||||
|
const pokemon = await db.query.cards.findMany({
|
||||||
|
with: { set: true, tcgdata: true, prices: true },
|
||||||
|
});
|
||||||
|
await client.collections('cards').documents().import(pokemon.map(card => {
|
||||||
|
const marketPrice = card.tcgdata?.marketPrice ? DollarToInt(card.tcgdata.marketPrice) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: card.cardId.toString(),
|
||||||
|
cardId: card.cardId,
|
||||||
|
productId: card.productId,
|
||||||
|
variant: card.variant,
|
||||||
|
productName: card.productName,
|
||||||
|
productLineName: card.productLineName,
|
||||||
|
rarityName: card.rarityName,
|
||||||
|
setName: card.set?.setName || "",
|
||||||
|
cardType: card.cardType || "",
|
||||||
|
energyType: card.energyType || "",
|
||||||
|
number: card.number,
|
||||||
|
Artist: card.artist || "",
|
||||||
|
sealed: card.sealed,
|
||||||
|
content: [card.productName, card.productLineName, card.set?.setName || "", card.number, card.rarityName, card.artist || ""].join(' '),
|
||||||
|
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
|
||||||
|
...(marketPrice !== null && { marketPrice }),
|
||||||
|
sku_id: card.prices.map(price => price.skuId.toString())
|
||||||
|
};
|
||||||
|
}), { action: 'upsert' });
|
||||||
|
console.log(chalk.green('Collection "cards" indexed successfully.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const upsertSkuCollection = async (db:DBInstance) => {
|
||||||
|
const skus = await db.query.skus.findMany();
|
||||||
|
await client.collections('skus').documents().import(skus.map(sku => ({
|
||||||
|
id: sku.skuId.toString(),
|
||||||
|
condition: sku.condition,
|
||||||
|
highestPrice: DollarToInt(sku.highestPrice),
|
||||||
|
lowestPrice: DollarToInt(sku.lowestPrice),
|
||||||
|
marketPrice: DollarToInt(sku.marketPrice),
|
||||||
|
})), { action: 'upsert' });
|
||||||
|
console.log(chalk.green('Collection "skus" indexed successfully.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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 'dotenv/config';
|
||||||
import * as schema from '../src/db/schema.ts';
|
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 fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
|
import * as helper from './pokemon-helper.ts';
|
||||||
//import util from 'util';
|
//import util from 'util';
|
||||||
|
|
||||||
|
|
||||||
async function syncTcgplayer() {
|
async function syncTcgplayer(cardSets:string[] = []) {
|
||||||
|
|
||||||
const productLines = [ "pokemon", "pokemon-japan" ];
|
const productLines = [ "pokemon", "pokemon-japan" ];
|
||||||
|
|
||||||
@@ -29,44 +30,21 @@ async function syncTcgplayer() {
|
|||||||
|
|
||||||
const setNames = data.results[0].aggregations.setName;
|
const setNames = data.results[0].aggregations.setName;
|
||||||
for (const setName of setNames) {
|
for (const setName of setNames) {
|
||||||
|
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}"...`));
|
console.log(chalk.blue(`Syncing product line "${productLine}" with setName "${setName.urlValue}"...`));
|
||||||
await syncProductLine(productLine, "setName", setName.urlValue);
|
await syncProductLine(productLine, "setName", setName.urlValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
console.log(chalk.green('✓ All TCGPlayer data synchronized successfully!'));
|
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) {
|
async function syncProductLine(productLine: string, field: string, fieldValue: string) {
|
||||||
let start = 0;
|
let start = 0;
|
||||||
@@ -130,10 +108,10 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
|||||||
|
|
||||||
for (const item of data.results[0].results) {
|
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)
|
// Check if productId already exists and skip if it does (to avoid hitting the API too much)
|
||||||
// if (allProductIds.has(item.productId)) {
|
if (allProductIds.size > 0 && allProductIds.has(item.productId)) {
|
||||||
// continue;
|
continue;
|
||||||
// }
|
}
|
||||||
|
|
||||||
console.log(chalk.blue(` - ${item.productName} (ID: ${item.productId})`));
|
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,
|
cardTypeB: item.customAttributes.cardTypeB || null,
|
||||||
energyType: detailData.customAttributes.energyType?.[0] || null,
|
energyType: detailData.customAttributes.energyType?.[0] || null,
|
||||||
flavorText: detailData.customAttributes.flavorText || null,
|
flavorText: detailData.customAttributes.flavorText || null,
|
||||||
hp: getNumberOrNull(item.customAttributes.hp),
|
hp: helper.GetNumberOrNull(item.customAttributes.hp),
|
||||||
number: detailData.customAttributes.number || '',
|
number: detailData.customAttributes.number || '',
|
||||||
releaseDate: detailData.customAttributes.releaseDate ? new Date(detailData.customAttributes.releaseDate) : null,
|
releaseDate: detailData.customAttributes.releaseDate ? new Date(detailData.customAttributes.releaseDate) : null,
|
||||||
resistance: item.customAttributes.resistance || null,
|
resistance: item.customAttributes.resistance || null,
|
||||||
@@ -184,8 +162,9 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
|||||||
maxFulfillableQuantity: detailData.maxFulfillableQuantity,
|
maxFulfillableQuantity: detailData.maxFulfillableQuantity,
|
||||||
medianPrice: detailData.medianPrice,
|
medianPrice: detailData.medianPrice,
|
||||||
totalListings: item.totalListings,
|
totalListings: item.totalListings,
|
||||||
Artist: detailData.formattedAttributes.Artist || null,
|
artist: detailData.formattedAttributes.Artist || null,
|
||||||
}).onDuplicateKeyUpdate({
|
}).onConflictDoUpdate({
|
||||||
|
target: schema.tcgcards.productId,
|
||||||
set: {
|
set: {
|
||||||
productName: detailData.productName,
|
productName: detailData.productName,
|
||||||
//productName: cleanProductName(item.productName),
|
//productName: cleanProductName(item.productName),
|
||||||
@@ -208,7 +187,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
|||||||
cardTypeB: item.customAttributes.cardTypeB || null,
|
cardTypeB: item.customAttributes.cardTypeB || null,
|
||||||
energyType: detailData.customAttributes.energyType?.[0] || null,
|
energyType: detailData.customAttributes.energyType?.[0] || null,
|
||||||
flavorText: detailData.customAttributes.flavorText || null,
|
flavorText: detailData.customAttributes.flavorText || null,
|
||||||
hp: getNumberOrNull(item.customAttributes.hp),
|
hp: helper.GetNumberOrNull(item.customAttributes.hp),
|
||||||
number: detailData.customAttributes.number || '',
|
number: detailData.customAttributes.number || '',
|
||||||
releaseDate: detailData.customAttributes.releaseDate ? new Date(detailData.customAttributes.releaseDate) : null,
|
releaseDate: detailData.customAttributes.releaseDate ? new Date(detailData.customAttributes.releaseDate) : null,
|
||||||
resistance: item.customAttributes.resistance || null,
|
resistance: item.customAttributes.resistance || null,
|
||||||
@@ -221,18 +200,21 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
|||||||
maxFulfillableQuantity: detailData.maxFulfillableQuantity,
|
maxFulfillableQuantity: detailData.maxFulfillableQuantity,
|
||||||
medianPrice: detailData.medianPrice,
|
medianPrice: detailData.medianPrice,
|
||||||
totalListings: item.totalListings,
|
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...
|
// set is...
|
||||||
await db.insert(schema.sets).values({
|
await db.insert(schema.sets).values({
|
||||||
setId: detailData.setId,
|
setId: detailData.setId,
|
||||||
setCode: detailData.setCode,
|
setCode: detailData.setCode,
|
||||||
setName: detailData.setName,
|
setName: detailData.setName,
|
||||||
setUrlName: detailData.setUrlName,
|
setUrlName: detailData.setUrlName,
|
||||||
}).onDuplicateKeyUpdate({
|
}).onConflictDoUpdate({
|
||||||
|
target: schema.sets.setId,
|
||||||
set: {
|
set: {
|
||||||
setCode: detailData.setCode,
|
setCode: detailData.setCode,
|
||||||
setName: detailData.setName,
|
setName: detailData.setName,
|
||||||
@@ -249,7 +231,8 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
|||||||
condition: skuItem.condition,
|
condition: skuItem.condition,
|
||||||
language: skuItem.language,
|
language: skuItem.language,
|
||||||
variant: skuItem.variant,
|
variant: skuItem.variant,
|
||||||
}).onDuplicateKeyUpdate({
|
}).onConflictDoUpdate({
|
||||||
|
target: schema.skus.skuId,
|
||||||
set: {
|
set: {
|
||||||
condition: skuItem.condition,
|
condition: skuItem.condition,
|
||||||
language: skuItem.language,
|
language: skuItem.language,
|
||||||
@@ -260,7 +243,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
|||||||
|
|
||||||
// get image if it doesn't already exist
|
// get image if it doesn't already exist
|
||||||
const imagePath = path.join(process.cwd(), 'public', 'cards', `${item.productId}.jpg`);
|
const imagePath = path.join(process.cwd(), 'public', 'cards', `${item.productId}.jpg`);
|
||||||
if (!await fileExists(imagePath)) {
|
if (!await helper.FileExists(imagePath)) {
|
||||||
const imageResponse = await fetch(`https://tcgplayer-cdn.tcgplayer.com/product/${item.productId}_in_1000x1000.jpg`);
|
const imageResponse = await fetch(`https://tcgplayer-cdn.tcgplayer.com/product/${item.productId}_in_1000x1000.jpg`);
|
||||||
if (imageResponse.ok) {
|
if (imageResponse.ok) {
|
||||||
const buffer = await imageResponse.arrayBuffer();
|
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
|
// 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
|
// clear the log file
|
||||||
await fs.rm('missing_images.log', { force: true });
|
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();
|
await syncTcgplayer();
|
||||||
await poolConnection.end();
|
}
|
||||||
|
else {
|
||||||
|
await syncTcgplayer(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the card table with new/updated variants
|
||||||
|
await helper.UpdateVariants(db);
|
||||||
|
|
||||||
|
// index the card updates
|
||||||
|
helper.upsertCardCollection(db);
|
||||||
|
|
||||||
|
await ClosePool();
|
||||||
|
|||||||
@@ -1,132 +1,11 @@
|
|||||||
import { Client } from 'typesense';
|
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { db, poolConnection } from '../src/db/index.ts';
|
import { db, ClosePool } from '../src/db/index.ts';
|
||||||
import { client } from '../src/db/typesense.ts';
|
import * as Indexing from './pokemon-helper.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' },
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function preloadSearchIndex() {
|
//await Indexing.createCardCollection();
|
||||||
const pokemon = await db.query.cards.findMany({
|
//await Indexing.createSkuCollection();
|
||||||
with: { set: true, tcgdata: true, prices: true },
|
await Indexing.upsertCardCollection(db);
|
||||||
});
|
await Indexing.upsertSkuCollection(db);
|
||||||
|
await ClosePool();
|
||||||
// Ensure the collection exists before importing documents
|
console.log(chalk.green('Pokémon reindex complete.'));
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,30 +1,23 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
import chalk from 'chalk';
|
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 { sql, inArray, eq } from 'drizzle-orm';
|
||||||
import { skus, processingSkus } from '../src/db/schema.ts';
|
import { skus, processingSkus, priceHistory, salesHistory } from '../src/db/schema.ts';
|
||||||
import { client } from '../src/db/typesense.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() {
|
async function resetProcessingTable() {
|
||||||
// Use sql.raw to execute the TRUNCATE TABLE statement
|
// 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));
|
await db.insert(processingSkus).select(db.select({skuId: skus.skuId}).from(skus));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncPrices() {
|
async function syncPrices() {
|
||||||
const batchSize = 1000;
|
const batchSize = 1000;
|
||||||
// const skuIndex = client.collections('skus');
|
// const skuIndex = client.collections('skus');
|
||||||
|
const updatedCards = new Set<number>();
|
||||||
|
|
||||||
await resetProcessingTable();
|
await resetProcessingTable();
|
||||||
console.log(chalk.green('Processing table reset and populated with current SKUs.'));
|
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}`));
|
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 {
|
const skuUpdates = skuData.map((sku: any) => { return {
|
||||||
skuId: sku.skuId,
|
skuId: sku.skuId,
|
||||||
cardId: 0,
|
cardId: 0,
|
||||||
@@ -72,41 +74,90 @@ async function syncPrices() {
|
|||||||
marketPrice: sku.marketPrice,
|
marketPrice: sku.marketPrice,
|
||||||
priceCount: null,
|
priceCount: null,
|
||||||
}});
|
}});
|
||||||
await db.insert(skus).values(skuUpdates).onDuplicateKeyUpdate({
|
const skuRows = await db.insert(skus).values(skuUpdates).onConflictDoUpdate({
|
||||||
|
target: skus.skuId,
|
||||||
set: {
|
set: {
|
||||||
calculatedAt: sql`values(${skus.calculatedAt})`,
|
calculatedAt: sql.raw(`excluded.${toSnakeCase(skus.calculatedAt.name)}`),
|
||||||
highestPrice: sql`values(${skus.highestPrice})`,
|
highestPrice: sql.raw(`excluded.${toSnakeCase(skus.highestPrice.name)}`),
|
||||||
lowestPrice: sql`values(${skus.lowestPrice})`,
|
lowestPrice: sql.raw(`excluded.${toSnakeCase(skus.lowestPrice.name)}`),
|
||||||
marketPrice: sql`values(${skus.marketPrice})`,
|
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
|
// remove skus from the 'working' processingSkus table
|
||||||
await db.delete(processingSkus).where(inArray(processingSkus.skuId, skuIds));
|
await db.delete(processingSkus).where(inArray(processingSkus.skuId, skuIds));
|
||||||
|
|
||||||
// be nice to the API and not send too many requests in a short time
|
// 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 updateLatestSales = async (updatedCards: Set<number>) => {
|
||||||
const skus = await db.query.skus.findMany();
|
for (const productId of updatedCards.values()) {
|
||||||
|
console.log(`Getting sale history for ${productId}`)
|
||||||
await client.collections('skus').documents().import(skus.map(sku => ({
|
const salesResponse = await fetch(`https://mpapi.tcgplayer.com/v2/product/${productId}/latestsales`,{
|
||||||
id: sku.skuId.toString(),
|
method: 'POST',
|
||||||
condition: sku.condition,
|
headers: {
|
||||||
highestPrice: DollarToInt(sku.highestPrice),
|
'Content-Type': 'application/json',
|
||||||
lowestPrice: DollarToInt(sku.lowestPrice),
|
'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'
|
||||||
marketPrice: DollarToInt(sku.marketPrice),
|
},
|
||||||
})), { action: 'upsert' });
|
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();
|
const start = Date.now();
|
||||||
await syncPrices();
|
const updatedCards = await syncPrices();
|
||||||
await indexPrices();
|
await helper.upsertSkuCollection(db);
|
||||||
await poolConnection.end();
|
//console.log(updatedCards);
|
||||||
|
//console.log(updatedCards.size);
|
||||||
|
//await updateLatestSales(updatedCards);
|
||||||
|
await ClosePool();
|
||||||
const end = Date.now();
|
const end = Date.now();
|
||||||
const duration = (end - start) / 1000;
|
const duration = (end - start) / 1000;
|
||||||
console.log(chalk.green(`Price sync completed in ${duration.toFixed(2)} seconds.`));
|
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/alert';
|
||||||
@import 'bootstrap/scss/badge';
|
@import 'bootstrap/scss/badge';
|
||||||
// @import 'bootstrap/scss/breadcrumb';
|
// @import 'bootstrap/scss/breadcrumb';
|
||||||
// @import 'bootstrap/scss/button-group';
|
@import 'bootstrap/scss/button-group';
|
||||||
@import 'bootstrap/scss/buttons';
|
@import 'bootstrap/scss/buttons';
|
||||||
@import 'bootstrap/scss/card';
|
@import 'bootstrap/scss/card';
|
||||||
// @import 'bootstrap/scss/carousel';
|
// @import 'bootstrap/scss/carousel';
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
// @import 'bootstrap/scss/spinners';
|
// @import 'bootstrap/scss/spinners';
|
||||||
@import 'bootstrap/scss/tables';
|
@import 'bootstrap/scss/tables';
|
||||||
@import 'bootstrap/scss/toasts';
|
@import 'bootstrap/scss/toasts';
|
||||||
// @import 'bootstrap/scss/tooltip';
|
@import 'bootstrap/scss/tooltip';
|
||||||
@import 'bootstrap/scss/transitions';
|
@import 'bootstrap/scss/transitions';
|
||||||
|
|
||||||
// Optional helpers
|
// Optional helpers
|
||||||
|
|||||||
2115
src/assets/css/_card.scss
Normal file
685
src/assets/css/_holofoil-integration.scss
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// HOLOFOIL INTEGRATION
|
||||||
|
// =============================================================================
|
||||||
|
//
|
||||||
|
// Three effect zones, determined by rarity and variant:
|
||||||
|
//
|
||||||
|
// NONE — no effect at all
|
||||||
|
// variant = Normal (or no recognised rarity/variant)
|
||||||
|
//
|
||||||
|
// INVERSE — effect on borders only (everything except the art window)
|
||||||
|
// variant = Reverse Holofoil
|
||||||
|
// rarity = Prism Rare
|
||||||
|
//
|
||||||
|
// ART WINDOW — effect clipped to the artwork area only
|
||||||
|
// rarity = Rare | Amazing Rare | Classic Collection | Holo Rare
|
||||||
|
// variant = Holofoil | 1st Edition Holofoil
|
||||||
|
//
|
||||||
|
// FULL CARD — effect over the entire card
|
||||||
|
// rarity = Ultra Rare | Character Rare | Illustration Rare |
|
||||||
|
// Special Illustration Rare | Double Rare | Hyper Rare |
|
||||||
|
// Mega Rare | Mega Attack Rare | ACE Spec Rare | ACE Rare |
|
||||||
|
// Art Rare | Special Art Rare | Black White Rare |
|
||||||
|
// Character Super Rare | Mega Ultra Rare | Rare BREAK |
|
||||||
|
// Secret Rare | Shiny Holo Rare | Shiny Rare |
|
||||||
|
// Shiny Secret Rare | Shiny Ultra Rare
|
||||||
|
//
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 1. CSS CUSTOM PROPERTIES — set on every wrapper element
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.image-grow,
|
||||||
|
.card-image-wrap {
|
||||||
|
|
||||||
|
// Pointer tracking — updated by holofoil-init.js on mousemove
|
||||||
|
--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-opacity: 0;
|
||||||
|
--card-scale: 1;
|
||||||
|
|
||||||
|
// Card geometry — matches Bootstrap's rounded-4 (--bs-border-radius-xl)
|
||||||
|
--card-radius: var(--bs-border-radius-xl, 0.375rem);
|
||||||
|
|
||||||
|
// Art window clip — original poke-holo values, correct for standard TCG card scans
|
||||||
|
// inset(top right bottom left): top=9.85%, sides=8%, bottom=52.85% (art bottom at 47.15%)
|
||||||
|
--clip-art: inset(9.85% 8% 52.85% 8%);
|
||||||
|
|
||||||
|
// Sunpillar palette
|
||||||
|
--sunpillar-1: hsl(2, 100%, 73%);
|
||||||
|
--sunpillar-2: hsl(53, 100%, 69%);
|
||||||
|
--sunpillar-3: hsl(93, 100%, 69%);
|
||||||
|
--sunpillar-4: hsl(176, 100%, 76%);
|
||||||
|
--sunpillar-5: hsl(228, 100%, 74%);
|
||||||
|
--sunpillar-6: hsl(283, 100%, 73%);
|
||||||
|
--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);
|
||||||
|
|
||||||
|
// Colour tokens
|
||||||
|
--red: #f80e35;
|
||||||
|
--yellow: #eedf10;
|
||||||
|
--green: #21e985;
|
||||||
|
--blue: #0dbde9;
|
||||||
|
--violet: #c929f1;
|
||||||
|
|
||||||
|
// Glow
|
||||||
|
--card-glow: hsl(175, 100%, 90%);
|
||||||
|
|
||||||
|
// Texture assets
|
||||||
|
--grain: url('/holofoils/grain.webp');
|
||||||
|
--glitter: url('/holofoils/glitter.png');
|
||||||
|
--glittersize: 25%;
|
||||||
|
--foil: none;
|
||||||
|
|
||||||
|
// Energy glow overrides
|
||||||
|
&[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%); }
|
||||||
|
|
||||||
|
// Hover activates opacity; JS updates pointer vars
|
||||||
|
&:hover,
|
||||||
|
&[data-holo-active] { --card-opacity: 0.2; }
|
||||||
|
|
||||||
|
display: block; // ensure wrapper is a block-level containing block
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 2. HOLO-SHINE AND HOLO-GLARE BASE STRUCTURE
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.holo-shine,
|
||||||
|
.holo-glare {
|
||||||
|
pointer-events: none;
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
// NO overflow:hidden — it interferes with clip-path on the element itself
|
||||||
|
will-change: transform, opacity, background-image, background-size,
|
||||||
|
background-position, background-blend-mode, filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The img inside has mb-2 but the wrapper already has the right size from
|
||||||
|
// aspect-ratio on .card-image — zero the margin so img fills wrapper flush.
|
||||||
|
.image-grow > img,
|
||||||
|
.card-image-wrap > img {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holo-shine {
|
||||||
|
z-index: 3;
|
||||||
|
mix-blend-mode: color-dodge;
|
||||||
|
opacity: var(--card-opacity);
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
// Sunpillar palette shift for ::before depth layer
|
||||||
|
--sunpillar-clr-1: var(--sunpillar-5);
|
||||||
|
--sunpillar-clr-2: var(--sunpillar-6);
|
||||||
|
--sunpillar-clr-3: var(--sunpillar-1);
|
||||||
|
--sunpillar-clr-4: var(--sunpillar-2);
|
||||||
|
--sunpillar-clr-5: var(--sunpillar-3);
|
||||||
|
--sunpillar-clr-6: var(--sunpillar-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
// Second palette shift for uppermost pseudo layer
|
||||||
|
--sunpillar-clr-1: var(--sunpillar-6);
|
||||||
|
--sunpillar-clr-2: var(--sunpillar-1);
|
||||||
|
--sunpillar-clr-3: var(--sunpillar-2);
|
||||||
|
--sunpillar-clr-4: var(--sunpillar-3);
|
||||||
|
--sunpillar-clr-5: var(--sunpillar-4);
|
||||||
|
--sunpillar-clr-6: var(--sunpillar-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.holo-glare {
|
||||||
|
z-index: 4;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
opacity: var(--card-opacity);
|
||||||
|
background-image: radial-gradient(
|
||||||
|
farthest-corner circle at var(--pointer-x) var(--pointer-y),
|
||||||
|
hsla(0, 0%, 100%, 0.8) 10%,
|
||||||
|
hsla(0, 0%, 100%, 0.65) 20%,
|
||||||
|
hsla(0, 0%, 0%, 0.5) 90%
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grain texture on ::before — soft-light blend adds physical substrate feel
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: var(--grain);
|
||||||
|
background-size: 33%;
|
||||||
|
background-repeat: repeat;
|
||||||
|
mix-blend-mode: soft-light;
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glitter texture on ::after — overlay blend adds sparkle points
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: var(--glitter);
|
||||||
|
background-size: var(--glittersize) var(--glittersize);
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-position:
|
||||||
|
calc(50% - ((5px * 2) * var(--pointer-from-left)) + 5px)
|
||||||
|
calc(50% - ((5px * 2) * var(--pointer-from-top)) + 5px);
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
opacity: calc(var(--card-opacity) * 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 3. ZONE HELPERS — reusable effect mixin
|
||||||
|
// The standard prismatic effect, applied at different clip regions below.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Standard shine background (used by ART WINDOW and FULL CARD zones)
|
||||||
|
@mixin prismatic-shine {
|
||||||
|
background-image:
|
||||||
|
var(--grain),
|
||||||
|
repeating-linear-gradient(110deg,
|
||||||
|
var(--violet), var(--blue), var(--green), var(--yellow), var(--red),
|
||||||
|
var(--violet), var(--blue), var(--green), var(--yellow), var(--red),
|
||||||
|
var(--violet), var(--blue), var(--green), var(--yellow), var(--red)
|
||||||
|
);
|
||||||
|
background-position:
|
||||||
|
center center,
|
||||||
|
calc(((50% - var(--background-x)) * 2.6) + 50%)
|
||||||
|
calc(((50% - var(--background-y)) * 3.5) + 50%);
|
||||||
|
background-size: 33%, 400% 400%;
|
||||||
|
background-repeat: repeat, no-repeat;
|
||||||
|
background-blend-mode: soft-light, normal;
|
||||||
|
filter: brightness(.8) contrast(.85) saturate(.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin prismatic-glare {
|
||||||
|
opacity: calc(var(--card-opacity) * 0.4);
|
||||||
|
filter: brightness(0.8) contrast(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 4. ZONE 0 — NORMAL: no effect
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.image-grow[data-variant="Normal" i],
|
||||||
|
.card-image-wrap[data-variant="Normal" i] {
|
||||||
|
.holo-shine,
|
||||||
|
.holo-glare { display: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 5. ZONE 1 — ART WINDOW EFFECT
|
||||||
|
// rarity: Rare, Amazing Rare, Classic Collection, Holo Rare
|
||||||
|
// variant: Holofoil, 1st Edition Holofoil
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.image-grow[data-rarity="Rare" i]:not([data-variant="Reverse Holofoil" i]):not([data-variant="Normal" i]),
|
||||||
|
.card-image-wrap[data-rarity="Rare" i]:not([data-variant="Reverse Holofoil" i]):not([data-variant="Normal" i]),
|
||||||
|
.image-grow[data-rarity="Amazing Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Amazing Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Classic Collection" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Classic Collection" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-variant="1st Edition Holofoil" i],
|
||||||
|
.card-image-wrap[data-variant="1st Edition Holofoil" i] {
|
||||||
|
|
||||||
|
.holo-shine {
|
||||||
|
clip-path: var(--clip-art);
|
||||||
|
@include prismatic-shine;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holo-glare {
|
||||||
|
clip-path: var(--clip-art);
|
||||||
|
@include prismatic-glare;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 5b. ZONE 1 BORDER ADDITION — Holofoil + 1st Edition Holofoil
|
||||||
|
//
|
||||||
|
// Real holofoil cards have the foil stamp on both the art window AND the card
|
||||||
|
// border. The element carries the art window clip; ::before carries the border
|
||||||
|
// clip via the same zero-width tunnel polygon as Zone 3.
|
||||||
|
// ::before inherits background-image/size/position from the parent via inherit.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.image-grow[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-variant="1st Edition Holofoil" i],
|
||||||
|
.card-image-wrap[data-variant="1st Edition Holofoil" i] {
|
||||||
|
|
||||||
|
.holo-shine {
|
||||||
|
&::before {
|
||||||
|
background-image: inherit;
|
||||||
|
background-size: inherit;
|
||||||
|
background-position: inherit;
|
||||||
|
background-repeat: inherit;
|
||||||
|
background-blend-mode: inherit;
|
||||||
|
filter: inherit;
|
||||||
|
mix-blend-mode: color-dodge;
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%,
|
||||||
|
8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.holo-glare {
|
||||||
|
&::before {
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%,
|
||||||
|
8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 6. ZONE 2 — FULL CARD EFFECT
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.image-grow[data-rarity="Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Character Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Character Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Special Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Special Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Double Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Double Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Hyper Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Hyper Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Mega Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Mega Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Mega Attack Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Mega Attack Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="ACE Spec Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="ACE Spec Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="ACE Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="ACE Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Special Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Special Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Black White Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Black White Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Character Super Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Character Super Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Mega Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Mega Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Rare BREAK" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Rare BREAK" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Shiny Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Shiny Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Shiny Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Shiny Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Shiny Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Shiny Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Shiny Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Shiny Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]) {
|
||||||
|
|
||||||
|
.holo-shine {
|
||||||
|
clip-path: none;
|
||||||
|
@include prismatic-shine;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holo-glare {
|
||||||
|
clip-path: none;
|
||||||
|
@include prismatic-glare;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 7. ZONE 3 — INVERSE (borders only): Reverse Holofoil + Prism Rare
|
||||||
|
//
|
||||||
|
// Applies the effect to everything EXCEPT the art window.
|
||||||
|
// Uses the "zero-width tunnel" technique from css-tricks.com/cutting-inner-part-element-using-clip-path/
|
||||||
|
// Outer rectangle drawn anticlockwise, closes back to 0% 0%, then the inner
|
||||||
|
// art window rectangle is drawn clockwise — nonzero winding treats the inner
|
||||||
|
// shape as a hole, leaving the art window transparent.
|
||||||
|
//
|
||||||
|
// Outer (anticlockwise): 0 0 → 0 100% → 100% 100% → 100% 0 → 0 0
|
||||||
|
// Inner art window (clockwise): 8% 9.85% → 92% 9.85% → 92% 47.15% → 8% 47.15%
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.image-grow[data-variant="Reverse Holofoil" i],
|
||||||
|
.card-image-wrap[data-variant="Reverse Holofoil" i],
|
||||||
|
.image-grow[data-rarity="Prism Rare" i],
|
||||||
|
.card-image-wrap[data-rarity="Prism Rare" i] {
|
||||||
|
|
||||||
|
// Energy colour tint — multiply blend darkens the card toward --card-glow.
|
||||||
|
// z-index 2 puts it above the card image (z-index 1) but below holo-shine (3).
|
||||||
|
// Opacity tied to --card-opacity so it appears/disappears with the hover effect.
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
background: var(--card-glow);
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
opacity: calc(var(--card-opacity) * 0.5);
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%,
|
||||||
|
8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.holo-shine {
|
||||||
|
// Energy-aware gradient — weaves --card-glow (set per data-energy on the
|
||||||
|
// wrapper) into the prismatic colour sequence so each energy type gets a
|
||||||
|
// tinted shimmer: Grass = green, Fire = orange, Water = cyan, etc.
|
||||||
|
background-image:
|
||||||
|
var(--grain),
|
||||||
|
repeating-linear-gradient(110deg,
|
||||||
|
var(--card-glow),
|
||||||
|
var(--blue),
|
||||||
|
var(--card-glow),
|
||||||
|
var(--green),
|
||||||
|
var(--yellow),
|
||||||
|
var(--card-glow),
|
||||||
|
var(--red),
|
||||||
|
var(--violet),
|
||||||
|
var(--card-glow)
|
||||||
|
);
|
||||||
|
background-position:
|
||||||
|
center center,
|
||||||
|
calc(((50% - var(--background-x)) * 2.6) + 50%)
|
||||||
|
calc(((50% - var(--background-y)) * 3.5) + 50%);
|
||||||
|
background-size: 33%, 400% 400%;
|
||||||
|
background-repeat: repeat, no-repeat;
|
||||||
|
background-blend-mode: soft-light, normal;
|
||||||
|
filter: brightness(1.0) contrast(1.0) saturate(1.4);
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%,
|
||||||
|
8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85%
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holo-glare {
|
||||||
|
@include prismatic-glare;
|
||||||
|
// Hot-spot tinted with energy colour to match the shine treatment
|
||||||
|
background-image: radial-gradient(
|
||||||
|
farthest-corner circle at var(--pointer-x) var(--pointer-y),
|
||||||
|
var(--card-glow) 0%,
|
||||||
|
hsla(0, 0%, 100%, 0.3) 20%,
|
||||||
|
hsla(0, 0%, 0%, 0.5) 90%
|
||||||
|
);
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%,
|
||||||
|
8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85%
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 8. MODAL ANIMATION
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Note: --card-opacity is intentionally NOT registered via @property.
|
||||||
|
// Registering it as <number> makes it interpolatable, causing the browser
|
||||||
|
// to smoothly transition it when JS sets it via inline style — creating the
|
||||||
|
// unwanted slow fade-in. Without registration it changes instantly.
|
||||||
|
@property --pointer-x { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
|
||||||
|
@property --pointer-y { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
|
||||||
|
@property --background-x { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
|
||||||
|
@property --background-y { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
|
||||||
|
@property --pointer-from-center { syntax: '<number>'; inherits: true; initial-value: 0; }
|
||||||
|
@property --pointer-from-left { syntax: '<number>'; inherits: true; initial-value: 0.5; }
|
||||||
|
@property --pointer-from-top { syntax: '<number>'; inherits: true; initial-value: 0.5; }
|
||||||
|
|
||||||
|
@keyframes holo-modal-opacity {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
4% { opacity: 0; }
|
||||||
|
8% { opacity: 0.35; }
|
||||||
|
85% { opacity: 0.35; }
|
||||||
|
90%, 100%{ opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes holo-modal-position {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
8% {
|
||||||
|
--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% {
|
||||||
|
--pointer-x: 25%; --pointer-y: 15%;
|
||||||
|
--background-x: 38%; --background-y: 28%;
|
||||||
|
--pointer-from-center: 0.85;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
--pointer-x: 50%; --pointer-y: 50%;
|
||||||
|
--background-x: 50%; --background-y: 50%;
|
||||||
|
--pointer-from-center: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image-wrap.holo-modal-mode {
|
||||||
|
// Animate pointer vars on the wrapper so CSS custom props interpolate
|
||||||
|
animation: holo-modal-position 60s ease-in-out infinite;
|
||||||
|
animation-delay: var(--shimmer-delay, -2s);
|
||||||
|
|
||||||
|
.holo-shine,
|
||||||
|
.holo-glare {
|
||||||
|
// Animate opacity directly — no @property needed, native interpolation
|
||||||
|
animation: holo-modal-opacity 60s ease-in-out infinite;
|
||||||
|
animation-delay: var(--shimmer-delay, -2s);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-holo-active] {
|
||||||
|
animation-play-state: paused;
|
||||||
|
.holo-shine { opacity: 0.20; }
|
||||||
|
.holo-glare { opacity: calc(0.20 * 0.4); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 9. MOBILE / TOUCH — static holofoil overlay, no JS tracking
|
||||||
|
//
|
||||||
|
// @media (hover: none) targets touchscreens only.
|
||||||
|
// Technique from joshdance.com/100/Day50: two rainbow gradients at opposing
|
||||||
|
// fixed positions interact via blend modes to create a static holographic sheen.
|
||||||
|
// Where the two gradient bands cross, the additive blending creates bright
|
||||||
|
// rainbow intersections that read as a light-catch effect — no pointer needed.
|
||||||
|
//
|
||||||
|
// Implementation:
|
||||||
|
// - .holo-shine gets the two-gradient stack at fixed diagonal positions
|
||||||
|
// - ::before moves in the opposite direction (negative position) so the
|
||||||
|
// crossing point creates the characteristic holofoil bright intersection
|
||||||
|
// - opacity is always-on at a low value — no hover event needed
|
||||||
|
// - will-change reset to auto — no GPU layer reservation needed
|
||||||
|
// - Glitter hidden — parallax position is meaningless without tracking
|
||||||
|
// - No CSS animation on touch — pure static CSS, zero JS involvement
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
|
||||||
|
.holo-shine,
|
||||||
|
.holo-glare {
|
||||||
|
will-change: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable any animation on modal cards on touch — static treatment handles it
|
||||||
|
.card-image-wrap.holo-modal-mode {
|
||||||
|
animation: none;
|
||||||
|
|
||||||
|
.holo-shine,
|
||||||
|
.holo-glare { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress glitter — parallax position is meaningless without pointer tracking
|
||||||
|
.holo-glare::after { display: none; }
|
||||||
|
|
||||||
|
// ── Static holofoil overlay for all effect zones on touch ─────────────────
|
||||||
|
// Override the JS-driven background-position values with fixed diagonals.
|
||||||
|
// The ::before pseudo moves in the opposite direction to create crossing bands.
|
||||||
|
|
||||||
|
.image-grow,
|
||||||
|
.card-image-wrap {
|
||||||
|
|
||||||
|
// Zone 1 — art window
|
||||||
|
&[data-rarity="Rare" i]:not([data-variant="Reverse Holofoil" i]):not([data-variant="Normal" i]),
|
||||||
|
&[data-rarity="Amazing Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Classic Collection" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-variant="1st Edition Holofoil" i],
|
||||||
|
// Zone 2 — full card
|
||||||
|
&[data-rarity="Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Character Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Special Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Double Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Hyper Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Mega Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Mega Attack Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="ACE Spec Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="ACE Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Special Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Black White Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Character Super Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Mega Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Rare BREAK" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Shiny Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Shiny Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Shiny Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Shiny Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
// Zone 3 — inverse (Reverse Holofoil + Prism Rare)
|
||||||
|
// Energy colour woven in via --card-glow, same as desktop Zone 3
|
||||||
|
&[data-variant="Reverse Holofoil" i],
|
||||||
|
&[data-rarity="Prism Rare" i] {
|
||||||
|
|
||||||
|
// Energy colour multiply tint — kept subtle on mobile
|
||||||
|
&::after {
|
||||||
|
opacity: 0.04;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holo-shine {
|
||||||
|
background-image:
|
||||||
|
var(--grain),
|
||||||
|
repeating-linear-gradient(110deg,
|
||||||
|
var(--card-glow),
|
||||||
|
var(--blue),
|
||||||
|
var(--card-glow),
|
||||||
|
var(--green),
|
||||||
|
var(--yellow),
|
||||||
|
var(--card-glow),
|
||||||
|
var(--red),
|
||||||
|
var(--violet),
|
||||||
|
var(--card-glow)
|
||||||
|
);
|
||||||
|
background-size: 33%, 400% 400%;
|
||||||
|
background-repeat: repeat, no-repeat;
|
||||||
|
background-blend-mode: soft-light, normal;
|
||||||
|
background-position: center, 38% 25%;
|
||||||
|
filter: brightness(1.0) contrast(1.1) saturate(1.0);
|
||||||
|
opacity: 0.35;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(110deg,
|
||||||
|
var(--card-glow),
|
||||||
|
var(--blue),
|
||||||
|
var(--card-glow),
|
||||||
|
var(--green),
|
||||||
|
var(--yellow),
|
||||||
|
var(--card-glow),
|
||||||
|
var(--red),
|
||||||
|
var(--violet),
|
||||||
|
var(--card-glow)
|
||||||
|
);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
background-position: 62% 75%;
|
||||||
|
mix-blend-mode: color-dodge;
|
||||||
|
opacity: 0.18;
|
||||||
|
filter: brightness(1.0) contrast(1.1) saturate(1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.holo-glare {
|
||||||
|
opacity: 0.15;
|
||||||
|
background-image: radial-gradient(
|
||||||
|
farthest-corner circle at 35% 25%,
|
||||||
|
var(--card-glow) 0%,
|
||||||
|
hsla(0, 0%, 100%, 0.2) 30%,
|
||||||
|
hsla(0, 0%, 0%, 0.3) 90%
|
||||||
|
);
|
||||||
|
filter: brightness(0.8) contrast(1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/* --------------------------------------------------
|
/* --------------------------------------------------
|
||||||
Bootstrap overrides
|
Bootstrap Overrides
|
||||||
-------------------------------------------------- */
|
-------------------------------------------------- */
|
||||||
|
|
||||||
$grid-breakpoints: (
|
$grid-breakpoints: (
|
||||||
@@ -21,21 +21,47 @@ $container-max-widths: (
|
|||||||
xxxl: 1840px
|
xxxl: 1840px
|
||||||
) !default;
|
) !default;
|
||||||
|
|
||||||
|
|
||||||
@import "_bootstrap";
|
@import "_bootstrap";
|
||||||
|
|
||||||
|
// ── Holofoil ──────────────────────────────────────────────────────────────
|
||||||
|
@import "_holofoil-integration"; // also pulls in _card.scss
|
||||||
|
|
||||||
/* --------------------------------------------------
|
/* --------------------------------------------------
|
||||||
Root Variables
|
Root Variables
|
||||||
-------------------------------------------------- */
|
-------------------------------------------------- */
|
||||||
:root {
|
:root {
|
||||||
--total: 11; /* Number of items in the energy wheel */
|
--total: 11;
|
||||||
--radius: 40px; /* Circle radius */
|
--radius: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
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
|
Layout
|
||||||
@@ -71,16 +97,12 @@ html {
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
--angle: calc(360deg / var(--total) * var(--i));
|
--angle: calc(360deg / var(--total) * var(--i));
|
||||||
|
transform: rotate(var(--angle)) translateX(var(--radius)) rotate(calc(-1 * var(--angle)));
|
||||||
|
|
||||||
transform:
|
&:first-of-type {
|
||||||
rotate(var(--angle))
|
|
||||||
translateX(var(--radius))
|
|
||||||
rotate(calc(-1 * var(--angle)));
|
|
||||||
}
|
|
||||||
|
|
||||||
.energy-wheel-item:first-of-type {
|
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------
|
/* --------------------------------------------------
|
||||||
Navbar & Icons
|
Navbar & Icons
|
||||||
@@ -143,7 +165,8 @@ html {
|
|||||||
transition: box-shadow 350ms ease, transform 350ms ease;
|
transition: box-shadow 350ms ease, transform 350ms ease;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.24);
|
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);
|
box-shadow: 0 8px 10px rgba(0, 0, 0, 0.2);
|
||||||
transform: translateY(-0.9rem) scale(1.02);
|
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 {
|
.nav-link {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: rgba(255, 255, 255, 0.67);
|
color: rgba(255, 255, 255, 0.67);
|
||||||
@@ -193,7 +244,6 @@ html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tiered Colors */
|
|
||||||
$tiers: (
|
$tiers: (
|
||||||
nm: rgba(156, 204, 102, 1),
|
nm: rgba(156, 204, 102, 1),
|
||||||
lp: rgba(211, 225, 86, 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 {
|
.dark-callout {
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
background-color: rgba(44, 48, 59, 1);
|
background-color: rgba(44, 48, 59, 1);
|
||||||
@@ -239,25 +295,22 @@ $tiers: (
|
|||||||
.card-image {
|
.card-image {
|
||||||
aspect-ratio: 23 / 32;
|
aspect-ratio: 23 / 32;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
z-index: 998;
|
z-index: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Icon sizing */
|
/* Icon Sizes */
|
||||||
.small-icon svg {
|
.small-icon svg { max-height: 16px; width: 100%; margin-top: -0.25rem; }
|
||||||
width: 100%;
|
.medium-icon svg { max-height: 32px; width: 100%; margin-left: -0.25rem; }
|
||||||
max-height: 16px;
|
|
||||||
margin-top: -0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Black silhouette overlay */
|
/* Masked Image */
|
||||||
.masked-image {
|
.masked-image {
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
filter: brightness(0);
|
filter: brightness(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Decorative background elements */
|
/* Decorative Background */
|
||||||
.starburst,
|
.starburst,
|
||||||
.whos-that-pokemon {
|
.whos-that-pokemon {
|
||||||
mix-blend-mode: lighten;
|
mix-blend-mode: lighten;
|
||||||
@@ -269,12 +322,16 @@ $tiers: (
|
|||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SVG sizes */
|
/* SVG Generic Sizes */
|
||||||
.energy-icon svg,
|
.energy-icon svg,
|
||||||
.rarity-icon-large svg,
|
.rarity-icon-large svg,
|
||||||
.set-icon svg {
|
.set-icon svg,
|
||||||
width: 2.5rem;
|
.edition-icon svg {
|
||||||
|
width: 2rem;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
width: 2.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rarity-icon-large svg,
|
.rarity-icon-large svg,
|
||||||
@@ -282,12 +339,14 @@ $tiers: (
|
|||||||
margin-bottom: -0.25rem;
|
margin-bottom: -0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-icon svg {
|
.filter-icon svg,
|
||||||
|
.search-button {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
fill: rgba(255,255,255,0.87);
|
fill: rgba(255,255,255,0.87);
|
||||||
stroke: rgba(255,255,255,0.87);
|
stroke: rgba(255,255,255,0.87);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form states */
|
||||||
.form-check-input:checked {
|
.form-check-input:checked {
|
||||||
background-color: var(--bs-success);
|
background-color: var(--bs-success);
|
||||||
border-color: var(--bs-success-border-subtle);
|
border-color: var(--bs-success-border-subtle);
|
||||||
@@ -298,17 +357,13 @@ $tiers: (
|
|||||||
box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25);
|
box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-button {
|
/* Back to Top */
|
||||||
width: 2rem;
|
|
||||||
fill: rgba(255,255,255,0.87);
|
|
||||||
stroke: rgba(255,255,255,0.87);
|
|
||||||
}
|
|
||||||
|
|
||||||
#btn-back-to-top {
|
#btn-back-to-top {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 5vh;
|
bottom: 5vh;
|
||||||
right: 5vw;
|
right: 5vw;
|
||||||
display: none;
|
display: none;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-icon svg {
|
.top-icon svg {
|
||||||
@@ -323,43 +378,50 @@ $tiers: (
|
|||||||
stroke: var(--bs-info-border-subtle);
|
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 {
|
.shadow-filter {
|
||||||
filter:
|
filter:
|
||||||
drop-shadow(0 5px 5px rgba(0, 0, 0, 0.3))
|
drop-shadow(0 5px 5px rgba(0, 0, 0, 0.3))
|
||||||
drop-shadow(0 4px 6px rgba(0, 0, 0, 0.2));
|
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
|
Pricing
|
||||||
-------------------------------------------------- */
|
-------------------------------------------------- */
|
||||||
|
|
||||||
.price-row {
|
.price-row {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
margin-top: -1.25rem;
|
margin-top: -1.25rem;
|
||||||
border-radius: 0.33rem;
|
border-radius: 0.33rem;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
rgba(156, 204, 102, 1) 21%,
|
map-get($tiers, nm) 21%,
|
||||||
rgba(211, 225, 86, 1) 42%,
|
map-get($tiers, lp) 42%,
|
||||||
rgba(255, 238, 87, 1) 63%,
|
map-get($tiers, mp) 63%,
|
||||||
rgba(255, 201, 41, 1) 74%,
|
map-get($tiers, hp) 74%,
|
||||||
rgba(255, 167, 36, 1) 85%
|
map-get($tiers, dmg) 85%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-button {
|
.inventory-button {
|
||||||
margin-bottom: -2rem;
|
|
||||||
margin-right: -0.25rem;
|
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
|
margin-bottom: -2rem;
|
||||||
|
margin-right: -0.25rem;
|
||||||
border-radius: 0.33rem;
|
border-radius: 0.33rem;
|
||||||
background-color: hsl(262, 47%, 55%);
|
background-color: hsl(262, 47%, 55%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -376,6 +438,7 @@ $tiers: (
|
|||||||
font-size: 0.9rem !important;
|
font-size: 0.9rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Price Label */
|
||||||
.price-label {
|
.price-label {
|
||||||
font-size: 0.69rem;
|
font-size: 0.69rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -384,37 +447,21 @@ $tiers: (
|
|||||||
border-radius: 0.33rem 0 0 0.33rem;
|
border-radius: 0.33rem 0 0 0.33rem;
|
||||||
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) { font-size: 0.72rem; }
|
||||||
font-size: 0.72rem;
|
@media (min-width: 996px) { font-size: 0.75rem; }
|
||||||
}
|
@media (min-width: 1200px) { font-size: 0.8rem; }
|
||||||
@media (min-width: 996px) {
|
@media (min-width: 1600px) { font-size: 1rem; }
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
@media (min-width: 1600px) {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-of-type(2) {
|
&:nth-of-type(2) { background-color: hsl(66, 70%, 61%); }
|
||||||
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%); }
|
||||||
&:nth-of-type(3) {
|
&:last-of-type { background-color: hsl(36, 100%, 57%); border-radius: 0.33rem; }
|
||||||
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
|
Search
|
||||||
-------------------------------------------------- */
|
-------------------------------------------------- */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.search-box,
|
.search-box,
|
||||||
.search-button {
|
.search-button {
|
||||||
@@ -429,7 +476,7 @@ $tiers: (
|
|||||||
line-height: 2rem;
|
line-height: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sticky (mobile-bottom / desktop-top) search bar */
|
/* Sticky Search Bar */
|
||||||
.search-bar {
|
.search-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -441,7 +488,6 @@ $tiers: (
|
|||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
float: right;
|
|
||||||
min-width: 45vw;
|
min-width: 45vw;
|
||||||
max-width: 45vw;
|
max-width: 45vw;
|
||||||
transform: rotate(0);
|
transform: rotate(0);
|
||||||
@@ -454,71 +500,48 @@ $tiers: (
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------
|
/* --------------------------------------------------
|
||||||
Circles (Header, Buttons, etc.)
|
Circles
|
||||||
-------------------------------------------------- */
|
-------------------------------------------------- */
|
||||||
|
|
||||||
/* Shared Circle Styles */
|
|
||||||
%circle-base {
|
%circle-base {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
margin: 0 auto;
|
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%;
|
mask: linear-gradient(135deg, #fffc 40%, #fff, #fffc 60%) 100% 100% / 240% 240%;
|
||||||
transition: 0.4s;
|
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; background: radial-gradient(circle at top left, hsl(0, 100%, 56%), hsl(0, 79%, 45%)); }
|
||||||
.redCircle {
|
.yellowCircle { @extend %circle-base; background: radial-gradient(circle at top left, hsl(61, 100%, 50%), hsl(61, 100%, 40%)); }
|
||||||
@extend %circle-base;
|
.greenCircle { @extend %circle-base; background: radial-gradient(circle at top left, hsl(149, 100%, 40%), hsl(149, 100%, 30%)); }
|
||||||
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%));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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,
|
.redCircle:hover,
|
||||||
|
.yellowCircle:hover,
|
||||||
.greenCircle:hover,
|
.greenCircle:hover,
|
||||||
.blueCircle:hover {
|
.blueCircle:hover {
|
||||||
--i: -1, 1;
|
--i: -1, 1;
|
||||||
mask-position: 0 0;
|
mask-position: 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
/* --------------------------------------------------
|
||||||
width: 1.85rem;
|
Buttons
|
||||||
height: 1.85rem;
|
-------------------------------------------------- */
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warning>span, .btn-warning>svg.nav-icon {
|
.btn-warning > span,
|
||||||
|
.btn-warning > svg.nav-icon {
|
||||||
fill: var(--bs-warning-border-subtle);
|
fill: var(--bs-warning-border-subtle);
|
||||||
stroke: var(--bs-warning-border-subtle);
|
stroke: var(--bs-warning-border-subtle);
|
||||||
color: 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);
|
fill: var(--bs-warning-bg-subtle);
|
||||||
stroke: var(--bs-warning-bg-subtle);
|
stroke: var(--bs-warning-bg-subtle);
|
||||||
color: var(--bs-warning-border-subtle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline-success svg.nav-icon {
|
.btn-outline-success svg.nav-icon {
|
||||||
@@ -526,8 +549,125 @@ $tiers: (
|
|||||||
stroke: var(--bs-success);
|
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);
|
fill: var(--bs-success-border-subtle);
|
||||||
stroke: 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);
|
||||||
|
}
|
||||||
|
-------------------------------------------------- */
|
||||||
260
src/assets/js/holofoil-init.js
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* holofoil-init.js
|
||||||
|
* Instruments .image-grow and .card-image-wrap with the holofoil effect system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function HolofoilSystem() {
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Variants that receive NO effect
|
||||||
|
const NO_EFFECT_VARIANTS = new Set(['normal']);
|
||||||
|
|
||||||
|
// Variants that always receive an effect regardless of rarity
|
||||||
|
const HOLO_VARIANTS = new Set([
|
||||||
|
'reverse holofoil',
|
||||||
|
'holofoil',
|
||||||
|
'1st edition holofoil',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Rarities that receive an effect
|
||||||
|
const HOLO_RARITIES = new Set([
|
||||||
|
// Art window zone
|
||||||
|
'rare',
|
||||||
|
'amazing rare',
|
||||||
|
'classic collection',
|
||||||
|
'holo rare',
|
||||||
|
// Full card zone
|
||||||
|
'ultra rare',
|
||||||
|
'character rare',
|
||||||
|
'illustration rare',
|
||||||
|
'special illustration rare',
|
||||||
|
'double rare',
|
||||||
|
'hyper rare',
|
||||||
|
'mega rare',
|
||||||
|
'mega attack rare',
|
||||||
|
'ace spec rare',
|
||||||
|
'ace rare',
|
||||||
|
'art rare',
|
||||||
|
'special art rare',
|
||||||
|
'black white rare',
|
||||||
|
'character super rare',
|
||||||
|
'mega ultra rare',
|
||||||
|
'rare break',
|
||||||
|
'secret rare',
|
||||||
|
'shiny holo rare',
|
||||||
|
'shiny rare',
|
||||||
|
'shiny secret rare',
|
||||||
|
'shiny ultra rare',
|
||||||
|
// Inverse zone
|
||||||
|
'prism rare',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ALL_WRAPPERS_SEL = '.image-grow, .card-image-wrap';
|
||||||
|
|
||||||
|
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) + '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldHaveEffect(el) {
|
||||||
|
if (el.dataset.default === 'true') return false;
|
||||||
|
// Also check if the card image itself is the default fallback
|
||||||
|
const img = el.querySelector('img');
|
||||||
|
if (img && img.src && img.src.endsWith('/cards/default.jpg')) return false;
|
||||||
|
const variant = (el.dataset.variant || '').toLowerCase().trim();
|
||||||
|
const rarity = (el.dataset.rarity || '').toLowerCase().trim();
|
||||||
|
if (NO_EFFECT_VARIANTS.has(variant)) return false;
|
||||||
|
if (HOLO_VARIANTS.has(variant)) return true;
|
||||||
|
if (HOLO_RARITIES.has(rarity)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchForDefault(el) {
|
||||||
|
if (el.dataset.default === 'true') return;
|
||||||
|
var observer = new MutationObserver(function() {
|
||||||
|
if (el.dataset.default === 'true') {
|
||||||
|
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'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const canHover = window.matchMedia('(hover: hover)').matches;
|
||||||
|
|
||||||
|
function stamp(el) {
|
||||||
|
if (el.dataset.holoInit) return;
|
||||||
|
if (!shouldHaveEffect(el)) {
|
||||||
|
el.dataset.holoInit = 'skip';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
injectChildren(el);
|
||||||
|
if (el.classList.contains('card-image-wrap')) {
|
||||||
|
if (canHover) {
|
||||||
|
// Desktop: use hover + pointer tracking, same as grid cards.
|
||||||
|
// No animation — CSS :hover rule controls --card-opacity directly.
|
||||||
|
el.classList.remove('holo-modal-mode');
|
||||||
|
} else {
|
||||||
|
// Touch: use the autonomous CSS animation sweep.
|
||||||
|
el.classList.add('holo-modal-mode');
|
||||||
|
el.style.setProperty('--shimmer-delay', rand(-8, 0) + 's');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watchForDefault(el);
|
||||||
|
el.dataset.holoInit = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointerState = new WeakMap();
|
||||||
|
|
||||||
|
function onPointerEnter(e) {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
if (el.dataset.holoInit !== '1') return;
|
||||||
|
el.dataset.holoActive = '1';
|
||||||
|
// Inline style wins over CSS immediately — @property not registered for
|
||||||
|
// --card-opacity so no interpolation. All calc() multipliers in child
|
||||||
|
// rules (glare * 0.4, glitter * 0.6) work correctly from this single var.
|
||||||
|
el.style.setProperty('--card-opacity', '0.2');
|
||||||
|
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() {
|
||||||
|
applyPointerVars(el, pointerVars(e.clientX, e.clientY, el.getBoundingClientRect()));
|
||||||
|
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;
|
||||||
|
// Remove inline style so CSS default (--card-opacity: 0) takes over instantly
|
||||||
|
el.style.removeProperty('--card-opacity');
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachListeners(el) {
|
||||||
|
if (el.dataset.holoListeners) return;
|
||||||
|
// On touch-only devices the CSS static shimmer handles the effect.
|
||||||
|
// Skip JS pointer tracking — pointermove never fires on touchscreens
|
||||||
|
// and registering listeners wastes memory with no benefit.
|
||||||
|
if (!window.matchMedia('(hover: hover)').matches) return;
|
||||||
|
el.addEventListener('pointerenter', onPointerEnter, { passive: true });
|
||||||
|
el.addEventListener('pointermove', onPointerMove, { passive: true });
|
||||||
|
el.addEventListener('pointerleave', onPointerLeave, { passive: true });
|
||||||
|
el.dataset.holoListeners = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stampAll(root) {
|
||||||
|
(root || document).querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) {
|
||||||
|
stamp(el);
|
||||||
|
if (el.dataset.holoInit === '1') attachListeners(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeGrid() {
|
||||||
|
var grid = document.getElementById('cardGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
new MutationObserver(function(mutations) {
|
||||||
|
mutations.forEach(function(m) {
|
||||||
|
m.addedNodes.forEach(function(node) {
|
||||||
|
if (node.nodeType !== 1) return;
|
||||||
|
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(mutations) {
|
||||||
|
mutations.forEach(function(m) {
|
||||||
|
m.addedNodes.forEach(function(node) {
|
||||||
|
if (node.nodeType !== 1) return;
|
||||||
|
|
||||||
|
var wrappers = [];
|
||||||
|
if (node.matches && node.matches(ALL_WRAPPERS_SEL)) wrappers.push(node);
|
||||||
|
if (node.querySelectorAll) {
|
||||||
|
node.querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) {
|
||||||
|
wrappers.push(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
wrappers.forEach(function(el) {
|
||||||
|
// Reset stamp so each new card is evaluated fresh
|
||||||
|
delete el.dataset.holoInit;
|
||||||
|
stamp(el);
|
||||||
|
if (el.dataset.holoInit === '1') attachListeners(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).observe(modal, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
stampAll();
|
||||||
|
observeGrid();
|
||||||
|
observeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as bootstrap from 'bootstrap';
|
import * as bootstrap from 'bootstrap';
|
||||||
window.bootstrap = bootstrap;
|
window.bootstrap = bootstrap;
|
||||||
|
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
||||||
|
|
||||||
// trap browser back and close the modal if open
|
// trap browser back and close the modal if open
|
||||||
const cardModal = document.getElementById('cardModal');
|
const cardModal = document.getElementById('cardModal');
|
||||||
@@ -25,3 +25,28 @@ cardModal.addEventListener('hide.bs.modal', () => {
|
|||||||
history.back();
|
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
|
||||||
<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'});">
|
type="button"
|
||||||
<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>
|
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>
|
</button>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
//Get the button
|
const mybutton = document.getElementById("btn-back-to-top");
|
||||||
let mybutton = document.getElementById("btn-back-to-top");
|
|
||||||
|
|
||||||
// When the user scrolls down 20px from the top of the document, show the button
|
function setButtonVisibility(visible: boolean) {
|
||||||
window.onscroll = function () {
|
if (!mybutton) return;
|
||||||
scrollFunction();
|
mybutton.style.display = visible ? "block" : "none";
|
||||||
};
|
mybutton.setAttribute("aria-hidden", visible ? "false" : "true");
|
||||||
|
}
|
||||||
|
|
||||||
function scrollFunction() {
|
function scrollFunction() {
|
||||||
if (
|
const scrolled = document.body.scrollTop > 20 || document.documentElement.scrollTop > 20;
|
||||||
document.body.scrollTop > 20 ||
|
setButtonVisibility(scrolled);
|
||||||
document.documentElement.scrollTop > 20
|
|
||||||
) {
|
|
||||||
mybutton.style.display = "block";
|
|
||||||
} else {
|
|
||||||
mybutton.style.display = "none";
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// When the user clicks on the button, scroll to the top of the document
|
|
||||||
mybutton.addEventListener("click", backToTop);
|
|
||||||
|
|
||||||
function backToTop() {
|
function backToTop() {
|
||||||
document.body.scrollTop = 0;
|
dataLayer.push({ event: "backToTop" });
|
||||||
document.documentElement.scrollTop = 0;
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mybutton) {
|
||||||
|
mybutton.addEventListener("click", backToTop);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("scroll", scrollFunction);
|
||||||
</script>
|
</script>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import BackToTop from "./BackToTop.astro"
|
import BackToTop from "./BackToTop.astro"
|
||||||
---
|
---
|
||||||
<div class="row mb-4">
|
<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="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 offcanvas-start" data-bs-backdrop="static" tabindex="-1" id="filterBar" aria-labelledby="filterBarLabel">
|
||||||
<div class="offcanvas-header">
|
<div class="offcanvas-header">
|
||||||
@@ -14,17 +14,379 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-12 col-md-9 mt-0">
|
<div class="col-sm-12 col-md-10 mt-0">
|
||||||
<div id="activeFilters" class="mb-2 d-flex align-items-center justify-content-end small"></div>
|
<div class="d-flex flex-column gap-1 flex-md-row align-items-center mb-3 w-100 justify-content-start">
|
||||||
<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="sortBy"></div>
|
||||||
<div id="notfound"></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>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal fade card-modal" id="cardModal" tabindex="-1" aria-labelledby="cardModalLabel" aria-hidden="true" transition:name="">
|
|
||||||
|
<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-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
|
||||||
<div class="modal-content">
|
<div class="modal-content p-2">Loading...</div>
|
||||||
Loading...
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<BackToTop>
|
<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) {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (navigator.clipboard && navigator.clipboard.write) {
|
||||||
|
const blob = await new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
|
||||||
|
});
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||||
|
showCopyToast('📋 Image copied!', '#198754');
|
||||||
|
} else {
|
||||||
|
const url = img.src;
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
showCopyToast('📋 Image URL copied!', '#198754');
|
||||||
|
} else {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = url;
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(input);
|
||||||
|
showCopyToast('📋 Image URL copied!', '#198754');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed:', err);
|
||||||
|
showCopyToast('❌ Copy failed', '#dc3545');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function showCopyToast(message, color) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.style.cssText = `
|
||||||
|
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||||
|
background: ${color}; color: white; padding: 10px 20px;
|
||||||
|
border-radius: 8px; font-size: 14px; z-index: 9999;
|
||||||
|
opacity: 0; transition: opacity 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
requestAnimationFrame(() => toast.style.opacity = '1');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
toast.addEventListener('transitionend', () => toast.remove());
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
|
const cardIndex = [];
|
||||||
|
let currentCardId = null;
|
||||||
|
let isNavigating = false;
|
||||||
|
|
||||||
|
// ── Register cards as HTMX loads them ────────────────────────────────────
|
||||||
|
const cardGrid = document.getElementById('cardGrid');
|
||||||
|
const gridObserver = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
for (const node of mutation.addedNodes) {
|
||||||
|
if (node.nodeType !== 1) continue;
|
||||||
|
const triggers = node.querySelectorAll
|
||||||
|
? node.querySelectorAll('[data-card-id]')
|
||||||
|
: [];
|
||||||
|
for (const el of triggers) {
|
||||||
|
const id = Number(el.getAttribute('data-card-id'));
|
||||||
|
if (id && !cardIndex.includes(id)) cardIndex.push(id);
|
||||||
|
}
|
||||||
|
if (node.dataset?.cardId) {
|
||||||
|
const id = Number(node.dataset.cardId);
|
||||||
|
if (id && !cardIndex.includes(id)) cardIndex.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
gridObserver.observe(cardGrid, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// ── Navigation helpers ────────────────────────────────────────────────────
|
||||||
|
function getAdjacentIds() {
|
||||||
|
const idx = cardIndex.indexOf(currentCardId);
|
||||||
|
return {
|
||||||
|
prev: idx > 0 ? cardIndex[idx - 1] : null,
|
||||||
|
next: idx < cardIndex.length - 1 ? cardIndex[idx + 1] : null,
|
||||||
|
idx,
|
||||||
|
total: cardIndex.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNavButtons(modal) {
|
||||||
|
const prevBtn = document.getElementById('modalPrevBtn');
|
||||||
|
const nextBtn = document.getElementById('modalNextBtn');
|
||||||
|
if (!modal || !modal.classList.contains('show')) {
|
||||||
|
prevBtn.classList.add('d-none');
|
||||||
|
nextBtn.classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { prev, next } = getAdjacentIds();
|
||||||
|
prevBtn.classList.toggle('d-none', prev === null);
|
||||||
|
nextBtn.classList.toggle('d-none', next === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</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,
|
"Fire": fire,
|
||||||
"Water": water,
|
"Water": water,
|
||||||
"Steel": steel,
|
"Steel": steel,
|
||||||
|
"Metal": steel,
|
||||||
"Colorless": colorless,
|
"Colorless": colorless,
|
||||||
"Fighting": fighting,
|
"Fighting": fighting,
|
||||||
"Psychic": psychic,
|
"Psychic": psychic,
|
||||||
"Electric": electric,
|
"Electric": electric,
|
||||||
|
"Lightning": electric,
|
||||||
};
|
};
|
||||||
|
|
||||||
const svg = energyMap[energy as keyof typeof energyMap] ?? "";
|
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">
|
<footer class="bd-footer py-4 py-md-5 mt-0 bg-body-tertiary">
|
||||||
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,32 +1,10 @@
|
|||||||
---
|
---
|
||||||
import '/src/assets/css/main.scss';
|
|
||||||
export const prerender = false;
|
|
||||||
---
|
---
|
||||||
<script is:inline>
|
<nav class="navbar navbar-expand sticky-top bg-dark" data-bs-theme="dark" aria-label="Main navigation">
|
||||||
const afterUpdate = (e) => {
|
<div class="container">
|
||||||
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">
|
|
||||||
<a class="navbar-brand d-flex" href="/">
|
<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>
|
</a>
|
||||||
<slot name="navItems"/>
|
<slot name="navItems"/>
|
||||||
<slot name="searchInput"/>
|
<slot name="searchInput"/>
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
---
|
---
|
||||||
import '/src/assets/css/main.scss';
|
|
||||||
---
|
---
|
||||||
<div class="navbar-collapse" id="navbarNav">
|
<div class="navbar-collapse" id="navbarNav" aria-labelledby="navbarToggler">
|
||||||
<ul class="navbar-nav ms-auto">
|
<ul class="navbar-nav ms-auto">
|
||||||
<li class="nav-item d-flex">
|
<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>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
import '/src/assets/css/main.scss';
|
|
||||||
---
|
|
||||||
|
|
||||||
|
---
|
||||||
<header class="header-top w-100">
|
<header class="header-top w-100">
|
||||||
<div class="header-wrap">
|
<div class="header-wrap">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const rarityMap = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const svg = rarityMap[rarity as keyof typeof 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>
|
<script is:inline>
|
||||||
const afterUpdate = (e) => {
|
const afterUpdate = (e) => {
|
||||||
@@ -14,6 +14,8 @@ import { SignedIn } from "@clerk/astro/components";
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const beforeSearch = (e) => {
|
const beforeSearch = (e) => {
|
||||||
|
const notfound = document.getElementById('notfound');
|
||||||
|
if (notfound) notfound.innerHTML = '';
|
||||||
const start = document.querySelector('#start');
|
const start = document.querySelector('#start');
|
||||||
if (start) {
|
if (start) {
|
||||||
start.value = '0';
|
start.value = '0';
|
||||||
@@ -23,16 +25,22 @@ import { SignedIn } from "@clerk/astro/components";
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SignedIn>
|
<Show when="signed-in">
|
||||||
<form class="d-flex ms-2" role="search" id="searchform" hx-post="/partials/cards" hx-target="#cardGrid" hx-trigger="load, submit" hx-vals='{"start":"0"}' hx-on--after-request="afterUpdate()" hx-on--before-request="beforeSearch()">
|
<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 me-2" data-bs-toggle="offcanvas" href="#filterBar" role="button" aria-controls="filterBar"><span class="d-block d-md-none filter-icon mt-1"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg></span><span class="d-none d-md-block">Filters</span></a>
|
<a class="btn btn-secondary btn-lg" 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">
|
<div class="input-group">
|
||||||
<input type="hidden" name="start" id="start" value="0" />
|
<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..." />
|
<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 });">
|
<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 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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 phantasmal_flames from "/src/svg/set/phantasmal_flames.svg?raw";
|
||||||
import destined_rivals from "/src/svg/set/destined_rivals.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 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;
|
const { set } = Astro.props;
|
||||||
|
|
||||||
@@ -130,7 +132,7 @@ const setMap = {
|
|||||||
"JU": jungle,
|
"JU": jungle,
|
||||||
"FO": fossil,
|
"FO": fossil,
|
||||||
"B2": base_set_2,
|
"B2": base_set_2,
|
||||||
"TR": battle_styles,
|
"TR": team_rocket,
|
||||||
"G1": gym_heroes,
|
"G1": gym_heroes,
|
||||||
"G2": gym_challenge,
|
"G2": gym_challenge,
|
||||||
"SI": southern_islands,
|
"SI": southern_islands,
|
||||||
@@ -251,9 +253,11 @@ const setMap = {
|
|||||||
"ASC": ascended_heroes,
|
"ASC": ascended_heroes,
|
||||||
"DRI": destined_rivals,
|
"DRI": destined_rivals,
|
||||||
"SSP": surging_sparks,
|
"SSP": surging_sparks,
|
||||||
|
"ME03": perfect_order,
|
||||||
};
|
};
|
||||||
|
|
||||||
const svg = setMap[set as keyof typeof setMap] ?? "";
|
const svg = setMap[set as keyof typeof setMap] ?? "";
|
||||||
|
if (!svg && set) console.warn(`No set icon found for: ${set}`);
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="set-icon shadow-filter" set:html={svg}></div>
|
<div class="set-icon shadow-filter" role="img" aria-label={set} set:html={svg}></div>
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
// src/db/index.ts
|
// src/db/index.ts
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { relations } from './relations.ts';
|
import { relations } from './relations.ts';
|
||||||
import { drizzle } from 'drizzle-orm/mysql2';
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
import mysql from 'mysql2/promise';
|
import { Pool } from "pg";
|
||||||
|
|
||||||
//export const poolConnection = mysql.createPool({ uri: process.env.DATABASE_URL, client_found_rows: false });
|
const pool = new Pool({
|
||||||
export const poolConnection = mysql.createPool({ uri: process.env.DATABASE_URL, flags: ["-FOUND_ROWS"] });
|
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";
|
import * as schema from "./schema.ts";
|
||||||
|
|
||||||
export const relations = defineRelations(schema, (r) => ({
|
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: {
|
skus: {
|
||||||
card: r.one.cards({
|
card: r.one.cards({
|
||||||
from: [r.skus.productId, r.skus.variant],
|
from: [r.skus.productId, r.skus.variant],
|
||||||
to: [r.cards.productId, r.cards.variant],
|
to: [r.cards.productId, r.cards.variant],
|
||||||
}),
|
}),
|
||||||
|
history: r.many.priceHistory(),
|
||||||
|
latestSales: r.many.salesHistory(),
|
||||||
},
|
},
|
||||||
cards: {
|
cards: {
|
||||||
prices: r.many.skus(),
|
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", {
|
export const pokeSchema = pgSchema("pokemon");
|
||||||
productId: int().primaryKey(),
|
|
||||||
|
export const tcgcards = pokeSchema.table('tcg_cards', {
|
||||||
|
productId: integer().primaryKey(),
|
||||||
productName: varchar({ length: 255 }).notNull(),
|
productName: varchar({ length: 255 }).notNull(),
|
||||||
productLineName: varchar({ length: 255 }).default("").notNull(),
|
productLineName: varchar({ length: 255 }).default("").notNull(),
|
||||||
productLineUrlName: varchar({ length: 255 }).default("").notNull(),
|
productLineUrlName: varchar({ length: 255 }).default("").notNull(),
|
||||||
productStatusId: int().default(0).notNull(),
|
productStatusId: integer().default(0).notNull(),
|
||||||
productTypeId: int().default(0).notNull(),
|
productTypeId: integer().default(0).notNull(),
|
||||||
productUrlName: varchar({ length: 255 }).default("").notNull(),
|
productUrlName: varchar({ length: 255 }).default("").notNull(),
|
||||||
rarityName: varchar({ length: 100 }).default("").notNull(),
|
rarityName: varchar({ length: 100 }).default("").notNull(),
|
||||||
sealed: boolean().default(false).notNull(),
|
sealed: boolean().default(false).notNull(),
|
||||||
sellerListable: boolean().default(false).notNull(),
|
sellerListable: boolean().default(false).notNull(),
|
||||||
setId: int(),
|
setId: integer(),
|
||||||
shippingCategoryId: int(),
|
shippingCategoryId: integer(),
|
||||||
duplicate: boolean().default(false).notNull(),
|
duplicate: boolean().default(false).notNull(),
|
||||||
foilOnly: boolean().default(false).notNull(),
|
foilOnly: boolean().default(false).notNull(),
|
||||||
maxFulfillableQuantity: int(),
|
maxFulfillableQuantity: integer(),
|
||||||
totalListings: int(),
|
totalListings: integer(),
|
||||||
score: decimal({ precision: 10, scale: 2, mode: 'number' }),
|
score: decimal({ precision: 10, scale: 2, mode: 'number' }),
|
||||||
lowestPrice: decimal({ precision: 10, scale: 2, mode: 'number' }),
|
lowestPrice: decimal({ precision: 10, scale: 2, mode: 'number' }),
|
||||||
lowestPriceWithShipping: 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 }),
|
cardTypeB: varchar({ length: 100 }),
|
||||||
energyType: varchar({ length: 100 }),
|
energyType: varchar({ length: 100 }),
|
||||||
flavorText: varchar({ length: 1000 }),
|
flavorText: varchar({ length: 1000 }),
|
||||||
hp: int(),
|
hp: integer(),
|
||||||
number: varchar({ length: 50 }).default("").notNull(),
|
number: varchar({ length: 50 }).default("").notNull(),
|
||||||
releaseDate: datetime(),
|
releaseDate: timestamp(),
|
||||||
resistance: varchar({ length: 100 }),
|
resistance: varchar({ length: 100 }),
|
||||||
retreatCost: varchar({ length: 100 }),
|
retreatCost: varchar({ length: 100 }),
|
||||||
stage: varchar({ length: 100 }),
|
stage: varchar({ length: 100 }),
|
||||||
weakness: varchar({ length: 100 }),
|
weakness: varchar({ length: 100 }),
|
||||||
Artist: varchar({ length: 255 }),
|
artist: varchar({ length: 255 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const cards = mysqlTable("cards", {
|
export const cards = pokeSchema.table('cards', {
|
||||||
cardId: int().notNull().primaryKey().autoincrement(),
|
cardId: integer().notNull().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
productId: int().notNull(),
|
productId: integer().notNull(),
|
||||||
variant: varchar({ length: 100 }).notNull(),
|
variant: varchar({ length: 100 }).notNull(),
|
||||||
productName: varchar({ length: 255 }),
|
productName: varchar({ length: 255 }),
|
||||||
productLineName: varchar({ length: 255 }),
|
productLineName: varchar({ length: 255 }),
|
||||||
productUrlName: varchar({ length: 255 }).default("").notNull(),
|
productUrlName: varchar({ length: 255 }).default("").notNull(),
|
||||||
rarityName: varchar({ length: 100 }),
|
rarityName: varchar({ length: 100 }),
|
||||||
sealed: boolean().default(false).notNull(),
|
sealed: boolean().default(false).notNull(),
|
||||||
setId: int(),
|
setId: integer(),
|
||||||
cardType: varchar({ length: 100 }),
|
cardType: varchar({ length: 100 }),
|
||||||
energyType: varchar({ length: 100 }),
|
energyType: varchar({ length: 100 }),
|
||||||
number: varchar({ length: 50 }),
|
number: varchar({ length: 50 }),
|
||||||
Artist: varchar({ length: 255 }),
|
artist: varchar({ length: 255 }),
|
||||||
},
|
},
|
||||||
(table) => [
|
(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", {
|
export const tcg_overrides = pokeSchema.table('tcg_overrides', {
|
||||||
productId: int().primaryKey(),
|
productId: integer().primaryKey(),
|
||||||
productName: varchar({ length: 255 }),
|
productName: varchar({ length: 255 }),
|
||||||
productLineName: varchar({ length: 255 }),
|
productLineName: varchar({ length: 255 }),
|
||||||
productUrlName: varchar({ length: 255 }).default("").notNull(),
|
productUrlName: varchar({ length: 255 }).default('').notNull(),
|
||||||
rarityName: varchar({ length: 100 }),
|
rarityName: varchar({ length: 100 }),
|
||||||
sealed: boolean().default(false).notNull(),
|
sealed: boolean().default(false).notNull(),
|
||||||
setId: int(),
|
setId: integer(),
|
||||||
cardType: varchar({ length: 100 }),
|
cardType: varchar({ length: 100 }),
|
||||||
energyType: varchar({ length: 100 }),
|
energyType: varchar({ length: 100 }),
|
||||||
number: varchar({ length: 50 }),
|
number: varchar({ length: 50 }),
|
||||||
Artist: varchar({ length: 255 }),
|
artist: varchar({ length: 255 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sets = mysqlTable("sets", {
|
export const sets = pokeSchema.table('sets', {
|
||||||
setId: int().primaryKey(),
|
setId: integer().primaryKey(),
|
||||||
setName: varchar({ length: 255 }).notNull(),
|
setName: varchar({ length: 255 }).notNull(),
|
||||||
setUrlName: varchar({ length: 255 }).notNull(),
|
setUrlName: varchar({ length: 255 }).notNull(),
|
||||||
setCode: varchar({ length: 100 }).notNull(),
|
setCode: varchar({ length: 100 }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const skus = mysqlTable("skus", {
|
export const skus = pokeSchema.table('skus', {
|
||||||
skuId: int().primaryKey(),
|
skuId: integer().primaryKey(),
|
||||||
cardId: int().default(0).notNull(),
|
cardId: integer().default(0).notNull(),
|
||||||
productId: int().notNull(),
|
productId: integer().notNull(),
|
||||||
condition: varchar({ length: 255 }).notNull(),
|
condition: varchar({ length: 255 }).notNull(),
|
||||||
language: varchar({ length: 100 }).notNull(),
|
language: varchar({ length: 100 }).notNull(),
|
||||||
variant: varchar({ length: 100 }).notNull(),
|
variant: varchar({ length: 100 }).notNull(),
|
||||||
calculatedAt: datetime(),
|
calculatedAt: timestamp(),
|
||||||
highestPrice: decimal({ precision: 10, scale: 2 }),
|
highestPrice: decimal({ precision: 10, scale: 2 }),
|
||||||
lowestPrice: decimal({ precision: 10, scale: 2 }),
|
lowestPrice: decimal({ precision: 10, scale: 2 }),
|
||||||
marketPrice: decimal({ precision: 10, scale: 2 }),
|
marketPrice: decimal({ precision: 10, scale: 2 }),
|
||||||
priceCount: int(),
|
priceCount: integer(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(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", {
|
export const priceHistory = pokeSchema.table('price_history', {
|
||||||
skuId: int().primaryKey(),
|
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';
|
import '/src/assets/css/main.scss';
|
||||||
|
const { title } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-bs-theme="dark">
|
<html lang="en" data-bs-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<script is:inline>
|
|
||||||
window.dataLayer = window.dataLayer || [];
|
|
||||||
</script>
|
|
||||||
<!-- Google Tag Manager -->
|
<!-- Google Tag Manager -->
|
||||||
<script is:inline>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
<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],
|
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||||
@@ -17,9 +15,10 @@ import '/src/assets/css/main.scss';
|
|||||||
<!-- End Google Tag Manager -->
|
<!-- End Google Tag Manager -->
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="htmx-config" content='{"historyCacheSize": 50}'/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<title>Rigid's App Thing</title>
|
<title>{title}</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Google Tag Manager (noscript) -->
|
<!-- Google Tag Manager (noscript) -->
|
||||||
@@ -39,6 +38,8 @@ import '/src/assets/css/main.scss';
|
|||||||
</div>
|
</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/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 src="../assets/js/main.js"></script>
|
||||||
|
<script>import '../assets/js/priceChart.js';</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -7,8 +7,11 @@ const isProtectedRoute = createRouteMatcher([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export const onRequest = clerkMiddleware((auth, context) => {
|
export const onRequest = clerkMiddleware((auth, context) => {
|
||||||
if (!auth().userId && isProtectedRoute(context.request)) {
|
const { isAuthenticated, redirectToSignIn } = auth()
|
||||||
// Redirect unauthenticated users to the sign-in page
|
|
||||||
return auth().redirectToSignIn();
|
if (!isAuthenticated && isProtectedRoute(context.request)) {
|
||||||
|
// Add custom logic to run before redirecting
|
||||||
|
|
||||||
|
return redirectToSignIn()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
---
|
---
|
||||||
|
export const prerender = false;
|
||||||
import Layout from '../layouts/Main.astro';
|
import Layout from '../layouts/Main.astro';
|
||||||
import NavItems from '../components/NavItems.astro';
|
import NavItems from '../components/NavItems.astro';
|
||||||
import NavBar from '../components/NavBar.astro';
|
import NavBar from '../components/NavBar.astro';
|
||||||
export const prerender = false;
|
|
||||||
import pokedexList from '../data/pokedex.json';
|
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
|
import pokedexList from '../data/pokedex.json';
|
||||||
const searchParams = Astro.url.searchParams;
|
|
||||||
const query = searchParams.get('q') || '*';
|
|
||||||
|
|
||||||
// Get random # (0001–1025)
|
// Get random # (0001–1025)
|
||||||
const randomNumber = String(Math.floor(Math.random() * 1025) + 1).padStart(4, "0");
|
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
|
// If not found (rare), fallback
|
||||||
const pokemonName = pokemon?.Name || "Unknown Pokémon";
|
const pokemonName = pokemon?.Name || "Unknown Pokémon";
|
||||||
---
|
---
|
||||||
<Layout>
|
<Layout title="404 - Page Not Found">
|
||||||
<NavBar slot="navbar">
|
<NavBar slot="navbar">
|
||||||
<NavItems slot="navItems" />
|
<NavItems slot="navItems" />
|
||||||
</NavBar>
|
</NavBar>
|
||||||
<div class="row mb-4" slot="page">
|
<div class="row mb-4" slot="page">
|
||||||
<div class="col-12 col-md-6">
|
<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>
|
<h4>Sorry, the page you are looking for does not exist.</h4>
|
||||||
<p class="copy-big my-4">
|
<p class="copy-big my-4">
|
||||||
Return to the <a href="/">home page</a> or search for another <a href="/pokemon">Pokémon</a>.
|
Return to the <a href="/">home page</a> or search for another <a href="/pokemon">Pokémon</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-5 offset-md-1">
|
<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>
|
<h4 class="alert-heading">Who's that Pokémon?</h4>
|
||||||
<p class="mb-0">Click the image to reveal.</p>
|
<p class="mb-0">Click the image to reveal.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-0 ratio ratio-1x1 position-relative overflow-hidden d-flex justify-items-center">
|
<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="d-flex flex-column-reverse flex-lg-row">
|
||||||
<div class="">
|
<div>
|
||||||
<img class="w-100 starburst top-0 bottom-0 left-0 right-0" src="/404/glow.png">
|
<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
|
||||||
<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')});"/>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pokémon name reveal -->
|
<!-- Pokémon name reveal -->
|
||||||
<div class="col-12 text-center mt-3">
|
<div class="col-12 text-center mt-3">
|
||||||
<h3 id="pokemon-name" class="opacity-0 transition-opacity">???</h3>
|
<h3
|
||||||
|
id="pokemon-name"
|
||||||
|
class="opacity-0 pokemon-transition"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>???</h3>
|
||||||
|
<button
|
||||||
|
id="play-again"
|
||||||
|
class="btn btn-primary mt-3 opacity-0 pokemon-transition"
|
||||||
|
style="pointer-events: none;"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
Guess another Pokémon
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<Footer slot="footer" />
|
||||||
</Layout>
|
</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>
|
||||||
@@ -1,28 +1,37 @@
|
|||||||
---
|
---
|
||||||
|
export const prerender = false;
|
||||||
import Layout from '../layouts/Main.astro';
|
import Layout from '../layouts/Main.astro';
|
||||||
import NavItems from '../components/NavItems.astro';
|
import NavItems from '../components/NavItems.astro';
|
||||||
import NavBar from '../components/NavBar.astro';
|
import NavBar from '../components/NavBar.astro';
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
export const prerender = false;
|
|
||||||
---
|
---
|
||||||
<Layout>
|
<Layout title="Contact Us">
|
||||||
<NavBar slot="navbar">
|
<NavBar slot="navbar">
|
||||||
<NavItems slot="navItems" />
|
<NavItems slot="navItems" />
|
||||||
</NavBar>
|
</NavBar>
|
||||||
<div class="row mb-4" slot="page">
|
<div class="row mb-4" slot="page">
|
||||||
|
<div class="col-12">
|
||||||
<h1>Contact Us</h1>
|
<h1>Contact Us</h1>
|
||||||
|
</div>
|
||||||
<div class="col-12 col-md-8 col-lg-6">
|
<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">
|
<form action="https://docs.google.com/forms/u/0/d/e/1FAIpQLSc4F7VZjZ6ImWnNRqzMLyAWnyGQdEC3Nr2xtbzugewky239kg/formResponse" method="POST" id="contactForm" target="hidden-iframe">
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
<!-- Name input -->
|
<!-- Name input -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="name" class="form-label">Full Name</label>
|
<label for="name" class="form-label">Full Name</label>
|
||||||
<input type="text" class="form-control" id="name" name="entry.563494744" required>
|
<input type="text" class="form-control" id="name" name="entry.563494744" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Email address input -->
|
<!-- Email address input -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="email" class="form-label">Email address</label>
|
<label for="email" class="form-label">Email address</label>
|
||||||
<input type="email" class="form-control" id="email" name="entry.577942868" aria-describedby="emailHelp" required>
|
<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 id="emailHelp" class="form-text">We'll never share your email with anyone else.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -33,10 +42,51 @@ export const prerender = false;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit button -->
|
<!-- Submit button -->
|
||||||
<button type="submit" class="btn btn-light">Submit</button>
|
<button type="submit" class="btn btn-light" id="submitBtn">Submit</button>
|
||||||
</form>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<Footer slot="footer" />
|
<Footer slot="footer" />
|
||||||
</Layout>
|
</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,48 @@
|
|||||||
---
|
---
|
||||||
|
export const prerender = false;
|
||||||
import Layout from '../layouts/Main.astro';
|
import Layout from '../layouts/Main.astro';
|
||||||
import NavItems from '../components/NavItems.astro';
|
import NavItems from '../components/NavItems.astro';
|
||||||
import NavBar from '../components/NavBar.astro';
|
import NavBar from '../components/NavBar.astro';
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
export const prerender = false;
|
import { Show, SignInButton, SignUpButton, SignOutButton } from '@clerk/astro/components'
|
||||||
import { Waitlist as WaitlistAstro } from '@clerk/astro/components';
|
|
||||||
import { SignIn as SignInAstro } from '@clerk/astro/components';
|
|
||||||
import { Sign } from 'node:crypto';
|
|
||||||
---
|
---
|
||||||
<Layout>
|
<Layout title="Rigid's App Thing">
|
||||||
<NavBar slot="navbar">
|
<NavBar slot="navbar">
|
||||||
<NavItems slot="navItems" />
|
<NavItems slot="navItems" />
|
||||||
</NavBar>
|
</NavBar>
|
||||||
<div class="row mb-4" slot="page">
|
<div class="row mb-4" slot="page">
|
||||||
|
<div class="col-12">
|
||||||
<h1>Rigid's App Thing</h1>
|
<h1>Rigid's App Thing</h1>
|
||||||
<h5 class="text-secondary">(working title)</h5>
|
<p class="text-secondary">(working title)</p>
|
||||||
<div class="col-12 col-md-7 mb-2">
|
</div>
|
||||||
<h4 class="mt-3">Welcome!</h4>
|
<div class="col-12 col-md-6 mb-2">
|
||||||
|
<h2 class="mt-3">Welcome!</h2>
|
||||||
<p class="mt-2">
|
<p class="mt-2">
|
||||||
You've been selected to participate in the closed beta! This single page web application is meant to elevate condition/variant data for the Pokemon TCG. In future iterations, we will add vendor inventory/collection management features, as well.</p>
|
You've been selected to participate in the closed beta! This single page web application is meant to elevate condition/variant data for the Pokemon TCG. In future iterations, we will add vendor inventory/collection management features, as well.
|
||||||
</p>
|
</p>
|
||||||
<p class="my-2">
|
<p class="my-2">
|
||||||
After the closed beta is complete, the app will move into a more open beta. Feel free to play "Who's that Pokémon?" with the random Pokémon generator <a href="/404">here</a>. Refresh the page to see a new Pokémon!
|
After the closed beta is complete, the app will move into a more open beta. Feel free to play "Who's that Pokémon?" with the random Pokémon generator <a href="/404">here</a>. Refresh the page to see a new Pokémon!
|
||||||
</p>
|
</p>
|
||||||
|
<Show when="signed-in">
|
||||||
<a href="/pokemon" class="btn btn-warning mt-2">Take me to the cards</a>
|
<a href="/pokemon" class="btn btn-warning mt-2">Take me to the cards</a>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 d-flex justify-content-end align-items-start mt-3">
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<Show when="signed-out">
|
||||||
|
<SignInButton asChild mode="modal">
|
||||||
|
<button class="btn btn-success">Sign In</button>
|
||||||
|
</SignInButton>
|
||||||
|
<SignUpButton asChild mode="modal">
|
||||||
|
<button class="btn btn-dark">Request Access</button>
|
||||||
|
</SignUpButton>
|
||||||
|
</Show>
|
||||||
|
<Show when="signed-in">
|
||||||
|
<SignOutButton asChild>
|
||||||
|
<button class="btn btn-danger">Sign Out</button>
|
||||||
|
</SignOutButton>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-4 offset-md-1">
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Footer slot="footer" />
|
<Footer slot="footer" />
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import SetIcon from '../../components/SetIcon.astro';
|
|||||||
import EnergyIcon from '../../components/EnergyIcon.astro';
|
import EnergyIcon from '../../components/EnergyIcon.astro';
|
||||||
import RarityIcon from '../../components/RarityIcon.astro';
|
import RarityIcon from '../../components/RarityIcon.astro';
|
||||||
import { db } from '../../db/index';
|
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 partial = true;
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
@@ -12,8 +16,6 @@ export const prerender = false;
|
|||||||
const searchParams = Astro.url.searchParams;
|
const searchParams = Astro.url.searchParams;
|
||||||
const cardId = Number(searchParams.get('cardId')) || 0;
|
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({
|
const card = await db.query.cards.findFirst({
|
||||||
where: { cardId: Number(cardId) },
|
where: { cardId: Number(cardId) },
|
||||||
with: {
|
with: {
|
||||||
@@ -24,9 +26,7 @@ const card = await db.query.cards.findFirst({
|
|||||||
|
|
||||||
function timeAgo(date: Date | null) {
|
function timeAgo(date: Date | null) {
|
||||||
if (!date) return "Not applicable";
|
if (!date) return "Not applicable";
|
||||||
|
|
||||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
|
|
||||||
const intervals: Record<string, number> = {
|
const intervals: Record<string, number> = {
|
||||||
year: 31536000,
|
year: 31536000,
|
||||||
month: 2592000,
|
month: 2592000,
|
||||||
@@ -34,189 +34,354 @@ function timeAgo(date: Date | null) {
|
|||||||
hour: 3600,
|
hour: 3600,
|
||||||
minute: 60
|
minute: 60
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [unit, value] of Object.entries(intervals)) {
|
for (const [unit, value] of Object.entries(intervals)) {
|
||||||
const count = Math.floor(seconds / value);
|
const count = Math.floor(seconds / value);
|
||||||
if (count >= 1) return `${count} ${unit}${count > 1 ? "s" : ""} ago`;
|
if (count >= 1) return `${count} ${unit}${count > 1 ? "s" : ""} ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "just now";
|
return "just now";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the most recent calculatedAt across all prices
|
|
||||||
const calculatedAt = (() => {
|
const calculatedAt = (() => {
|
||||||
if (!card?.prices?.length) return null;
|
if (!card?.prices?.length) return null;
|
||||||
|
|
||||||
// Extract all valid calculatedAt timestamps
|
|
||||||
const dates = card.prices
|
const dates = card.prices
|
||||||
.map(p => p.calculatedAt)
|
.map(p => p.calculatedAt)
|
||||||
.filter(d => d) // remove null/undefined
|
.filter(d => d)
|
||||||
.map(d => new Date(d));
|
.map(d => new Date(d));
|
||||||
|
|
||||||
if (!dates.length) return null;
|
if (!dates.length) return null;
|
||||||
|
|
||||||
// Return the most recent one
|
|
||||||
return new Date(Math.max(...dates.map(d => d.getTime())));
|
return new Date(Math.max(...dates.map(d => d.getTime())));
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ── Fetch price history + compute volatility ──────────────────────────────
|
||||||
|
const cardSkus = card?.prices?.length
|
||||||
|
? await db.select().from(skus).where(eq(skus.cardId, cardId))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const skuIds = cardSkus.map(s => s.skuId);
|
||||||
|
|
||||||
|
const historyRows = skuIds.length
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
skuId: priceHistory.skuId,
|
||||||
|
calculatedAt: priceHistory.calculatedAt,
|
||||||
|
marketPrice: priceHistory.marketPrice,
|
||||||
|
condition: skus.condition,
|
||||||
|
})
|
||||||
|
.from(priceHistory)
|
||||||
|
.innerJoin(skus, eq(priceHistory.skuId, skus.skuId))
|
||||||
|
.where(inArray(priceHistory.skuId, skuIds))
|
||||||
|
.orderBy(priceHistory.calculatedAt)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Rolling 30-day cutoff for volatility calculation
|
||||||
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000);
|
||||||
|
|
||||||
|
const byCondition: Record<string, number[]> = {};
|
||||||
|
for (const row of historyRows) {
|
||||||
|
if (row.marketPrice == null) continue;
|
||||||
|
if (!row.calculatedAt) continue;
|
||||||
|
if (new Date(row.calculatedAt) < thirtyDaysAgo) continue;
|
||||||
|
const price = Number(row.marketPrice);
|
||||||
|
if (price <= 0) continue;
|
||||||
|
if (!byCondition[row.condition]) byCondition[row.condition] = [];
|
||||||
|
byCondition[row.condition].push(price);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeVolatility(prices: number[]): { label: string; monthlyVol: number } {
|
||||||
|
if (prices.length < 2) return { label: '—', monthlyVol: 0 };
|
||||||
|
const returns: number[] = [];
|
||||||
|
for (let i = 1; i < prices.length; i++) {
|
||||||
|
returns.push(Math.log(prices[i] / prices[i - 1]));
|
||||||
|
}
|
||||||
|
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
|
||||||
|
const variance = returns.reduce((sum, r) => sum + (r - mean) ** 2, 0) / (returns.length - 1);
|
||||||
|
const monthlyVol = Math.sqrt(variance) * Math.sqrt(30);
|
||||||
|
const label = monthlyVol >= 0.30 ? 'High'
|
||||||
|
: monthlyVol >= 0.15 ? 'Medium'
|
||||||
|
: 'Low';
|
||||||
|
return { label, monthlyVol: Math.round(monthlyVol * 100) / 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const volatilityByCondition: Record<string, { label: string; monthlyVol: number }> = {};
|
||||||
|
for (const [condition, prices] of Object.entries(byCondition)) {
|
||||||
|
volatilityByCondition[condition] = computeVolatility(prices);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Price history for chart (full history, not windowed) ──────────────────
|
||||||
|
const priceHistoryForChart = historyRows.map(row => ({
|
||||||
|
condition: row.condition,
|
||||||
|
calculatedAt: row.calculatedAt
|
||||||
|
? new Date(row.calculatedAt).toISOString().split('T')[0]
|
||||||
|
: null,
|
||||||
|
marketPrice: row.marketPrice,
|
||||||
|
})).filter(r => r.calculatedAt !== null);
|
||||||
|
|
||||||
|
// ── Determine which range buttons to show ────────────────────────────────
|
||||||
|
const now = Date.now();
|
||||||
|
const oldestDate = historyRows.length
|
||||||
|
? Math.min(...historyRows
|
||||||
|
.filter(r => r.calculatedAt)
|
||||||
|
.map(r => new Date(r.calculatedAt!).getTime()))
|
||||||
|
: now;
|
||||||
|
|
||||||
|
const dataSpanDays = (now - oldestDate) / 86_400_000;
|
||||||
|
|
||||||
|
const showRanges = {
|
||||||
|
'1m': dataSpanDays >= 1,
|
||||||
|
'3m': dataSpanDays >= 60,
|
||||||
|
'6m': dataSpanDays >= 180,
|
||||||
|
'1y': dataSpanDays >= 365,
|
||||||
|
'all': dataSpanDays >= 400,
|
||||||
|
};
|
||||||
|
|
||||||
const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
|
const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
|
||||||
|
|
||||||
const conditionAttributes = (price: any) => {
|
const conditionAttributes = (price: any) => {
|
||||||
const volatility = (() => {
|
const condition: string = price?.condition || "Near Mint";
|
||||||
const current = price?.marketPrice;
|
const vol = volatilityByCondition[condition] ?? { label: '—', monthlyVol: 0 };
|
||||||
const low = price?.lowestPrice;
|
|
||||||
const high = price?.highestPrice;
|
|
||||||
const median = price?.medianPrice;
|
|
||||||
|
|
||||||
if (current === null || low === null || high === null) return "—";
|
const volatilityClass = (() => {
|
||||||
|
switch (vol.label) {
|
||||||
const range = Number(high) - Number(low);
|
case "High": return "alert-danger";
|
||||||
if (range <= 0) return "Low";
|
case "Medium": return "alert-warning";
|
||||||
|
case "Low": return "alert-success";
|
||||||
const position = (Number(current) - Number(low)) / range;
|
default: return "alert-dark";
|
||||||
if (position > 0.76) return "High";
|
}
|
||||||
if (position > 0.49) return "Medium";
|
|
||||||
return "Low";
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const volatilityClass =
|
const volatilityDisplay = vol.label === '—'
|
||||||
volatility === "High" ? "alert-danger" :
|
? '—'
|
||||||
volatility === "Medium" ? "alert-warning" :
|
: `${vol.label} (${(vol.monthlyVol * 100).toFixed(0)}%)`;
|
||||||
volatility === "Low" ? "alert-success" :
|
|
||||||
"";
|
|
||||||
|
|
||||||
const condition: string = price?.condition || "Near Mint";
|
|
||||||
return {
|
return {
|
||||||
"Near Mint": { label: "nav-nm", volatility: volatility, volatilityClass: volatilityClass, class:"show active" },
|
"Near Mint": { label: "nav-nm", volatility: volatilityDisplay, volatilityClass, class: "show active" },
|
||||||
"Lightly Played": { label: "nav-lp", volatility: volatility, volatilityClass: volatilityClass },
|
"Lightly Played": { label: "nav-lp", volatility: volatilityDisplay, volatilityClass },
|
||||||
"Moderately Played": { label: "nav-mp", volatility: volatility, volatilityClass: volatilityClass },
|
"Moderately Played":{ label: "nav-mp", volatility: volatilityDisplay, volatilityClass },
|
||||||
"Heavily Played": { label: "nav-hp", volatility: volatility, volatilityClass: volatilityClass },
|
"Heavily Played": { label: "nav-hp", volatility: volatilityDisplay, volatilityClass },
|
||||||
"Damaged": { label: "nav-dmg", volatility: volatility, volatilityClass: volatilityClass},
|
"Damaged": { label: "nav-dmg", volatility: volatilityDisplay, volatilityClass }
|
||||||
}[condition];
|
}[condition];
|
||||||
};
|
};
|
||||||
|
|
||||||
const ebaySearchUrl = (card: any) => {
|
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`;
|
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-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
|
<div class="modal-content" data-card-id={card?.cardId}>
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header border-0">
|
<div class="modal-header border-0">
|
||||||
<div class="container-fluid row align-items-center">
|
<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="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?.number}</div>
|
||||||
<div class="text-secondary col-auto">{card?.variant}</div>
|
<div class="text-light col-auto">{card?.variant}</div>
|
||||||
</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>
|
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="modal-body pt-0">
|
<div class="modal-body pt-0">
|
||||||
<div class="container-fluid">
|
<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="row g-4">
|
||||||
|
|
||||||
|
<!-- Card image column -->
|
||||||
<div class="col-sm-12 col-md-3">
|
<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="position-relative mt-1">
|
||||||
<div class="d-flex flex-row justify-content-between mt-2">
|
|
||||||
<div class="p text-secondary">{card?.set?.setCode}</div>
|
<!-- card-image-wrap gives the modal image shimmer effects
|
||||||
<div class="p text-secondary">Illustrator: {card?.Artist}</div>
|
without the hover lift/scale that image-grow has in main.scss -->
|
||||||
|
<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={`/cards/${card?.productId}.jpg`}
|
||||||
|
class="card-image w-100 img-fluid rounded-4"
|
||||||
|
alt={card?.productName}
|
||||||
|
crossorigin="anonymous"
|
||||||
|
onerror="this.onerror=null; this.src='/cards/default.jpg'; this.closest('.image-grow, .card-image-wrap')?.setAttribute('data-default','true')"
|
||||||
|
onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});"
|
||||||
|
/>
|
||||||
|
<div class="holo-shine"></div>
|
||||||
|
<div class="holo-glare"></div>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs + price data column -->
|
||||||
<div class="col-sm-12 col-md-7">
|
<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">
|
<ul class="nav nav-tabs nav-fill border-0 me-3 mb-2" id="myTab" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<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>
|
<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>
|
||||||
<li class="nav-item" role="presentation">
|
<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>
|
<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>
|
||||||
<li class="nav-item" role="presentation">
|
<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>
|
<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>
|
||||||
<li class="nav-item" role="presentation">
|
<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>
|
<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>
|
||||||
<li class="nav-item" role="presentation">
|
<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>
|
<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>
|
||||||
<li class="nav-item" role="presentation">
|
<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>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content" id="myTabContent">
|
<div class="tab-content" id="myTabContent">
|
||||||
{card?.prices.slice().sort((a, b) => conditionOrder.indexOf(a.condition) - conditionOrder.indexOf(b.condition)).map((price) => {
|
{card?.prices.slice().sort((a, b) => conditionOrder.indexOf(a.condition) - conditionOrder.indexOf(b.condition)).map((price) => {
|
||||||
const attributes = conditionAttributes(price);
|
const attributes = conditionAttributes(price);
|
||||||
return (
|
return (
|
||||||
<div class={`tab-pane fade ${attributes?.label} ${attributes?.class}`} id={`${attributes?.label}`} role="tabpanel" tabindex="0">
|
<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-column gap-1">
|
||||||
<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">
|
<!-- Stat cards -->
|
||||||
<h6>Market Price</h6>
|
<div class="d-flex flex-fill flex-row gap-1">
|
||||||
<p class="pb-0">${price.marketPrice}</p>
|
<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>
|
||||||
<div class="alert alert-secondary rounded p-2 flex-fill mb-1">
|
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
|
||||||
<h6>Lowest Price</h6>
|
<h6 class="mb-auto">Lowest Price</h6>
|
||||||
<p class="pb-0">${price.lowestPrice}</p>
|
<p class="mb-0 mt-1">${price.lowestPrice}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="alert alert-secondary rounded p-2 flex-fill mb-1">
|
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
|
||||||
<h6>Highest Price</h6>
|
<h6 class="mb-auto">Highest Price</h6>
|
||||||
<p class="pb-0">${price.highestPrice}</p>
|
<p class="mb-0 mt-1">${price.highestPrice}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class={`alert alert-secondary rounded p-2 flex-fill mb-1 ${attributes?.volatilityClass}`}>
|
<div class={`alert rounded p-2 flex-fill d-flex flex-column mb-0 ${attributes?.volatilityClass}`}>
|
||||||
<h6>Volatility</h6>
|
<h6 class="mb-auto d-flex justify-content-between align-items-start">
|
||||||
<p class="pb-0">{attributes?.volatility}</p>
|
<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'>Monthly Volatility</div>
|
||||||
|
<div class='small'>
|
||||||
|
<p class="mb-1">
|
||||||
|
<strong>What this measures:</strong> how much the market price tends to move day-to-day,
|
||||||
|
scaled up to a monthly expectation.
|
||||||
|
</p>
|
||||||
|
<p class="mb-0">
|
||||||
|
A card with <strong>30% volatility</strong> typically swings ±30% over a month.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 16 16"> <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/> <path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/> </svg>
|
||||||
|
</span>
|
||||||
|
</h6>
|
||||||
|
<p class="mb-0 mt-1">{attributes?.volatility}</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<!-- Table only — chart is outside the tab panes -->
|
||||||
<h6>Latest Sales</h6>
|
<div class="w-100">
|
||||||
</div>
|
<div class="alert alert-dark rounded p-2 mb-0 table-responsive">
|
||||||
<div class="alert alert-secondary rounded p-2 mb-1">
|
<h6>Latest Verified Sales</h6>
|
||||||
<h6>Placeholder for graph</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0">
|
|
||||||
<div class="row g-2 mt-2">
|
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0"></div>
|
||||||
</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>
|
||||||
|
<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>
|
||||||
</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">
|
||||||
<div class="col-sm-12 col-md-2 mt-0 mt-md-5">
|
{showRanges['1m'] && <button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button>}
|
||||||
<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>
|
{showRanges['3m'] && <button type="button" class="btn btn-dark price-range-btn" data-range="3m">3M</button>}
|
||||||
<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>
|
{showRanges['6m'] && <button type="button" class="btn btn-dark price-range-btn" data-range="6m">6M</button>}
|
||||||
</div>
|
{showRanges['1y'] && <button type="button" class="btn btn-dark price-range-btn" data-range="1y">1Y</button>}
|
||||||
</div>
|
{showRanges['all'] && <button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>}
|
||||||
<div class="text-end my-0"><small class="text-body-secondary">Prices last changed: {timeAgo(calculatedAt)}</small></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script is:inline>
|
</div>
|
||||||
async function copyImage(img) {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
|
|
||||||
canvas.width = img.naturalWidth;
|
<!-- External links column -->
|
||||||
canvas.height = img.naturalHeight;
|
<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>
|
||||||
|
|
||||||
// draw the real image pixels
|
</div>
|
||||||
ctx.drawImage(img, 0, 0);
|
<div class="text-end my-0"><small class="text-body-tertiary">Prices last changed: {timeAgo(calculatedAt)}</small></div>
|
||||||
|
</div>
|
||||||
// convert to blob
|
</div>
|
||||||
canvas.toBlob(async (blob) => {
|
</div>
|
||||||
await navigator.clipboard.write([
|
</div>
|
||||||
new ClipboardItem({ "image/png": blob })
|
</div>
|
||||||
]);
|
|
||||||
console.log("Copied image via canvas.");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
---
|
---
|
||||||
import { client } from '../../db/typesense';
|
import { client } from '../../db/typesense';
|
||||||
import RarityIcon from '../../components/RarityIcon.astro';
|
import RarityIcon from '../../components/RarityIcon.astro';
|
||||||
|
import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
|
import * as util from 'util';
|
||||||
|
|
||||||
|
|
||||||
// all the facet fields we want to use for filtering
|
// all the facet fields we want to use for filtering
|
||||||
const facetFields:any = {
|
const facetFields:any = {
|
||||||
"productLineName": "Product Line",
|
//"productLineName": "Product Line",
|
||||||
"setName": "Set",
|
"setName": "Set",
|
||||||
"variant": "Variant",
|
"variant": "Variant",
|
||||||
"rarityName": "Rarity",
|
"rarityName": "Rarity",
|
||||||
@@ -14,13 +17,50 @@ const facetFields:any = {
|
|||||||
"energyType": "Energy Type"
|
"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
|
// get the query from post request using form data
|
||||||
const formData = await Astro.request.formData();
|
const formData = await Astro.request.formData();
|
||||||
const query = formData.get('q')?.toString() || '';
|
const query = formData.get('q')?.toString() || '';
|
||||||
const start = Number(formData.get('start')?.toString() || '0');
|
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 EREADER_SETS = ['Expedition Base Set', 'Aquapolis', 'Skyridge', 'Battle-e'];
|
||||||
|
const EREADER_RE = /^(e-?reader|e reader)$/i;
|
||||||
|
|
||||||
|
let resolvedQuery = query;
|
||||||
|
let queryFilter = '';
|
||||||
|
|
||||||
|
if (EREADER_RE.test(query.trim())) {
|
||||||
|
resolvedQuery = '';
|
||||||
|
queryFilter = `setName:=[${EREADER_SETS.map(s => '`' + s + '`').join(',')}]`;
|
||||||
|
}
|
||||||
|
|
||||||
const filters = Array.from(formData.entries())
|
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]) => {
|
.reduce((acc, [key, value]) => {
|
||||||
if (!acc[key]) {
|
if (!acc[key]) {
|
||||||
acc[key] = [];
|
acc[key] = [];
|
||||||
@@ -34,27 +74,28 @@ const filterChecked = (field: string, value: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filterBy = Object.entries(filters).map(([field, values]) => {
|
const filterBy = Object.entries(filters).map(([field, values]) => {
|
||||||
return `${field}:=[${values.join(',')}]`;
|
return `${field}:=[${values.map(v => '`'+v+'`').join(',')}]`;
|
||||||
}).join(' && ');
|
}).join(' && ');
|
||||||
|
|
||||||
const facetFilter = (facet:string) => {
|
const facetFilter = (facet:string) => {
|
||||||
const otherFilters = Object.entries(filters)
|
const otherFilters = Object.entries(filters)
|
||||||
.filter(([field]) => field !== facet)
|
.filter(([field]) => field !== facet)
|
||||||
.map(([field, values]) => `${field}:=[${values.join(',')}]`)
|
.map(([field, values]) => `${field}:=[${values.map(v => '`'+v+'`').join(',')}]`)
|
||||||
.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)
|
// primary search values (for cards)
|
||||||
let searchArray = [{
|
let searchArray = [{
|
||||||
collection: 'cards',
|
collection: 'cards',
|
||||||
filter_by: `sealed:false${filterBy ? ` && ${filterBy}` : ''}`,
|
filter_by: `sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${filterBy ? ` && ${filterBy}` : ''}`,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
facet_by: '',
|
facet_by: '',
|
||||||
max_facet_values: 0,
|
max_facet_values: 0,
|
||||||
page: Math.floor(start / 20) + 1,
|
page: Math.floor(start / 20) + 1,
|
||||||
sort_by: '_text_match:asc, releaseDate:desc, number:asc',
|
sort_by: resolvedSort,
|
||||||
include_fields: '$skus(*)',
|
include_fields: '$skus(*)',
|
||||||
}];
|
}];
|
||||||
|
|
||||||
@@ -76,8 +117,8 @@ if (start === 0) {
|
|||||||
|
|
||||||
const searchRequests = { searches: searchArray };
|
const searchRequests = { searches: searchArray };
|
||||||
const commonSearchParams = {
|
const commonSearchParams = {
|
||||||
q: query,
|
q: resolvedQuery,
|
||||||
query_by: 'productLineName,productName,setName,number,rarityName,Artist',
|
query_by: 'content,setName,productLineName,rarityName,energyType,cardType'
|
||||||
};
|
};
|
||||||
|
|
||||||
// use typesense to search for cards matching the query and return the productIds of the results
|
// use typesense to search for cards matching the query and return the productIds of the results
|
||||||
@@ -114,10 +155,23 @@ const facetNames = (name:string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const facets = searchResults.results.slice(1).map((result: any) => {
|
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) &&
|
{(start === 0) &&
|
||||||
|
|
||||||
<div id="facetContainer" hx-swap-oob="true">
|
<div id="facetContainer" hx-swap-oob="true">
|
||||||
@@ -144,22 +198,41 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div id="sortBy" class="d-flex flex-fill align-items-center me-auto gap-2" hx-swap-oob="true">
|
||||||
<div id="activeFilters" class="mb-2 d-flex align-items-center justify-content-end small" 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) &&
|
{(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">
|
<ul class="list-group list-group-horizontal">
|
||||||
{Object.entries(filters).map(([filter, values]) => (
|
{Object.entries(filters).map(([filter, values]) => (
|
||||||
values.map((value) => (
|
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>
|
</ul>
|
||||||
<span class="ms-2"><button type="button" class="btn-close" aria-label="Clear all filters" id="clear-all-filters"></button></span>
|
<span class="ms-2"><button type="button" class="btn-close" aria-label="Clear all filters" id="clear-all-filters"></button></span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<script define:vars={{ totalHits, filters, facets }} is:inline>
|
<script define:vars={{ totalHits, filters, facets }} is:inline>
|
||||||
|
|
||||||
// Filter the facet values to make things like Set easier to find
|
// Filter the facet values to make things like Set easier to find
|
||||||
@@ -213,8 +286,11 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
|||||||
<div class="inventory-button position-relative float-end shadow-filter text-center d-none">
|
<div class="inventory-button position-relative float-end shadow-filter text-center d-none">
|
||||||
<div class="inventory-label pt-2">+/-</div>
|
<div class="inventory-label pt-2">+/-</div>
|
||||||
</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'});">
|
<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'});">
|
||||||
<img src={`/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 card-image image-grow w-100" onerror="this.onerror=null;this.src='/cards/default.jpg'"/>
|
<div class="image-grow rounded-4 card-image" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}><img src={`/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='/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>
|
||||||
<div class="row row-cols-5 gx-1 price-row mb-2">
|
<div class="row row-cols-5 gx-1 price-row mb-2">
|
||||||
{conditionOrder.map((condition) => (
|
{conditionOrder.map((condition) => (
|
||||||
@@ -226,11 +302,11 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="h5 my-0">{card.productName}</div>
|
<div class="h5 my-0">{card.productName}</div>
|
||||||
<div class="d-flex flex-row lh-1 mt-1 justify-content-between">
|
<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 flex-grow-1 d-none d-lg-flex">{card.setName}</div>
|
||||||
<div class="text-secondary">{card.number}</div>
|
<div class="text-body-tertiary">{card.number}</div>
|
||||||
<span class="ps-2 small-icon"><RarityIcon rarity={card.rarityName} /></span>
|
<span class="ps-2 small-icon"><RarityIcon rarity={card.rarityName} /></span>
|
||||||
</div>
|
</div>
|
||||||
<div>{card.variant}</div><span class="d-none">{card.productId}</span>
|
<div class="text-body-tertiary">{card.variant}</div><span class="d-none">{card.productId}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
))}
|
))}
|
||||||
|
|||||||
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,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
|
export const prerender = false;
|
||||||
import Layout from '../layouts/Main.astro';
|
import Layout from '../layouts/Main.astro';
|
||||||
import Search from '../components/Search.astro';
|
import Search from '../components/Search.astro';
|
||||||
import CardGrid from "../components/CardGrid.astro";
|
import CardGrid from "../components/CardGrid.astro";
|
||||||
import NavBar from '../components/NavBar.astro';
|
import NavBar from '../components/NavBar.astro';
|
||||||
export const prerender = false;
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout title="Card Search">
|
||||||
<NavBar slot="navbar">
|
<NavBar slot="navbar">
|
||||||
<Search slot="searchInput" />
|
<Search slot="searchInput" />
|
||||||
</NavBar>
|
</NavBar>
|
||||||
|
|||||||
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 |