31 Commits

Author SHA1 Message Date
Thad Miller
a265aea424 [feat] minor tweaks to the inventory modal 2026-07-16 15:41:44 -04:00
zach
f0ee662450 Updated inventory modal styling and added inventory button to card grid 2026-07-16 08:53:33 -04:00
Thad Miller
b91b64f32c [feat] cleanup dashboard page and allow removal of inventory 2026-07-10 17:11:59 -04:00
Thad Miller
1e219b6c26 [feat] dashboard catalogs are real and clickable 2026-07-10 11:30:41 -04:00
Thad Miller
5abd6e4b92 [feat] inventory modal initiated 2026-07-09 21:36:42 -04:00
Thad Miller
4990c3aa04 [chore] npm update 2026-07-09 21:36:42 -04:00
Zach Harding
c0055ed713 replace productId with cardId in price history chart plotting 2026-06-04 15:18:27 -04:00
Zach Harding
96af72c7f4 update ebay link to have variant included in query 2026-06-04 14:20:39 -04:00
afae3f445e [feat] retry preload cards 3x 2026-05-29 22:25:09 -04:00
e7c71e1c75 [feat] rework card number to be int32 for sorting 2026-05-29 16:29:02 -04:00
9afc600e63 [bugfix] remove leading zeros from number 2026-05-28 21:15:29 -04:00
2cf47d2b15 [feat] tcg player import added to admin page 2026-05-28 16:12:48 -04:00
b0dbe7ced5 [feat] reindex added to admin 2026-05-28 15:14:18 -04:00
ae0f3d6683 [bugfix] allow local clerk to work 2026-05-28 14:42:50 -04:00
47f18348bf [feat] boost card retrieval speed by removing typesense join 2026-05-27 17:32:46 -04:00
Zach Harding
6517044821 replace setname with setcode on mobile view card 2026-05-26 16:14:57 -04:00
Zach Harding
1b5e77d55d fixed incorrect set abbreviation 2026-05-26 15:38:50 -04:00
Zach Harding
d43ef99fea new set icon/mapping 2026-05-26 15:37:19 -04:00
Zach Harding
a566b82036 fixed tooltip init for card modal 2026-05-26 14:59:58 -04:00
Zach Harding
48b0098c6f equal height columns for card images 2026-05-26 11:31:19 -04:00
c582a40894 [bugfix] revert previous change because pricing wasn't showing 2026-05-26 11:10:49 -04:00
55af3a2e3c [feat] remove sku filter for speed 2026-05-26 11:02:32 -04:00
601cb7b770 Merge branch 'feat/inventory' 2026-05-26 05:32:26 -04:00
Zach Harding
85cfd1de64 added the ability to search by artist name 2026-04-07 08:07:47 -04:00
Zach Harding
c03a0b36a0 removed ads, fixed copy image for newer iOS 2026-04-05 13:11:46 -04:00
Zach Harding
5cdf9b1772 ads placement - tbd 2026-04-05 10:17:43 -04:00
Zach Harding
17465b13c1 adsense verification, remove latest sales table, add new search mechanic (weight, synonyms), fix low volatility (NaN%) 2026-04-01 17:43:47 -04:00
Zach Harding
c61cafecdc added /admin for admin panel - limited to users in the admin role (also updated local .env to match prod keys for clerk) 2026-03-28 16:52:53 -04:00
Zach Harding
2b3d5f322e Merge branch 'master' of papi.tkpups.com:tmiller/pokemon 2026-03-25 14:32:43 -04:00
Zach Harding
53cdddb183 correct sort by market price by adding a back a roll-up market price from skus to product ID during sync 2026-03-25 14:31:46 -04:00
35c8bf25f5 [bugfix] update static path on scripts 2026-03-25 13:42:19 -04:00
26 changed files with 2201 additions and 2006 deletions

2337
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,18 +9,18 @@
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^9.5.4", "@astrojs/node": "^10.1.4",
"@clerk/astro": "^3.0.1", "@clerk/astro": "^3.0.1",
"@clerk/shared": "^4.0.0", "@clerk/shared": "^4.0.0",
"@clerk/themes": "^2.4.55", "@clerk/themes": "^2.4.55",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"astro": "^5.17.1", "astro": "^6.4.8",
"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", "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",
"pg": "^8.20.0", "pg": "^8.20.0",
"sass": "^1.97.3", "sass": "^1.97.3",
"typesense": "^3.0.1" "typesense": "^3.0.1"
@@ -29,7 +29,7 @@
"@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", "@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"
} }
} }

View File

@@ -12,7 +12,7 @@ async function findMissingImages() {
.where(sql`${schema.tcgcards.sealed} = false`); .where(sql`${schema.tcgcards.sealed} = false`);
const missingImages: string[] = []; const missingImages: string[] = [];
for (const card of cards) { for (const card of cards) {
const imagePath = path.join(process.cwd(), 'public', 'cards', `${card.productId}.jpg`); const imagePath = path.join(process.cwd(), 'static', 'cards', `${card.productId}.jpg`);
try { try {
await fs.access(imagePath); await fs.access(imagePath);
} catch (err) { } catch (err) {

View File

@@ -12,6 +12,9 @@ const DollarToInt = (dollar: any) => {
return Math.round(dollar * 100); return Math.round(dollar * 100);
} }
export type Logger = (msg: string) => void;
const defaultLogger: Logger = (msg) => console.log(chalk.green(msg));
export const Sleep = (ms: number) => { export const Sleep = (ms: number) => {
@@ -39,7 +42,7 @@ export const GetNumberOrNull = (value: any): number | null => {
// Delete and recreate the 'cards' index // Delete and recreate the 'cards' index
export const createCardCollection = async () => { export const createCardCollection = async (log: Logger = defaultLogger) => {
try { try {
await client.collections('cards').delete(); await client.collections('cards').delete();
} catch (error) { } catch (error) {
@@ -56,9 +59,11 @@ export const createCardCollection = async () => {
{ name: 'productLineName', type: 'string', facet: true }, { name: 'productLineName', type: 'string', facet: true },
{ name: 'rarityName', type: 'string', facet: true }, { name: 'rarityName', type: 'string', facet: true },
{ name: 'setName', type: 'string', facet: true }, { name: 'setName', type: 'string', facet: true },
{ name: 'setCode', type: 'string' },
{ name: 'cardType', type: 'string', facet: true }, { name: 'cardType', type: 'string', facet: true },
{ name: 'energyType', type: 'string', facet: true }, { name: 'energyType', type: 'string', facet: true },
{ name: 'number', type: 'string', sort: true }, { name: 'number', type: 'string' },
{ name: 'inumber', type: 'int32', optional: true, sort: true },
{ name: 'Artist', type: 'string' }, { name: 'Artist', type: 'string' },
{ name: 'sealed', type: 'bool' }, { name: 'sealed', type: 'bool' },
{ name: 'releaseDate', type: 'int32' }, { name: 'releaseDate', type: 'int32' },
@@ -67,11 +72,11 @@ export const createCardCollection = async () => {
// { name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true } // { name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
], ],
}); });
console.log(chalk.green('Collection "cards" created successfully.')); log('Collection "cards" created successfully.');
} }
// Delete and recreate the 'skus' index // Delete and recreate the 'skus' index
export const createSkuCollection = async () => { export const createSkuCollection = async (log: Logger = defaultLogger) => {
try { try {
await client.collections('skus').delete(); await client.collections('skus').delete();
} catch (error) { } catch (error) {
@@ -88,11 +93,11 @@ export const createSkuCollection = async () => {
{ name: 'card_id', type: 'string', reference: 'cards.id', async_reference: true }, { name: 'card_id', type: 'string', reference: 'cards.id', async_reference: true },
] ]
}); });
console.log(chalk.green('Collection "skus" created successfully.')); log('Collection "skus" created successfully.');
} }
// Delete and recreate the 'inventory' index // Delete and recreate the 'inventory' index
export const createInventoryCollection = async () => { export const createInventoryCollection = async (log: Logger = defaultLogger) => {
try { try {
await client.collections('inventories').delete(); await client.collections('inventories').delete();
} catch (error) { } catch (error) {
@@ -116,16 +121,24 @@ export const createInventoryCollection = async () => {
{ name: 'cardType', type: 'string' }, { name: 'cardType', type: 'string' },
] ]
}); });
console.log(chalk.green('Collection "inventories" created successfully.')); log('Collection "inventories" created successfully.');
} }
export const upsertCardCollection = async (db:DBInstance) => { export const upsertCardCollection = async (db:DBInstance, log: Logger = defaultLogger) => {
const pokemon = await db.query.cards.findMany({ const pokemon = await db.query.cards.findMany({
with: { set: true, tcgdata: true, prices: true }, with: { set: true, tcgdata: true, prices: true },
}); });
await client.collections('cards').documents().import(pokemon.map(card => { await client.collections('cards').documents().import(pokemon.map(card => {
const marketPrice = card.tcgdata?.marketPrice ? DollarToInt(card.tcgdata.marketPrice) : null; // Use the NM SKU price matching the card's variant (kept fresh by syncPrices)
// Fall back to any NM sku, then to tcgdata price
const nmSku = card.prices.find(p => p.condition === 'Near Mint' && p.variant === card.variant)
?? card.prices.find(p => p.condition === 'Near Mint');
const marketPrice = nmSku?.marketPrice
? DollarToInt(nmSku.marketPrice)
: card.tcgdata?.marketPrice
? DollarToInt(card.tcgdata.marketPrice)
: null;
return { return {
id: card.cardId.toString(), id: card.cardId.toString(),
@@ -136,21 +149,23 @@ export const upsertCardCollection = async (db:DBInstance) => {
productLineName: card.productLineName, productLineName: card.productLineName,
rarityName: card.rarityName, rarityName: card.rarityName,
setName: card.set?.setName || "", setName: card.set?.setName || "",
setCode: card.set?.setCode || "",
cardType: card.cardType || "", cardType: card.cardType || "",
energyType: card.energyType || "", energyType: card.energyType || "",
number: card.number, number: card.number,
inumber: (card.number !== null) ? parseInt(card.number) : undefined,
Artist: card.artist || "", Artist: card.artist || "",
sealed: card.sealed, sealed: card.sealed,
content: [card.productName, card.productLineName, card.set?.setName || "", card.number, card.rarityName, card.artist || ""].join(' '), content: [card.productName, card.productLineName, card.set?.setName || "", card.set?.setCode || "", card.number, card.rarityName, card.artist || ""].join(' '),
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0, releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
...(marketPrice !== null && { marketPrice }), ...(marketPrice !== null && { marketPrice }),
// sku_id: card.prices.map(price => price.skuId.toString()) // sku_id: card.prices.map(price => price.skuId.toString())
}; };
}), { action: 'upsert' }); }), { action: 'upsert' });
console.log(chalk.green('Collection "cards" indexed successfully.')); log('Collection "cards" indexed successfully.');
} }
export const upsertSkuCollection = async (db:DBInstance) => { export const upsertSkuCollection = async (db:DBInstance, log: Logger = defaultLogger) => {
const skus = await db.query.skus.findMany(); const skus = await db.query.skus.findMany();
await client.collections('skus').documents().import(skus.map(sku => ({ await client.collections('skus').documents().import(skus.map(sku => ({
id: sku.skuId.toString(), id: sku.skuId.toString(),
@@ -160,10 +175,10 @@ export const upsertSkuCollection = async (db:DBInstance) => {
marketPrice: DollarToInt(sku.marketPrice), marketPrice: DollarToInt(sku.marketPrice),
card_id: sku.cardId.toString(), card_id: sku.cardId.toString(),
})), { action: 'upsert' }); })), { action: 'upsert' });
console.log(chalk.green('Collection "skus" indexed successfully.')); log('Collection "skus" indexed successfully.');
} }
export const upsertInventoryCollection = async (db:DBInstance) => { export const upsertInventoryCollection = async (db:DBInstance, log: Logger = defaultLogger) => {
const inv = await db.query.inventory.findMany({ const inv = await db.query.inventory.findMany({
with: { sku: { with: { card: { with: { set: true } } } } } with: { sku: { with: { card: { with: { set: true } } } } }
}); });
@@ -188,25 +203,27 @@ export const upsertInventoryCollection = async (db:DBInstance) => {
i.sku?.card?.artist || "" i.sku?.card?.artist || ""
].join(' '), ].join(' '),
})), { action: 'upsert' }); })), { action: 'upsert' });
console.log(chalk.green('Collection "inventories" indexed successfully.')); log('Collection "inventories" indexed successfully.');
} }
export const UpdateVariants = async (db:DBInstance) => { export const UpdateVariants = async (db:DBInstance, log: Logger = (m) => console.log(m)) => {
const updates = await db.execute(sql`update cards as c const updates = await db.execute(sql`update cards as c
set 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, 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 sealed = a.sealed, set_id = a.set_id, card_type = a.card_type, energy_type = a.energy_type, number = a.number, inumber = a.inumber, artist = a.artist
from ( from (
select t.product_id, b.variant, 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_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.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.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.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 coalesce(o.number, regexp_replace(t.number,'^0+','')) as number,
nullif(regexp_replace(regexp_replace(coalesce(o.number,t.number),'/.*',''),'[^0-9]','','g'),'')::integer as inumber,
coalesce(o.artist, t.artist) as artist
from tcg_cards t from tcg_cards t
join (select distinct product_id, variant from skus) b on t.product_id = b.product_id 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 left join tcg_overrides o on t.product_id = o.product_id
@@ -219,22 +236,23 @@ where c.product_id = a.product_id and c.variant = a.variant and
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 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`); 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) 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", inumber, artist)
select t.product_id, b.variant, 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_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.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.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 coalesce(o.energy_type, t.energy_type) as energy_type, coalesce(o.number, regexp_replace(t.number,'^0+','')) as number,
nullif(regexp_replace(regexp_replace(coalesce(o.number,t.number),'/.*',''),'[^0-9]','','g'),'')::integer as inumber, coalesce(o.artist, t.artist) as artist
from tcg_cards t from tcg_cards t
join (select distinct product_id, variant from skus) b on t.product_id = b.product_id 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 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) 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`); log(`Inserted ${inserts.rowCount} rows into cards table`);
const skuUpdates = await db.execute(sql`update skus s set card_id = c.card_id from cards c where s.product_id = c.product_id and s.variant = c.variant and s.card_id is distinct from c.card_id`); const skuUpdates = await db.execute(sql`update skus s set card_id = c.card_id from cards c where s.product_id = c.product_id and s.variant = c.variant and s.card_id is distinct from c.card_id`);
console.log(`Updated ${skuUpdates.rowCount} rows in skus table`); log(`Updated ${skuUpdates.rowCount} rows in skus table`);
} }

View File

@@ -1,106 +1,86 @@
import 'dotenv/config'; import 'dotenv/config';
import * as schema from '../src/db/schema.ts'; import * as schema from '../src/db/schema.ts';
import { db, ClosePool } from '../src/db/index.ts'; import { db, ClosePool, type DBInstance } 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 { pathToFileURL } from 'node:url';
import * as helper from './pokemon-helper.ts'; import * as helper from './pokemon-helper.ts';
//import util from 'util';
export type Logger = (msg: string) => void;
const consoleLogger: Logger = (m) => console.log(m);
export type RunImportOptions = {
sets?: string[];
log?: Logger;
runUpdateVariants?: boolean;
runCardUpsert?: boolean;
};
async function syncTcgplayer(cardSets:string[] = []) { const syncProductLine = async (
database: DBInstance,
const productLines = [ "pokemon", "pokemon-japan" ]; productLine: string,
field: string,
// work from the available sets within the product line fieldValue: string,
for (const productLine of productLines) { allProductIds: Set<number>,
const d = {"algorithm":"sales_dismax","from":0,"size":1,"filters":{"term":{"productLineName":[productLine]}},"settings":{"useFuzzySearch":false}}; log: Logger,
) => {
const response = await fetch('https://mp-search-api.tcgplayer.com/v1/search/request?q=&isList=false', {
method: 'POST',
headers: {'Content-Type': 'application/json',},
body: JSON.stringify(d),
});
if (!response.ok) {
console.error('Error notifying sync completion:', response.statusText);
process.exit(1);
}
const data = await response.json();
const setNames = data.results[0].aggregations.setName;
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}"...`));
await syncProductLine(productLine, "setName", setName.urlValue);
}
}
}
console.log(chalk.green('✓ All TCGPlayer data synchronized successfully!'));
}
async function syncProductLine(productLine: string, field: string, fieldValue: string) {
let start = 0; let start = 0;
let size = 50; const size = 50;
let total = 1000000; let total = 1000000;
while (start < total) { while (start < total) {
console.log(` Fetching items ${start} to ${start + size} of ${total}...`); log(` Fetching items ${start} to ${start + size} of ${total}...`);
const d = { const d = {
"algorithm":"sales_dismax", "algorithm": "sales_dismax",
"from":start, "from": start,
"size":size, "size": size,
"filters":{ "filters": {
"term":{"productLineName":[productLine], [field]:[fieldValue]} , "term": { "productLineName": [productLine], [field]: [fieldValue] },
"range":{}, "range": {},
"match":{} "match": {},
},
"listingSearch":{
"context":{"cart":{}},
"filters":{"term":{
"sellerStatus":"Live",
"channelId":0
},
"range":{
"quantity":{"gte":1}
},
"exclude":{"channelExclusion":0}
}
},
"context":{
"cart":{},
"shippingCountry":"US",
"userProfile":{}
},
"settings":{
"useFuzzySearch":false,
"didYouMean":{}
},
"sort":{}
};
//console.log(util.inspect(d, { depth: null }));
//process.exit(1);
const response = await fetch('https://mp-search-api.tcgplayer.com/v1/search/request?q=&isList=false', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}, },
body: JSON.stringify(d), "listingSearch": {
}); "context": { "cart": {} },
"filters": {
"term": { "sellerStatus": "Live", "channelId": 0 },
"range": { "quantity": { "gte": 1 } },
"exclude": { "channelExclusion": 0 },
},
},
"context": { "cart": {}, "shippingCountry": "US", "userProfile": {} },
"settings": { "useFuzzySearch": false, "didYouMean": {} },
"sort": {},
};
if (!response.ok) { let response: Response | null = null;
console.error('Error notifying sync completion:', response.statusText); let lastError: unknown = null;
process.exit(1); for (let attempt = 1; attempt <= 3; attempt++) {
try {
const r = await fetch('https://mp-search-api.tcgplayer.com/v1/search/request?q=&isList=false', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(d),
});
if (r.ok) {
response = r;
break;
}
lastError = new Error(`TCGPlayer search request failed: ${r.status} ${r.statusText}`);
} catch (err) {
lastError = err;
}
if (attempt < 3) {
log(` retry ${attempt}/2 for search request in 5s...`);
await helper.Sleep(5000);
}
}
if (!response) {
throw lastError instanceof Error
? lastError
: new Error(`TCGPlayer search request failed: ${String(lastError)}`);
} }
const data = await response.json(); const data = await response.json();
@@ -108,28 +88,43 @@ 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)
if (allProductIds.size > 0 && allProductIds.has(item.productId)) { if (allProductIds.size > 0 && allProductIds.has(item.productId)) {
continue; continue;
} }
console.log(chalk.blue(` - ${item.productName} (ID: ${item.productId})`)); log(` - ${item.productName} (ID: ${item.productId})`);
// Get product detail let detailResponse: Response | null = null;
const detailResponse = await fetch(`https://mp-search-api.tcgplayer.com/v2/product/${item.productId}/details`, { let lastError: unknown = null;
method: 'GET', for (let attempt = 1; attempt <= 3; attempt++) {
}); try {
if (!detailResponse.ok) { const r = await fetch(`https://mp-search-api.tcgplayer.com/v2/product/${item.productId}/details`, {
console.error('Error fetching product details:', detailResponse.statusText); method: 'GET',
process.exit(1); });
if (r.ok) {
detailResponse = r;
break;
}
lastError = new Error(`Error fetching product details for ${item.productId}: ${r.statusText}`);
} catch (err) {
lastError = err;
}
if (attempt < 3) {
log(` retry ${attempt}/2 for product ${item.productId} in 5s...`);
await helper.Sleep(5000);
}
}
if (!detailResponse) {
throw lastError instanceof Error
? lastError
: new Error(`Error fetching product details for ${item.productId}: ${String(lastError)}`);
} }
const detailData = await detailResponse.json(); const detailData = await detailResponse.json();
await db.insert(schema.tcgcards).values({ await database.insert(schema.tcgcards).values({
productId: item.productId, productId: item.productId,
productName: detailData.productName, productName: detailData.productName,
//productName: cleanProductName(item.productName),
rarityName: item.rarityName, rarityName: item.rarityName,
productLineName: detailData.productLineName, productLineName: detailData.productLineName,
productLineUrlName: detailData.productLineUrlName, productLineUrlName: detailData.productLineUrlName,
@@ -167,7 +162,6 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
target: schema.tcgcards.productId, target: schema.tcgcards.productId,
set: { set: {
productName: detailData.productName, productName: detailData.productName,
//productName: cleanProductName(item.productName),
rarityName: item.rarityName, rarityName: item.rarityName,
productLineName: detailData.productLineName, productLineName: detailData.productLineName,
productLineUrlName: detailData.productLineUrlName, productLineUrlName: detailData.productLineUrlName,
@@ -204,11 +198,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
}, },
}); });
// console.log(`item: ${item.setId}\tdetail: ${detailData.setId}`); await database.insert(schema.sets).values({
// console.log(`item: ${item.setCode}\tdetail: ${detailData.setCode}`);
// console.log(`item: ${item.setName}\tdetail: ${detailData.setName}`);
// set is...
await db.insert(schema.sets).values({
setId: detailData.setId, setId: detailData.setId,
setCode: detailData.setCode, setCode: detailData.setCode,
setName: detailData.setName, setName: detailData.setName,
@@ -222,10 +212,8 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
}, },
}); });
// skus are...
for (const skuItem of detailData.skus) { for (const skuItem of detailData.skus) {
await database.insert(schema.skus).values({
await db.insert(schema.skus).values({
skuId: skuItem.sku, skuId: skuItem.sku,
productId: detailData.productId, productId: detailData.productId,
condition: skuItem.condition, condition: skuItem.condition,
@@ -241,7 +229,6 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
}); });
} }
// get image if it doesn't already exist
const imagePath = path.join(process.cwd(), 'static', 'cards', `${item.productId}.jpg`); const imagePath = path.join(process.cwd(), 'static', 'cards', `${item.productId}.jpg`);
if (!await helper.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`);
@@ -249,37 +236,88 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
const buffer = await imageResponse.arrayBuffer(); const buffer = await imageResponse.arrayBuffer();
await fs.writeFile(imagePath, Buffer.from(buffer)); await fs.writeFile(imagePath, Buffer.from(buffer));
} else { } else {
console.error(chalk.yellow(`Error fetching ${item.productId}: ${item.productName} image:`, imageResponse.statusText)); log(`Error fetching ${item.productId}: ${item.productName} image: ${imageResponse.statusText}`);
await fs.appendFile('missing_images.log', `${item.productId}: ${item.productName}\n`, 'utf-8'); await fs.appendFile('missing_images.log', `${item.productId}: ${item.productName}\n`, 'utf-8');
} }
} }
// be nice to the API and not send too many requests in a short time
await helper.Sleep(300); await helper.Sleep(300);
} }
start += size; start += size;
} }
};
const syncTcgplayer = async (database: DBInstance, cardSets: string[], allProductIds: Set<number>, log: Logger) => {
const productLines = ["pokemon", "pokemon-japan"];
for (const productLine of productLines) {
const d = {
"algorithm": "sales_dismax", "from": 0, "size": 1,
"filters": { "term": { "productLineName": [productLine] } },
"settings": { "useFuzzySearch": false },
};
const response = await fetch('https://mp-search-api.tcgplayer.com/v1/search/request?q=&isList=false', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(d),
});
if (!response.ok) {
throw new Error(`TCGPlayer setName aggregation failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const setNames = data.results[0].aggregations.setName;
for (const setName of setNames) {
let processSet = true;
if (cardSets.length > 0) {
processSet = cardSets.some(set => setName.value.toLowerCase().includes(set.toLowerCase()));
}
if (processSet) {
log(`Syncing product line "${productLine}" with setName "${setName.urlValue}"...`);
await syncProductLine(database, productLine, "setName", setName.urlValue, allProductIds, log);
}
}
}
log('All TCGPlayer data synchronized successfully!');
};
export const runImport = async (opts: RunImportOptions = {}) => {
const { sets = [], log = consoleLogger, runUpdateVariants = true, runCardUpsert = true } = opts;
await fs.rm('missing_images.log', { force: true });
// When no set filter is provided, skip productIds already in the cards table
// (matches the CLI script's "no args" behavior).
const allProductIds = sets.length === 0
? new Set<number>(
await db.select({ productId: schema.cards.productId }).from(schema.cards)
.then(rows => rows.map(row => row.productId))
)
: new Set<number>();
await syncTcgplayer(db, sets, allProductIds, log);
if (runUpdateVariants) {
log('Updating card variants...');
await helper.UpdateVariants(db, log);
}
if (runCardUpsert) {
log('Reindexing "cards" collection...');
await helper.upsertCardCollection(db, log);
}
};
// CLI entry point — preserves the original `tsx scripts/preload-tcgplayer.ts [set...]` usage.
const isCli = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isCli) {
const args = process.argv.slice(2);
await runImport({ sets: args });
await ClosePool();
} }
// clear the log file
await fs.rm('missing_images.log', { force: true });
let allProductIds = new Set();
const args = process.argv.slice(2);
if (args.length === 0) {
allProductIds = new Set(await db.select({ productId: schema.cards.productId }).from(schema.cards).then(rows => rows.map(row => row.productId)));
await syncTcgplayer();
}
else {
await syncTcgplayer(args);
}
// update the card table with new/updated variants
await helper.UpdateVariants(db);
// index the card updates
await helper.upsertCardCollection(db);
await ClosePool();

View File

@@ -154,6 +154,7 @@ const updateLatestSales = async (updatedCards: Set<number>) => {
const start = Date.now(); const start = Date.now();
const updatedCards = await syncPrices(); const updatedCards = await syncPrices();
await helper.upsertSkuCollection(db); await helper.upsertSkuCollection(db);
await helper.upsertCardCollection(db);
//console.log(updatedCards); //console.log(updatedCards);
//console.log(updatedCards.size); //console.log(updatedCards.size);
//await updateLatestSales(updatedCards); //await updateLatestSales(updatedCards);

View File

@@ -18,7 +18,7 @@
@import 'bootstrap/scss/containers'; @import 'bootstrap/scss/containers';
@import 'bootstrap/scss/images'; @import 'bootstrap/scss/images';
@import 'bootstrap/scss/nav'; @import 'bootstrap/scss/nav';
// @import 'bootstrap/scss/accordion'; @import 'bootstrap/scss/accordion';
@import 'bootstrap/scss/alert'; @import 'bootstrap/scss/alert';
@import 'bootstrap/scss/badge'; @import 'bootstrap/scss/badge';
// @import 'bootstrap/scss/breadcrumb'; // @import 'bootstrap/scss/breadcrumb';

View File

@@ -185,6 +185,10 @@ $colors: mauve, lilac, "purple", "orchid", "snow";
} }
} }
.navbar-nav > .nav-item > .nav-link:hover {
color: rgba(255, 255, 255, 1);
}
.nav-tabs { .nav-tabs {
.nav-link.active, .nav-link.active,
.nav-item.show .nav-link { .nav-item.show .nav-link {
@@ -1170,4 +1174,4 @@ input[type="search"]::-webkit-search-cancel-button {
background-size: 1rem; background-size: 1rem;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAAn0lEQVR42u3UMQrDMBBEUZ9WfQqDmm22EaTyjRMHAlM5K+Y7lb0wnUZPIKHlnutOa+25Z4D++MRBX98MD1V/trSppLKHqj9TTBWKcoUqffbUcbBBEhTjBOV4ja4l4OIAZThEOV6jHO8ARXD+gPPvKMABinGOrnu6gTNUawrcQKNCAQ7QeTxORzle3+sDfjJpPCqhJh7GixZq4rHcc9l5A9qZ+WeBhgEuAAAAAElFTkSuQmCC); background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAAn0lEQVR42u3UMQrDMBBEUZ9WfQqDmm22EaTyjRMHAlM5K+Y7lb0wnUZPIKHlnutOa+25Z4D++MRBX98MD1V/trSppLKHqj9TTBWKcoUqffbUcbBBEhTjBOV4ja4l4OIAZThEOV6jHO8ARXD+gPPvKMABinGOrnu6gTNUawrcQKNCAQ7QeTxORzle3+sDfjJpPCqhJh7GixZq4rHcc9l5A9qZ+WeBhgEuAAAAAElFTkSuQmCC);
} }
-------------------------------------------------- */ -------------------------------------------------- */

View File

@@ -49,6 +49,13 @@ import BackToTop from "./BackToTop.astro"
<script is:inline> <script is:inline>
(function () { (function () {
// ── Tooltip initializer ───────────────────────────────────────────────────
function initTooltips(root = document) {
root.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
bootstrap.Tooltip.getOrCreateInstance(el, { container: 'body' });
});
}
// ── Price mode helpers ──────────────────────────────────────────────────── // ── Price mode helpers ────────────────────────────────────────────────────
// marketPriceByCondition is injected into the modal HTML via a data attribute // marketPriceByCondition is injected into the modal HTML via a data attribute
// on #inventoryEntryList: data-market-prices='{"Near Mint":6.00,...}' // on #inventoryEntryList: data-market-prices='{"Near Mint":6.00,...}'
@@ -246,6 +253,9 @@ import BackToTop from "./BackToTop.astro"
// ── Global helpers ──────────────────────────────────────────────────────── // ── Global helpers ────────────────────────────────────────────────────────
window.copyImage = async function(img) { window.copyImage = async function(img) {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
try { try {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
@@ -260,10 +270,17 @@ import BackToTop from "./BackToTop.astro"
clean.src = img.src; clean.src = img.src;
}); });
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
});
if (isIOS) {
const file = new File([blob], 'card.png', { type: 'image/png' });
await navigator.share({ files: [file] });
return;
}
if (navigator.clipboard && navigator.clipboard.write) { 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 })]); await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
showCopyToast('📋 Image copied!', '#198754'); showCopyToast('📋 Image copied!', '#198754');
} else { } else {
@@ -282,6 +299,7 @@ import BackToTop from "./BackToTop.astro"
} }
} }
} catch (err) { } catch (err) {
if (err.name === 'AbortError') return;
console.error('Failed:', err); console.error('Failed:', err);
showCopyToast('❌ Copy failed', '#dc3545'); showCopyToast('❌ Copy failed', '#dc3545');
} }
@@ -414,6 +432,7 @@ import BackToTop from "./BackToTop.astro"
if (typeof htmx !== 'undefined') htmx.process(modal); if (typeof htmx !== 'undefined') htmx.process(modal);
initInventoryForms(modal); initInventoryForms(modal);
initTooltips(modal);
updateNavButtons(modal); updateNavButtons(modal);
initChartAfterSwap(modal); initChartAfterSwap(modal);
switchToRequestedTab(); switchToRequestedTab();
@@ -509,6 +528,7 @@ import BackToTop from "./BackToTop.astro"
if (typeof htmx !== 'undefined') htmx.process(target); if (typeof htmx !== 'undefined') htmx.process(target);
initInventoryForms(target); initInventoryForms(target);
initTooltips(target);
const destImg = target.querySelector('img.card-image'); const destImg = target.querySelector('img.card-image');
if (destImg) { if (destImg) {
@@ -638,6 +658,7 @@ import BackToTop from "./BackToTop.astro"
updateNavButtons(cardModal); updateNavButtons(cardModal);
initChartAfterSwap(cardModal); initChartAfterSwap(cardModal);
initInventoryForms(cardModal); initInventoryForms(cardModal);
initTooltips(cardModal);
switchToRequestedTab(); switchToRequestedTab();
}); });
@@ -648,6 +669,7 @@ import BackToTop from "./BackToTop.astro"
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initInventoryForms(); initInventoryForms();
initTooltips();
const pending = sessionStorage.getItem('pendingSearch'); const pending = sessionStorage.getItem('pendingSearch');
if (pending) { if (pending) {
@@ -656,7 +678,7 @@ import BackToTop from "./BackToTop.astro"
if (input) input.value = pending; if (input) input.value = pending;
// The form's hx-trigger="load" will fire automatically on page load, // The form's hx-trigger="load" will fire automatically on page load,
// picking up the pre-populated input value — no manual trigger needed. // picking up the pre-populated input value — no manual trigger needed.
} }
}); });
})(); })();

View File

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

View File

@@ -125,6 +125,7 @@ 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 team_rocket from "/src/svg/set/team_rocket.svg?raw";
import perfect_order from "/src/svg/set/perfect_order.svg?raw"; import perfect_order from "/src/svg/set/perfect_order.svg?raw";
import chaos_rising from "/src/svg/set/chaos_rising.svg?raw";
const { set } = Astro.props; const { set } = Astro.props;
@@ -254,6 +255,7 @@ const setMap = {
"DRI": destined_rivals, "DRI": destined_rivals,
"SSP": surging_sparks, "SSP": surging_sparks,
"ME03": perfect_order, "ME03": perfect_order,
"CRI": chaos_rising,
}; };
const svg = setMap[set as keyof typeof setMap] ?? ""; const svg = setMap[set as keyof typeof setMap] ?? "";

View File

@@ -56,6 +56,7 @@ export const cards = pokeSchema.table('cards', {
cardType: varchar({ length: 100 }), cardType: varchar({ length: 100 }),
energyType: varchar({ length: 100 }), energyType: varchar({ length: 100 }),
number: varchar({ length: 50 }), number: varchar({ length: 50 }),
inumber: integer(),
artist: varchar({ length: 255 }), artist: varchar({ length: 255 }),
}, },
(table) => [ (table) => [
@@ -136,7 +137,8 @@ export const inventory = pokeSchema.table('inventory',{
createdAt: timestamp().notNull().defaultNow(), createdAt: timestamp().notNull().defaultNow(),
}, },
(table) => [ (table) => [
index('idx_userid_skuId').on(table.userId, table.skuId) index('idx_userid_skuId').on(table.userId, table.skuId),
index('idx_userid_catalogName').on(table.userId, table.catalogName)
]); ]);
export const processingSkus = pokeSchema.table('processing_skus', { export const processingSkus = pokeSchema.table('processing_skus', {

View File

@@ -19,6 +19,7 @@ const { title } = Astro.props;
<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}'/> <meta name="htmx-config" content='{"historyCacheSize": 50}'/>
<meta name="google-adsense-account" content="ca-pub-1140571217687341">
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<title>{title}</title> <title>{title}</title>
@@ -45,4 +46,4 @@ const { title } = Astro.props;
<script src="../assets/js/main.js"></script> <script src="../assets/js/main.js"></script>
<script>import '../assets/js/priceChart.js';</script> <script>import '../assets/js/priceChart.js';</script>
</body> </body>
</html> </html>

View File

@@ -1,7 +1,6 @@
import { clerkMiddleware, createRouteMatcher, clerkClient } from '@clerk/astro/server'; import { clerkMiddleware, createRouteMatcher, clerkClient } from '@clerk/astro/server';
import type { MiddlewareNext } from 'astro';
import 'dotenv/config'; import 'dotenv/config';
declare global { declare global {
namespace App { namespace App {
interface Locals { interface Locals {
@@ -9,19 +8,23 @@ declare global {
} }
} }
} }
const isProtectedRoute = createRouteMatcher(['/pokemon']); const isProtectedRoute = createRouteMatcher(['/pokemon']);
const isAdminRoute = createRouteMatcher(['/admin']); const isAdminRoute = createRouteMatcher(['/admin', '/api/reindex', '/api/preload-tcgplayer']);
const TARGET_ORG_ID = "org_3Baav9czkRLLlC7g89oJWqRRulK"; const TARGET_ORG_ID = "org_3Baav9czkRLLlC7g89oJWqRRulK";
const ADMIN_ORG_IDS = new Set([
"org_3Baav9czkRLLlC7g89oJWqRRulK",
"org_3ABdwuK3qD7Saq590ZMQWY7AvVz",
]);
export const onRequest = clerkMiddleware(async (auth, context, next) => { export const onRequest = clerkMiddleware(async (auth, context, next) => {
const { isAuthenticated, userId, redirectToSignIn, has } = auth(); const { isAuthenticated, userId, redirectToSignIn, has } = auth();
if (!isAuthenticated && isProtectedRoute(context.request)) { if (!isAuthenticated && isProtectedRoute(context.request)) {
return redirectToSignIn(); return redirectToSignIn();
} }
// ── Inventory visibility check ────────────────────────────────────────────── // ── Inventory visibility check ──────────────────────────────────────────────
// Resolves to true if the user belongs to the target org OR has the feature // Resolves to true if the user belongs to the target org OR has the feature
const canAddInventory = process.env.INVENTORY_ACCESS === 'true' || const canAddInventory = process.env.INVENTORY_ACCESS === 'true' ||
@@ -33,27 +36,38 @@ export const onRequest = clerkMiddleware(async (auth, context, next) => {
(await getUserOrgIds(context, userId)).includes(TARGET_ORG_ID) (await getUserOrgIds(context, userId)).includes(TARGET_ORG_ID)
) )
); );
// Expose the flag to your Astro pages via locals // Expose the flag to your Astro pages via locals
context.locals.canAddInventory = Boolean(canAddInventory); context.locals.canAddInventory = Boolean(canAddInventory);
// ── Admin route guard (unchanged) ─────────────────────────────────────────── // ── Admin route guard ───────────────────────────────────────────
if (isAdminRoute(context.request)) { if (isAdminRoute(context.request)) {
if (!isAuthenticated || !userId) { if (!isAuthenticated || !userId) {
return redirectToSignIn(); return redirectToSignIn();
} }
try { try {
const client = await clerkClient(context); const client = await clerkClient(context);
const memberships = await client.organizations.getOrganizationMembershipList({ const userOrgIds = await getUserOrgIds(context, userId);
organizationId: TARGET_ORG_ID, const matchingOrgIds = userOrgIds.filter((id) => ADMIN_ORG_IDS.has(id));
});
if (matchingOrgIds.length === 0) {
const userMembership = memberships.data.find( return new Response(null, { status: 404 });
(m) => m.publicUserData?.userId === userId }
const membershipLists = await Promise.all(
matchingOrgIds.map((orgId) =>
client.organizations.getOrganizationMembershipList({ organizationId: orgId })
)
); );
if (!userMembership || userMembership.role !== "org:admin") { const isAdmin = membershipLists.some((list) =>
list.data.some(
(m) => m.publicUserData?.userId === userId && m.role === "org:admin"
)
);
if (!isAdmin) {
return new Response(null, { status: 404 }); return new Response(null, { status: 404 });
} }
} catch (e) { } catch (e) {
@@ -61,10 +75,10 @@ export const onRequest = clerkMiddleware(async (auth, context, next) => {
return context.redirect("/"); return context.redirect("/");
} }
} }
return next(); return next();
}); });
// ── Helper: fetch all org IDs the current user belongs to ─────────────────── // ── Helper: fetch all org IDs the current user belongs to ───────────────────
async function getUserOrgIds(context: any, userId: string): Promise<string[]> { async function getUserOrgIds(context: any, userId: string): Promise<string[]> {
try { try {

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

@@ -0,0 +1,225 @@
---
export const prerender = false;
import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro';
---
<Layout title="Admin Panel">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div slot="page">
<div class="container my-4">
<div class="row mb-4">
<div class="col-12">
<h1>Admin Panel</h1>
<div class="accordion" id="adminAccordion">
<div class="accordion-item">
<h2 class="accordion-header" id="reindexHeading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#reindexCollapse" aria-expanded="false" aria-controls="reindexCollapse">
Reindex
</button>
</h2>
<div id="reindexCollapse" class="accordion-collapse collapse" aria-labelledby="reindexHeading"
data-bs-parent="#adminAccordion">
<div class="accordion-body">
<form id="reindexForm">
<div class="mb-3">
<div class="form-text mb-2">Select collections to reindex:</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="reindexCards" name="cards" checked />
<label class="form-check-label" for="reindexCards">Cards</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="reindexSkus" name="skus" checked />
<label class="form-check-label" for="reindexSkus">SKUs</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="reindexInventory" name="inventory" checked />
<label class="form-check-label" for="reindexInventory">Inventory</label>
</div>
</div>
<div class="mb-3 form-check">
<input class="form-check-input" type="checkbox" id="reindexRecreate" name="recreate" />
<label class="form-check-label" for="reindexRecreate">
Recreate index (drops and recreates collections; otherwise updates in place)
</label>
</div>
<button type="submit" class="btn btn-purple" id="reindexRun">Run Reindex</button>
</form>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="tcgImportHeading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#tcgImportCollapse" aria-expanded="false" aria-controls="tcgImportCollapse">
TCG Player Import
</button>
</h2>
<div id="tcgImportCollapse" class="accordion-collapse collapse" aria-labelledby="tcgImportHeading"
data-bs-parent="#adminAccordion">
<div class="accordion-body">
<form id="tcgImportForm">
<div class="mb-3">
<label for="tcgImportSetName" class="form-label">Set Name</label>
<input type="text" class="form-control" id="tcgImportSetName" name="setName"
placeholder="e.g. Surging Sparks" autocomplete="off" required />
<div class="form-text">Matches any set whose name contains this text (case-insensitive).</div>
</div>
<button type="submit" class="btn btn-purple" id="tcgImportRun">Run Import</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Reusable scrollable progress modal. Open via window.AdminProgress.open(title). -->
<div class="modal fade" id="adminProgressModal" tabindex="-1" aria-labelledby="adminProgressLabel" aria-hidden="true"
data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="adminProgressLabel">Progress</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"
id="adminProgressClose" disabled></button>
</div>
<div class="modal-body p-0">
<pre id="adminProgressLog"
class="m-0 p-3 small"
style="max-height: 60vh; overflow-y: auto; white-space: pre-wrap; word-break: break-word;"></pre>
</div>
<div class="modal-footer">
<span class="me-auto small text-secondary" id="adminProgressStatus">Idle</span>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"
id="adminProgressDismiss" disabled>Close</button>
</div>
</div>
</div>
</div>
</div>
<Footer slot="footer" />
</Layout>
<script>
// Reusable progress modal. Other admin features can call window.AdminProgress.
type ProgressHandle = {
append: (line: string) => void;
setStatus: (text: string) => void;
done: (text?: string) => void;
};
declare global {
interface Window {
AdminProgress: {
open: (title: string) => Promise<ProgressHandle>;
};
}
}
const getBootstrap = (): any => (window as any).bootstrap;
const openProgress = async (title: string): Promise<ProgressHandle> => {
const modalEl = document.getElementById('adminProgressModal')!;
const labelEl = document.getElementById('adminProgressLabel')!;
const logEl = document.getElementById('adminProgressLog')!;
const statusEl = document.getElementById('adminProgressStatus')!;
const closeBtn = document.getElementById('adminProgressClose') as HTMLButtonElement;
const dismissBtn = document.getElementById('adminProgressDismiss') as HTMLButtonElement;
labelEl.textContent = title;
logEl.textContent = '';
statusEl.textContent = 'Running...';
closeBtn.disabled = true;
dismissBtn.disabled = true;
const modal = getBootstrap().Modal.getOrCreateInstance(modalEl);
modal.show();
return {
append: (line: string) => {
logEl.textContent += (logEl.textContent ? '\n' : '') + line;
logEl.scrollTop = logEl.scrollHeight;
},
setStatus: (text: string) => { statusEl.textContent = text; },
done: (text = 'Done') => {
statusEl.textContent = text;
closeBtn.disabled = false;
dismissBtn.disabled = false;
},
};
};
window.AdminProgress = { open: openProgress };
// Stream a POST JSON request line-by-line into a progress modal.
const streamToProgress = async (url: string, body: unknown, title: string, runBtn: HTMLButtonElement) => {
runBtn.disabled = true;
const progress = await window.AdminProgress.open(title);
try {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok || !resp.body) {
progress.append(`Request failed: ${resp.status} ${resp.statusText}`);
progress.done('Failed');
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop() ?? '';
for (const line of lines) progress.append(line);
}
if (buf) progress.append(buf);
progress.done('Done');
} catch (err: any) {
progress.append(`Error: ${err?.message || String(err)}`);
progress.done('Failed');
} finally {
runBtn.disabled = false;
}
};
// Reindex form wiring
const reindexForm = document.getElementById('reindexForm') as HTMLFormElement | null;
if (reindexForm) {
reindexForm.addEventListener('submit', (e) => {
e.preventDefault();
const body = {
cards: (document.getElementById('reindexCards') as HTMLInputElement).checked,
skus: (document.getElementById('reindexSkus') as HTMLInputElement).checked,
inventory: (document.getElementById('reindexInventory') as HTMLInputElement).checked,
recreate: (document.getElementById('reindexRecreate') as HTMLInputElement).checked,
};
streamToProgress('/api/reindex', body, 'Reindex',
document.getElementById('reindexRun') as HTMLButtonElement);
});
}
// TCG Player import form wiring
const tcgImportForm = document.getElementById('tcgImportForm') as HTMLFormElement | null;
if (tcgImportForm) {
tcgImportForm.addEventListener('submit', (e) => {
e.preventDefault();
const setName = (document.getElementById('tcgImportSetName') as HTMLInputElement).value.trim();
streamToProgress('/api/preload-tcgplayer', { setName }, `TCG Player Import: ${setName || '(none)'}`,
document.getElementById('tcgImportRun') as HTMLButtonElement);
});
}
</script>

View File

@@ -138,14 +138,20 @@ const removeFromInventory = async (inventoryId: string) => {
await client.collections('inventories').documents(inventoryId).delete(); await client.collections('inventories').documents(inventoryId).delete();
} }
const updateInventory = async (inventoryId: string, quantity: number, purchasePrice: number, note: string) => { const updateInventory = async (inventoryId: string, quantity: number, purchasePrice: number, note: string, catalogName: string) => {
// Update the database // Update the database
await db.update(inventory).set({ await db.update(inventory).set({
quantity: quantity, quantity: quantity,
purchasePrice: purchasePrice.toFixed(2), purchasePrice: purchasePrice.toFixed(2),
note: note, note: note,
catalogName: catalogName,
}).where(eq(inventory.inventoryId, inventoryId)); }).where(eq(inventory.inventoryId, inventoryId));
// No need to update Typesense since we don't search by quantity or price // Quantity/price/note aren't searched, but catalogName is faceted in Typesense.
try {
await client.collections('inventories').documents(inventoryId).update({ catalogName });
} catch (error) {
console.error('Error updating inventory catalog in Typesense:', error);
}
} }
export const POST: APIRoute = async ({ request, locals }) => { export const POST: APIRoute = async ({ request, locals }) => {
@@ -183,7 +189,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
const qty = Number(formData.get('quantity')) || 1; const qty = Number(formData.get('quantity')) || 1;
const price = Number(formData.get('purchasePrice')) || 0; const price = Number(formData.get('purchasePrice')) || 0;
const invNote = formData.get('note')?.toString() || ''; const invNote = formData.get('note')?.toString() || '';
await updateInventory(invId, qty, price, invNote); const invCatalog = formData.get('catalogName')?.toString() || 'Default';
await updateInventory(invId, qty, price, invNote, invCatalog);
break; break;
default: default:

View File

@@ -0,0 +1,43 @@
import type { APIRoute } from 'astro';
import { runImport } from '../../../scripts/preload-tcgplayer';
export const POST: APIRoute = async ({ request }) => {
const { setName } = await request.json().catch(() => ({} as any));
const trimmed = typeof setName === 'string' ? setName.trim() : '';
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const log = (msg: string) => {
controller.enqueue(encoder.encode(msg + '\n'));
};
try {
if (!trimmed) {
log('Set name is required.');
return;
}
log(`Starting TCGPlayer import for set: "${trimmed}"`);
await runImport({ sets: [trimmed], log });
log('TCGPlayer import complete.');
} catch (e: any) {
const cause = e?.cause;
const causeMsg = cause?.message || (cause ? String(cause) : '');
log(`Error: ${e?.message || String(e)}`);
if (causeMsg) log(`Caused by: ${causeMsg}`);
console.error('TCGPlayer import error:', e);
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
});
};

71
src/pages/api/reindex.ts Normal file
View File

@@ -0,0 +1,71 @@
import type { APIRoute } from 'astro';
import { db } from '../../db/index';
import * as Indexing from '../../../scripts/pokemon-helper';
export const POST: APIRoute = async ({ request }) => {
const { cards, skus, inventory, recreate } = await request.json().catch(() => ({} as any));
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const log = (msg: string) => {
controller.enqueue(encoder.encode(msg + '\n'));
};
try {
if (!cards && !skus && !inventory) {
log('No collections selected.');
return;
}
if (recreate) {
if (cards) {
log('Recreating "cards" collection...');
await Indexing.createCardCollection(log);
}
if (skus) {
log('Recreating "skus" collection...');
await Indexing.createSkuCollection(log);
}
if (inventory) {
log('Recreating "inventories" collection...');
await Indexing.createInventoryCollection(log);
}
}
if (cards) {
log('Indexing "cards"...');
await Indexing.upsertCardCollection(db, log);
}
if (skus) {
log('Indexing "skus"...');
await Indexing.upsertSkuCollection(db, log);
}
if (inventory) {
log('Indexing "inventories"...');
await Indexing.upsertInventoryCollection(db, log);
}
log('Reindex complete.');
} catch (e: any) {
const cause = e?.cause;
const causeMsg = cause?.message || (cause ? String(cause) : '');
const causeDetail = cause?.detail ? ` | detail: ${cause.detail}` : '';
const causeCode = cause?.code ? ` | code: ${cause.code}` : '';
log(`Error: ${e?.message || String(e)}`);
if (causeMsg) log(`Caused by: ${causeMsg}${causeCode}${causeDetail}`);
console.error('Reindex error:', e);
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
});
};

View File

@@ -26,6 +26,18 @@ const totalQty = summary.totalQty || 0;
const totalValue = summary.totalValue || 0; const totalValue = summary.totalValue || 0;
const totalGain = summary.totalGain || 0; const totalGain = summary.totalGain || 0;
// distinct catalog names for this user, for the sidebar catalog list
const catalogRows = userId
? await db
.selectDistinct({ name: inventory.catalogName })
.from(inventory)
.where(eq(inventory.userId, userId))
: [];
const catalogs = catalogRows
.map(r => r.name)
.filter((n): n is string => !!n)
.sort((a, b) => a.localeCompare(b));
--- ---
<Layout title="Inventory Dashboard"> <Layout title="Inventory Dashboard">
@@ -35,13 +47,6 @@ const totalGain = summary.totalGain || 0;
<aside class="col-12 col-md-2 border-end border-secondary bg-dark p-3 d-flex flex-column gap-3"> <aside class="col-12 col-md-2 border-end border-secondary bg-dark p-3 d-flex flex-column gap-3">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-uppercase text-secondary fw-bold ls-wide" style="letter-spacing:.08em">Catalogs</h6> <h6 class="mb-0 text-uppercase text-secondary fw-bold ls-wide" style="letter-spacing:.08em">Catalogs</h6>
<button
class="btn btn-purple-secondary fs-7"
title="New catalog"
type="button"
data-bs-toggle="modal"
data-bs-target="#newCatalogModal"
>+ New</button>
</div> </div>
<ul id="catalogList" class="list-group list-group-flush"> <ul id="catalogList" class="list-group list-group-flush">
@@ -50,6 +55,10 @@ const totalGain = summary.totalGain || 0;
data-catalog="all" data-catalog="all"
role="button" role="button"
style="cursor:pointer" style="cursor:pointer"
hx-post="/partials/inventory-cards"
hx-vals={JSON.stringify({ catalog: "all" })}
hx-target="#gridView"
hx-swap="innerHTML"
> >
<span class="d-flex align-items-center gap-2"> <span class="d-flex align-items-center gap-2">
View all cards View all cards
@@ -57,12 +66,16 @@ const totalGain = summary.totalGain || 0;
<span class="badge rounded-pill text-bg-secondary small ms-auto">{totalQty}</span> <span class="badge rounded-pill text-bg-secondary small ms-auto">{totalQty}</span>
</li> </li>
{["Case Cards", "Japanese Singles", "Bulk"].map((name) => ( {catalogs.map((name) => (
<li <li
class="ms-2 list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center gap-2" class="ms-2 list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center gap-2"
data-catalog={name} data-catalog={name}
role="button" role="button"
style="cursor:pointer" style="cursor:pointer"
hx-post="/partials/inventory-cards"
hx-vals={JSON.stringify({ catalog: name })}
hx-target="#gridView"
hx-swap="innerHTML"
> >
{name} {name}
</li> </li>
@@ -76,22 +89,10 @@ const totalGain = summary.totalGain || 0;
</div> </div>
</aside> </aside>
<main class="col-12 col-md-10 p-4"> <main class="col-12 col-md-10 mt-4">
<div class="d-flex flex-wrap gap-2 align-items-center mb-4"> <div class="d-flex flex-wrap gap-2 mb-4">
<div class="d-flex align-items-center gap-1">
<button id="btnGrid" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn active" title="Images view">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1.5rem" height="1.5rem" fill="currentColor"><path d="M351.6 220.4C349.7 258.3 381.1 288.3 418 286.5L527.6 281.1C593 275.5 589.7 214.9 589 162.5C593.9 90.4 588.4 39.6 502.4 44.6C391.5 42.2 352.5 36.7 354.5 164.3C353.4 183.3 352.4 202.4 351.5 220.3zM418.4 168.6L418.4 168.6C419.6 147.4 420.8 125.9 421.6 109.4C421.6 109.2 421.6 109.1 421.7 109C421.7 108.8 422 108.6 422.1 108.6C456.2 108.5 491.4 108.6 525.7 108.7C525.8 108.7 526 109 526.1 109.1L526.1 109.3C525.5 142.4 524.9 184.6 524.2 217.3L415.6 222.6C416.3 207.3 417.4 188.1 418.5 168.7zM301.4 112.5C303.3 74.7 272 44.6 235 46.4L125.4 51.8C40.8 58.8 69.2 164.2 63.1 222.4C62.4 258.3 91.2 288.3 127.5 288.3C159.4 288.3 198.3 288.3 231 288.3C298.6 286.3 297.2 220.3 298.5 168.6C299.6 149.6 300.6 130.5 301.5 112.6zM234.6 164.3C233.4 185.6 232.2 207 231.4 223.5C231.4 223.7 231.4 223.8 231.3 223.9C231.3 224 231.1 224.2 231.1 224.2L231 224.3C198.3 224.2 159.3 224.3 127.3 224.3C127.3 224.3 127.2 224.3 127.1 224.2C127 224.1 126.9 224 126.9 224L126.9 223.8C127.5 192.1 128.1 149.9 128.8 115.8L237.4 110.5C236.7 125.8 235.6 145 234.5 164.4zM63.6 404C64.5 421.9 65.5 441 66.6 460C68.2 512.2 65.9 577.1 134.1 579.7L237.6 579.7C273.9 579.7 302.7 549.7 302 513.9C301.7 498.8 301.4 480.4 301.1 461.9C301.8 409.5 305 348.9 239.7 343.3L130 338C93 336.2 61.7 366.2 63.6 404.1zM130.4 455.8C129.3 436.4 128.3 417.1 127.5 401.8L236.1 407.1C236.8 441.2 237.3 483.4 238 515C238 515.1 238 515.1 238 515.2C238 515.3 237.9 515.4 237.8 515.5C237.7 515.6 237.6 515.6 237.6 515.6C205.8 515.6 166.4 515.5 134 515.6C133.9 515.6 133.7 515.3 133.7 515.2C133.6 515.1 133.6 515 133.6 514.8C132.8 498.3 131.6 476.9 130.4 455.6L130.4 455.6zM523 578.1C560 579.9 591.3 549.8 589.4 512C588.5 494.1 587.5 475 586.4 456C585.9 381.4 580.2 328.2 490 336.3C457.9 336.4 439 336.3 415.4 336.3C379.1 336.3 350.3 366.3 351 402.1C351.3 417.2 351.6 435.6 351.9 454.1C351.2 506.5 348 567.1 413.3 572.7L523 578.1zM525.5 514.1L416.9 508.8C416.2 474.7 415.7 432.5 415 400.9L415 400.7C415 400.6 415.3 400.3 415.3 400.3C443.6 400.3 484.7 400.3 518.9 400.3C518.9 400.3 519 400.3 519.1 400.4C519.2 400.5 519.3 400.6 519.3 400.7C519.4 400.8 519.4 400.9 519.4 401.1C521.1 435.4 524 484.9 525.4 514.3z"/></svg>
<span class="ms-1">Grid</span>
</button>
<button id="btnTable" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn" title="List view">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1.5rem" height="1.5rem" fill="currentColor" style="d-inline"><path d="M102.3 211.8C110.7 214 118.9 213.7 126.2 210C132.6 208 138.3 204 142.4 198.4C184.2 169.6 148.5 98.8 98.3 108.3C39.6 119.9 49.3 205.8 102.2 211.8zM114.4 375.6C114.4 375.6 114.8 375.5 116.2 375.3L116.5 375.2C169.2 368.9 178.6 283.3 120.1 271.7C69.9 262.3 34.2 332.9 76 361.8C80.1 367.4 85.9 371.4 92.3 373.4C99 376.6 106.8 377.6 114.5 375.5zM116.8 423.5C63.5 413.9 30 495.4 84.4 522.3C130.5 544.4 183.2 485.4 150.4 446.7C147.9 440.2 143.4 434.9 137.7 431.2C132.1 426.4 124.8 423.5 116.8 423.5zM352.8 508.4C423.1 503.6 491.2 499 561.5 501.8C579.2 502.5 594.1 488.8 594.8 471.1C595.5 453.4 581.7 438.6 564.1 437.9C490.4 435 416.4 439.9 344.2 444.8C310.8 447 277.8 449.3 245.4 450.7C227.7 451.4 214 466.4 214.8 484C215.5 501.7 230.5 515.4 248.1 514.6C283.8 513 318.6 510.7 352.8 508.4zM344 344.8L344.3 344.8C412.9 343.4 479.9 342.9 548.3 346.2C566 347 580.9 333.3 581.7 315.6C582.5 298 568.8 283 551.1 282.2C482.2 278.8 412.2 279.3 343.1 280.7C310.3 281.3 277.9 281.9 245.8 281.9C228.1 281.9 213.8 296.2 213.8 313.9C213.8 331.6 228.1 345.9 245.8 345.9C278.5 345.9 311.4 345.3 343.9 344.7zM444.8 187.4C480.8 186.5 519 181.7 551.8 187.6C569.2 190.7 585.8 179.1 588.9 161.7C592 144.3 580.4 127.7 563 124.6C484.4 115.2 408.3 126.5 331 129.9C301.8 131.9 273.7 133 246 131.4C228.4 130.3 213.2 143.8 212.2 161.4C213.8 217.2 300.2 190.8 335.5 193.7C372.2 191.2 408.7 188.4 444.8 187.4z"/></svg>
<span class="ms-1">List</span>
</button>
</div>
<div class="vr opacity-25 mx-1"></div> <a href="/pokemon" class="btn btn-vendor">Add cards</a>
<a href="/pokemon" class="btn btn-vendor">+ Add Card</a>
<button <button
type="button" type="button"
@@ -130,103 +131,10 @@ const totalGain = summary.totalGain || 0;
<div id="inventoryView"> <div id="inventoryView">
<div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load"> <div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load">
</div> </div>
<!-- <div id="tableView" style="display:none">
<div class="inv-list-wrap">
<table class="table align-middle mb-0 inv-list-table">
<tbody id="inventoryRows">
{inventory.map(card => {
const market = nmPrice(card);
const purchase = nmPurchase(card);
const diff = market - purchase;
const pct = purchase > 0 ? (diff / purchase) * 100 : 0;
const isGain = diff >= 0;
return (
<tr class="inv-list-row">
<td class="inv-list-cardcell">
<div class="inv-list-card">
<div
class="inv-list-thumb card-trigger"
data-card-id={card.productId}
data-bs-toggle="modal"
data-bs-target="#cardModal"
>
<img
src={`/cards/${card.productId}.jpg`}
alt={card.productName}
loading="lazy"
decoding="async"
onerror="this.onerror=null;this.src='/cards/default.jpg';"
/>
</div>
<div class="inv-list-info">
<div
class="inv-list-name"
data-card-id={card.productId}
data-bs-toggle="modal"
data-bs-target="#cardModal"
style="cursor:pointer"
>
{card.productName}
</div>
<div class="inv-list-meta">
<div class="inv-list-setlink">{card.setName}</div>
<div>{card.rarityName}</div>
<div>{card.number}</div>
</div>
<div class="inv-list-condition">
<span>Near Mint</span>
<span>•</span>
<span>{card.variant !== "Normal" ? card.variant : "Holofoil"}</span>
</div>
</div>
<div class="inv-list-right">
<div class={`inv-list-price-line ${isGain ? "up" : "down"}`}>
<span class="inv-grid-arrow small">{isGain ? "▲" : "▼"}</span>
<span class="inv-list-price">${market.toFixed(2)}</span>
</div>
<div class={`inv-list-delta ${isGain ? "up" : "down"}`}>
{isGain ? "+" : "-"}${Math.abs(diff).toFixed(2)} ({isGain ? "+" : "-"}{Math.abs(pct).toFixed(2)}%)
</div>
<div class="inv-list-qty">Qty: {card.qty}</div>
</div>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div class="text-secondary small mt-2 ps-1" id="rowCount"></div>
</div> -->
</div> </div>
</main> </main>
</div> </div>
<!-- <div class="modal fade" id="newCatalogModal" tabindex="-1" aria-labelledby="newCatalogLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="newCatalogLabel">Create Catalog</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<label class="form-label small text-secondary text-uppercase fw-semibold" for="catalogNameInput">Catalog Name</label>
<input id="catalogNameInput" type="text" class="form-control bg-dark-subtle text-light border-secondary" placeholder="e.g. Japanese Holos" />
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="createCatalogBtn">Create</button>
</div>
</div>
</div>
</div>-->
<div class="modal fade" id="bulkImportModal" tabindex="-1" aria-labelledby="bulkImportLabel" aria-modal="true" role="dialog"> <div class="modal fade" id="bulkImportModal" tabindex="-1" aria-labelledby="bulkImportLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-lg modal-dialog-centered"> <div class="modal-dialog modal-lg modal-dialog-centered">
@@ -247,49 +155,296 @@ const totalGain = summary.totalGain || 0;
</div> </div>
</div> </div>
<div class="modal-footer border-secondary"> <div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-danger" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="csvUploadBtn">Upload &amp; Preview</button> <button type="button" class="btn btn-success" id="csvUploadBtn">Upload</button>
</div>
</div>
</div>
</div>
<!--
<div class="modal fade" id="inventoryEditModal" tabindex="-1" aria-labelledby="inventoryEditLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="inventoryEditLabel">Edit Inventory</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="inventoryEditBody">
<p class="text-secondary small">Select a card to edit its quantity and purchase price.</p>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success">Save</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal fade" id="addCardModal" tabindex="-1" aria-labelledby="addCardLabel" aria-modal="true" role="dialog"> <!-- Inventory detail modal (loaded from /partials/inventory-modal on open) -->
<div class="modal-dialog modal-lg modal-dialog-centered"> <div class="modal card-modal" id="inventoryModal" tabindex="-1" aria-labelledby="inventoryModalLabel" aria-hidden="true">
<div class="modal-content bg-dark text-light border border-secondary"> <div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
<div class="modal-header border-secondary"> <div class="modal-content p-2">Loading...</div>
<h5 class="modal-title" id="addCardLabel">Add Card</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="text" class="form-control bg-dark-subtle text-light border-secondary mb-3" placeholder="Search card name…" id="addCardSearch" />
<p class="text-secondary small">Search results will appear here. Connect to your card database API to enable live search.</p>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" disabled>Add Selected</button>
</div>
</div>
</div> </div>
</div> --> </div>
<script is:inline>
(function () {
const catalogList = document.getElementById('catalogList');
if (catalogList) {
catalogList.addEventListener('click', (e) => {
const li = e.target.closest('li[data-catalog]');
if (!li) return;
catalogList.querySelectorAll('li[data-catalog]').forEach(el => el.classList.remove('active'));
li.classList.add('active');
});
}
})();
(function () {
const modal = document.getElementById('inventoryModal');
if (!modal) return;
const CONDITION_COLORS = {
"Near Mint": { active: 'hsla(88, 50%, 67%, 1)', muted: 'hsla(88, 50%, 67%, 0.67)' },
"Lightly Played": { active: 'hsla(66, 70%, 68%, 1)', muted: 'hsla(66, 70%, 68%, 0.67)' },
"Moderately Played": { active: 'hsla(54, 100%, 73%, 1)', muted: 'hsla(54, 100%, 73%, 0.67)' },
"Heavily Played": { active: 'hsla(46, 100%, 65%, 1)', muted: 'hsla(46, 100%, 65%, 0.67)' },
"Damaged": { active: 'hsla(36, 100%, 65%, 1)', muted: 'hsla(36, 100%, 65%, 0.67)' },
};
const PURCHASE_COLOR = 'hsla(258, 90%, 76%, 1)';
const RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '1y': 365, 'all': Infinity };
let chart = null;
let history = [];
let meta = { purchasePrice: null, purchaseDate: null, condition: 'Near Mint' };
let range = 'all';
function formatDate(dateStr) {
const [y, m, d] = dateStr.split('-');
return new Date(Number(y), Number(m) - 1, Number(d))
.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' });
}
function buildData(rangeKey) {
const cutoff = RANGE_DAYS[rangeKey] === Infinity
? new Date(0)
: new Date(Date.now() - RANGE_DAYS[rangeKey] * 86400000);
const filtered = history.filter(r => new Date(r.calculatedAt) >= cutoff);
const showPurchase = meta.purchaseDate
&& meta.purchasePrice != null
&& new Date(meta.purchaseDate) >= cutoff;
const dateSet = new Set(filtered.map(r => r.calculatedAt));
if (showPurchase) dateSet.add(meta.purchaseDate);
const dates = [...dateSet].sort();
const priceByDate = {};
filtered.forEach(r => { priceByDate[r.calculatedAt] = Number(r.marketPrice); });
return {
labels: dates.map(formatDate),
lineData: dates.map(d => priceByDate[d] ?? null),
purchaseData: dates.map(d => (showPurchase && d === meta.purchaseDate) ? meta.purchasePrice : null),
hasData: filtered.length > 0,
};
}
function render() {
if (!chart) return;
const { labels, lineData, purchaseData } = buildData(range);
chart.data.labels = labels;
chart.data.datasets[0].data = lineData;
chart.data.datasets[1].data = purchaseData;
chart.update('none');
}
function initChart(root) {
const canvas = root.querySelector('#inventoryPriceChart');
if (!canvas || typeof Chart === 'undefined') return;
if (chart) { chart.destroy(); chart = null; }
try { history = JSON.parse(canvas.dataset.history || '[]'); } catch { history = []; }
meta = {
purchasePrice: canvas.dataset.purchasePrice !== '' ? Number(canvas.dataset.purchasePrice) : null,
purchaseDate: canvas.dataset.purchaseDate || null,
condition: canvas.dataset.condition || 'Near Mint',
};
range = '6m';
root.querySelectorAll('.inv-price-range-btn').forEach(b =>
b.classList.toggle('active', b.dataset.range === '6m'));
const emptyEl = root.querySelector('#inventoryPriceEmpty');
const wrap = canvas.closest('.chart-canvas-wrap');
if (!history.length) {
emptyEl?.classList.remove('d-none');
wrap?.classList.add('d-none');
return;
}
emptyEl?.classList.add('d-none');
wrap?.classList.remove('d-none');
const colors = CONDITION_COLORS[meta.condition] || CONDITION_COLORS['Near Mint'];
const { labels, lineData, purchaseData } = buildData(range);
chart = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
labels,
datasets: [
{
label: 'Market Price',
data: lineData,
borderColor: colors.active,
borderWidth: 2,
pointRadius: 2,
pointHoverRadius: 5,
pointBackgroundColor: colors.active,
tension: 0.3,
fill: false,
spanGaps: true,
order: 1,
},
{
label: 'Purchase',
data: purchaseData,
showLine: false,
pointRadius: 6,
pointHoverRadius: 8,
pointStyle: 'rectRot',
pointBackgroundColor: PURCHASE_COLOR,
pointBorderColor: '#fff',
pointBorderWidth: 1,
order: 0,
},
],
},
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,
filter: (item) => item.parsed.y != null,
callbacks: {
labelColor: (ctx) => {
const c = ctx.dataset.label === 'Purchase' ? PURCHASE_COLOR : colors.active;
return { borderColor: c, backgroundColor: c };
},
label: (ctx) => {
if (ctx.dataset.label === 'Purchase') {
return ` Purchase: $${Number(ctx.parsed.y).toFixed(2)}`;
}
return ` Market: $${Number(ctx.parsed.y).toFixed(2)}`;
},
afterLabel: (ctx) => {
if (ctx.dataset.label === 'Purchase' || meta.purchasePrice == null) return;
const diff = ctx.parsed.y - meta.purchasePrice;
const sign = diff >= 0 ? '+' : '';
const pct = meta.purchasePrice > 0 ? (diff / meta.purchasePrice) * 100 : 0;
return `vs purchase: ${sign}$${Math.abs(diff).toFixed(2)} (${sign}${Math.abs(pct).toFixed(1)}%)`;
},
},
},
},
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: (v) => `$${Number(v).toFixed(2)}` },
border: { color: 'rgba(255, 255, 255, 0.1)' },
},
},
},
});
}
// ── Load modal content on open ──────────────────────────────────────────
modal.addEventListener('show.bs.modal', async (e) => {
const trigger = e.relatedTarget;
const inventoryId = trigger?.dataset.inventoryId;
const content = modal.querySelector('.modal-content');
if (!content) return;
if (!inventoryId) { content.innerHTML = '<div class="p-4 text-danger">Missing inventory id.</div>'; return; }
content.innerHTML = '<div class="p-5 text-center text-secondary">Loading…</div>';
try {
const res = await fetch(`/partials/inventory-modal?inventoryId=${encodeURIComponent(inventoryId)}`);
content.innerHTML = await res.text();
requestAnimationFrame(() => initChart(content));
} catch (err) {
content.innerHTML = '<div class="p-4 text-danger">Failed to load inventory entry.</div>';
}
});
modal.addEventListener('hidden.bs.modal', () => {
if (chart) { chart.destroy(); chart = null; }
history = [];
});
// ── Time-range buttons ──────────────────────────────────────────────────
modal.addEventListener('click', (e) => {
const btn = e.target.closest('.inv-price-range-btn');
if (!btn) return;
modal.querySelectorAll('.inv-price-range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
range = btn.dataset.range || 'all';
render();
});
// ── Delete inventory entry ──────────────────────────────────────────────
modal.addEventListener('click', async (e) => {
const delBtn = e.target.closest('[data-inventory-delete]');
if (!delBtn) return;
const form = modal.querySelector('[data-inventory-edit-form]');
const inventoryId = form?.querySelector('[name=inventoryId]')?.value;
const cardId = form?.querySelector('[name=cardId]')?.value;
if (!inventoryId) return;
if (!confirm('Are you sure you want to remove this card from your inventory?')) return;
const original = delBtn.textContent;
delBtn.disabled = true; delBtn.textContent = 'Deleting…';
try {
const body = new FormData();
body.append('action', 'remove');
body.append('inventoryId', inventoryId);
if (cardId) body.append('cardId', cardId);
const res = await fetch('/api/inventory', { method: 'POST', body });
if (res.ok) {
// drop the matching grid tile, then close the modal
document.querySelector(`#gridView [data-inventory-id="${inventoryId}"]`)?.closest('.col')?.remove();
const instance = window.bootstrap?.Modal.getInstance(modal);
if (instance) instance.hide();
else modal.querySelector('[data-bs-dismiss=modal]')?.click();
} else {
delBtn.disabled = false; delBtn.textContent = original;
}
} catch {
delBtn.disabled = false; delBtn.textContent = original;
}
});
// ── Save catalog + note ─────────────────────────────────────────────────
modal.addEventListener('submit', async (e) => {
const form = e.target.closest('[data-inventory-edit-form]');
if (!form) return;
e.preventDefault();
const btn = modal.querySelector('button[type="submit"][form="inventoryEditForm"]');
const original = btn ? btn.textContent : '';
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
try {
const res = await fetch('/api/inventory', { method: 'POST', body: new FormData(form) });
if (res.ok) {
const instance = window.bootstrap?.Modal.getInstance(modal);
if (instance) instance.hide();
else modal.querySelector('[data-bs-dismiss=modal]')?.click();
} else if (btn) {
btn.textContent = original; btn.disabled = false;
}
} catch {
if (btn) { btn.textContent = original; btn.disabled = false; }
}
});
})();
</script>
</div> </div>
<Footer slot="footer" /> <Footer slot="footer" />
</Layout> </Layout>

View File

@@ -11,8 +11,8 @@ import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
import { Tooltip } from "bootstrap"; import { Tooltip } from "bootstrap";
// auth check for inventory management features // auth check for inventory management features
//const { canAddInventory } = Astro.locals; const { canAddInventory } = Astro.locals;
const canAddInventory = false; // const canAddInventory = false;
export const partial = true; export const partial = true;
export const prerender = false; export const prerender = false;
@@ -55,9 +55,36 @@ const calculatedAt = (() => {
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 ────────────────────────────── // ── Spread-based volatility (high - low) / low ────────────────────────────
// Log-return volatility was unreliable because marketPrice is a smoothed daily
// value, not transaction-driven. The 30-day high/low spread is a more honest
// proxy for price movement over the period.
const volatilityByCondition: Record<string, { label: string; spread: number }> = {};
for (const price of card?.prices ?? []) {
const condition = price.condition;
const low = Number(price.lowestPrice);
const high = Number(price.highestPrice);
const market = Number(price.marketPrice);
if (!low || !high || !market || market <= 0) {
volatilityByCondition[condition] = { label: '—', spread: 0 };
continue;
}
const spread = (high - low) / market;
const label = spread >= 0.50 ? 'High'
: spread >= 0.25 ? 'Medium'
: 'Low';
volatilityByCondition[condition] = { label, spread: Math.round(spread * 100) / 100 };
}
// ── Price history for chart ───────────────────────────────────────────────
const cardSkus = card?.prices?.length const cardSkus = card?.prices?.length
? await db.select().from(skus).where(eq(skus.cardId, cardId)) ? await db.select().from(skus).where(eq(skus.cardId, card.cardId))
: []; : [];
const skuIds = cardSkus.map(s => s.skuId); const skuIds = cardSkus.map(s => s.skuId);
@@ -76,41 +103,6 @@ const historyRows = skuIds.length
.orderBy(priceHistory.calculatedAt) .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 => ({ const priceHistoryForChart = historyRows.map(row => ({
condition: row.condition, condition: row.condition,
calculatedAt: row.calculatedAt calculatedAt: row.calculatedAt
@@ -119,29 +111,11 @@ const priceHistoryForChart = historyRows.map(row => ({
marketPrice: row.marketPrice, marketPrice: row.marketPrice,
})).filter(r => r.calculatedAt !== null); })).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 condition: string = price?.condition || "Near Mint"; const condition: string = price?.condition || "Near Mint";
const vol = volatilityByCondition[condition] ?? { label: '—', monthlyVol: 0 }; const vol = volatilityByCondition[condition] ?? { label: '—', spread: 0 };
const volatilityClass = (() => { const volatilityClass = (() => {
switch (vol.label) { switch (vol.label) {
@@ -154,7 +128,7 @@ const conditionAttributes = (price: any) => {
const volatilityDisplay = vol.label === '—' const volatilityDisplay = vol.label === '—'
? '—' ? '—'
: `${vol.label} (${(vol.monthlyVol * 100).toFixed(0)}%)`; : `${vol.label} (${(vol.spread * 100).toFixed(0)}%)`;
return { return {
"Near Mint": { label: "nav-nm", volatility: volatilityDisplay, volatilityClass, class: "show active" }, "Near Mint": { label: "nav-nm", volatility: volatilityDisplay, volatilityClass, class: "show active" },
@@ -177,7 +151,7 @@ for (const price of card?.prices ?? []) {
const availableVariants = [...new Set(cardSkus.map(s => s.variant))].sort(); const availableVariants = [...new Set(cardSkus.map(s => s.variant))].sort();
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)}+${encodeURIComponent(card?.variant)}&LH_Sold=1&Graded=No&_dcat=183454`;
}; };
const altSearchUrl = (card: any) => { const altSearchUrl = (card: any) => {
@@ -205,9 +179,6 @@ const altSearchUrl = (card: any) => {
<!-- Card image column --> <!-- 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"> <div class="position-relative mt-1">
<!-- card-image-wrap gives the modal image shimmer effects
without the hover lift/scale that image-grow has in main.scss -->
<div <div
class="card-image-wrap rounded-4" class="card-image-wrap rounded-4"
data-energy={card?.energyType} data-energy={card?.energyType}
@@ -287,11 +258,11 @@ const altSearchUrl = (card: any) => {
<p class="mb-0 mt-1">${price.marketPrice}</p> <p class="mb-0 mt-1">${price.marketPrice}</p>
</div> </div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0"> <div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Lowest Price</h6> <h6 class="mb-auto">Low Price <span class="small p text-secondary">(30 day)</span></h6>
<p class="mb-0 mt-1">${price.lowestPrice}</p> <p class="mb-0 mt-1">${price.lowestPrice}</p>
</div> </div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0"> <div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Highest Price</h6> <h6 class="mb-auto">High Price <span class="small p text-secondary">(30 day)</span></h6>
<p class="mb-0 mt-1">${price.highestPrice}</p> <p class="mb-0 mt-1">${price.highestPrice}</p>
</div> </div>
<div class={`alert rounded p-2 flex-fill d-flex flex-column mb-0 ${attributes?.volatilityClass}`}> <div class={`alert rounded p-2 flex-fill d-flex flex-column mb-0 ${attributes?.volatilityClass}`}>
@@ -306,14 +277,14 @@ const altSearchUrl = (card: any) => {
data-bs-trigger="hover focus click" data-bs-trigger="hover focus click"
data-bs-html="true" data-bs-html="true"
data-bs-title={` data-bs-title={`
<div class='tooltip-heading fw-bold mb-1'>Monthly Volatility</div> <div class='tooltip-heading fw-bold mb-1'>30-Day Price Spread</div>
<div class='small'> <div class='small'>
<p class="mb-1"> <p class="mb-1">
<strong>What this measures:</strong> how much the market price tends to move day-to-day, <strong>What this measures:</strong> how wide the gap between the 30-day low and high is,
scaled up to a monthly expectation. relative to the market price.
</p> </p>
<p class="mb-0"> <p class="mb-0">
A card with <strong>30% volatility</strong> typically swings ±30% over a month. A card with <strong>50%+ spread</strong> has seen significant price swings over the past month.
</p> </p>
</div> </div>
`} `}
@@ -502,7 +473,7 @@ const altSearchUrl = (card: any) => {
</datalist> </datalist>
<div class="form-text"> <div class="form-text">
Type a name or pick an existing catalog. Enter a name or pick an existing catalog.
</div> </div>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -566,16 +537,18 @@ const altSearchUrl = (card: any) => {
</canvas> </canvas>
</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="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end mt-2" role="group" aria-label="Time range">
{showRanges['1m'] && <button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button>} <button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button>
{showRanges['3m'] && <button type="button" class="btn btn-dark price-range-btn" data-range="3m">3M</button>} <button type="button" class="btn btn-dark price-range-btn" data-range="3m">3M</button>
{showRanges['6m'] && <button type="button" class="btn btn-dark price-range-btn" data-range="6m">6M</button>} <button type="button" class="btn btn-dark price-range-btn" data-range="6m">6M</button>
{showRanges['1y'] && <button type="button" class="btn btn-dark price-range-btn" data-range="1y">1Y</button>} <button type="button" class="btn btn-dark price-range-btn" data-range="1y">1Y</button>
{showRanges['all'] && <button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>} <button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- External links column --> <!-- External links column -->

View File

@@ -7,8 +7,8 @@ export const prerender = false;
import * as util from 'util'; import * as util from 'util';
// auth check for inventory management features // auth check for inventory management features
//const { canAddInventory } = Astro.locals; const { canAddInventory } = Astro.locals;
const canAddInventory = false; // const canAddInventory = false;
// 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 = {
@@ -22,14 +22,14 @@ const facetFields:any = {
// ── Allowed sort values ─────────────────────────────────────────────────── // ── Allowed sort values ───────────────────────────────────────────────────
const sortMap: Record<string, string> = { const sortMap: Record<string, string> = {
'releaseDate:desc,number:asc': '_text_match:asc,releaseDate:desc,number:asc', 'releaseDate:desc,number:asc': '_text_match:asc,releaseDate:desc,inumber(missing_values:last):asc',
'releaseDate:asc,number:asc': '_text_match:asc,releaseDate:asc,number:asc', 'releaseDate:asc,number:asc': '_text_match:asc,releaseDate:asc,inumber(missing_values:last):asc',
'marketPrice:desc': 'marketPrice:desc,releaseDate:desc,number:asc', 'marketPrice:desc': 'marketPrice:desc,releaseDate:desc,inumber(missing_values:last):asc',
'marketPrice:asc': 'marketPrice:asc,releaseDate:desc,number:asc', 'marketPrice:asc': 'marketPrice:asc,releaseDate:desc,inumber(missing_values:last):asc',
'number:asc': '_text_match:asc,number:asc', 'number:asc': '_text_match:asc,inumber(missing_values:last):asc',
'number:desc': '_text_match:asc,number:desc', 'number:desc': '_text_match:asc,inumber(missing_values:last):desc',
}; };
const DEFAULT_SORT = '_text_match:asc,releaseDate:desc,number:asc'; const DEFAULT_SORT = '_text_match:asc,releaseDate:desc,inumber(missing_values:last):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();
@@ -51,15 +51,53 @@ const languageFilter = language === 'en' ? " && productLineName:=`Pokemon`"
// synonyms alone (e.g. terms that need to match across multiple set names) // 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 // and rewrites them into a direct filter, clearing the query so it doesn't
// also try to text-match against card names. // 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; const ALIAS_FILTERS = [
// ── Era / set groupings ───────────────────────────────────────────────
{ re: /^(e-?reader|e reader)$/i, field: 'setName',
values: ['Expedition Base Set', 'Aquapolis', 'Skyridge', 'Battle-e'] },
{ re: /^neo$/i, field: 'setName',
values: ['Neo Genesis', 'Neo Discovery', 'Neo Revelation', 'Neo Destiny'] },
{ re: /^(wotc|wizards)$/i, field: 'setName',
values: ['Base Set', 'Jungle', 'Fossil', 'Base Set 2', 'Team Rocket',
'Gym Heroes', 'Gym Challenge', 'Neo Genesis', 'Neo Discovery',
'Neo Revelation', 'Neo Destiny', 'Expedition Base Set',
'Aquapolis', 'Skyridge', 'Battle-e'] },
{ re: /^(sun\s*(&|and)\s*moon|s(&|and)m|sm)$/i, field: 'setName',
values: ['Sun & Moon', 'Guardians Rising', 'Burning Shadows', 'Crimson Invasion',
'Ultra Prism', 'Forbidden Light', 'Celestial Storm', 'Dragon Majesty',
'Lost Thunder', 'Team Up', 'Unbroken Bonds', 'Unified Minds',
'Hidden Fates', 'Cosmic Eclipse', 'Detective Pikachu'] },
{ re: /^(sword\s*(&|and)\s*shield|s(&|and)s|swsh)$/i, field: 'setName',
values: ['Sword & Shield', 'Rebel Clash', 'Darkness Ablaze', 'Vivid Voltage',
'Battle Styles', 'Chilling Reign', 'Evolving Skies', 'Fusion Strike',
'Brilliant Stars', 'Astral Radiance', 'Pokemon GO', 'Lost Origin',
'Silver Tempest', 'Crown Zenith'] },
// ── Card type shorthands ──────────────────────────────────────────────
{ re: /^trainers?$/i, field: 'cardType', values: ['Trainer'] },
{ re: /^supporters?$/i, field: 'cardType', values: ['Supporter'] },
{ re: /^stadiums?$/i, field: 'cardType', values: ['Stadium'] },
{ re: /^items?$/i, field: 'cardType', values: ['Item'] },
{ re: /^(energys?|energies)$/i, field: 'cardType', values: ['Energy'] },
// ── Rarity shorthands ─────────────────────────────────────────────────
{ re: /^promos?$/i, field: 'rarityName', values: ['Promo'] },
];
let resolvedQuery = query; let resolvedQuery = query;
let queryFilter = ''; let queryFilter = '';
if (EREADER_RE.test(query.trim())) { for (const alias of ALIAS_FILTERS) {
resolvedQuery = ''; if (alias.re.test(query.trim())) {
queryFilter = `setName:=[${EREADER_SETS.map(s => '`' + s + '`').join(',')}]`; resolvedQuery = '';
queryFilter = `${alias.field}:=[${alias.values.map(s => '`' + s + '`').join(',')}]`;
break;
}
} }
const filters = Array.from(formData.entries()) const filters = Array.from(formData.entries())
@@ -91,15 +129,15 @@ const facetFilter = (facet:string) => {
// primary search values (for cards) // primary search values (for cards)
let searchArray = [{ // Note: no `$skus(...)` join here — see the sku fetch below for why.
let searchArray: any[] = [{
collection: 'cards', collection: 'cards',
filter_by: `$skus(id:*) && sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${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: resolvedSort, sort_by: resolvedSort,
include_fields: '$skus(*)',
}]; }];
// on first load (start === 0) we want to get the facets for the filters // on first load (start === 0) we want to get the facets for the filters
@@ -121,7 +159,10 @@ if (start === 0) {
const searchRequests = { searches: searchArray }; const searchRequests = { searches: searchArray };
const commonSearchParams = { const commonSearchParams = {
q: resolvedQuery, q: resolvedQuery,
query_by: 'content,setName,productLineName,rarityName,energyType,cardType' query_by: 'content,setName,setCode,productName,Artist',
query_by_weights: '10,6,8,9,8',
num_typos: '2,1,0,1,2',
prefix: 'true,true,false,false,false',
}; };
// use typesense to search for cards matching the query and return the productIds of the results // use typesense to search for cards matching the query and return the productIds of the results
@@ -131,6 +172,28 @@ const cardResults = searchResults.results[0] as any;
const pokemon = cardResults.hits?.map((hit: any) => hit.document) ?? []; const pokemon = cardResults.hits?.map((hit: any) => hit.document) ?? [];
const totalHits = cardResults?.found; const totalHits = cardResults?.found;
// Skus aren't used for searching or sorting — they only supply the per-condition
// prices displayed on each card. Joining them into the primary search via
// `$skus(id:*)` forces Typesense to materialize the reverse join across the whole
// filtered result set before pagination (~20x slower). Instead, fetch skus for
// just the visible cards in one direct, indexed filter and attach them by id.
if (pokemon.length > 0) {
const cardIds = pokemon.map((c: any) => c.id);
const skuSearch = await client.collections('skus').documents().search({
q: '*',
query_by: 'condition',
filter_by: `card_id:=[${cardIds.map((id: string) => '`' + id + '`').join(',')}]`,
per_page: 250,
include_fields: 'condition,marketPrice,card_id',
});
const skusByCard: Record<string, any[]> = {};
for (const hit of (skuSearch.hits ?? []) as any[]) {
const sku = hit.document;
(skusByCard[sku.card_id] ??= []).push(sku);
}
for (const card of pokemon) card.skus = skusByCard[card.id] ?? [];
}
// format price to 2 decimal places (or 0 if price >=100) and adds a $ sign, if the price is null it returns "" // format price to 2 decimal places (or 0 if price >=100) and adds a $ sign, if the price is null it returns ""
const formatPrice = (condition:string, skus: any) => { const formatPrice = (condition:string, skus: any) => {
@@ -280,20 +343,20 @@ const facets = searchResults.results.slice(1).map((result: any) => {
} }
{pokemon.length === 0 && ( {pokemon.length === 0 && (
<div id="notfound" hx-swap-oob="true"> <div id="notfound" class="mt-4 h6" hx-swap-oob="true">
Pokemon not found No cards found! Please modify your search and try again.
</div> </div>
)} )}
{pokemon.map((card:any) => ( {pokemon.map((card:any) => (
<div class="col"> <div class="col equal-height-col">
{canAddInventory && ( {canAddInventory && (
<button type="button" class="btn btn-sm inventory-button position-relative float-end shadow-filter text-center p-2 fw-bold" 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="event.stopPropagation(); sessionStorage.setItem('openModalTab', 'nav-vendor');"> <button type="button" class="btn btn-sm inventory-button position-relative float-end shadow-filter text-center p-2 fw-bold" 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="event.stopPropagation(); sessionStorage.setItem('openModalTab', 'nav-vendor');">
+/ +/
</button> </button>
)} )}
<div class="card-trigger position-relative" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});"> <div class="card-trigger position-relative" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});">
<div class="image-grow rounded-4 card-image" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}><img src={`/static/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 w-100" onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow')?.setAttribute('data-default','true')"/><span class="position-absolute top-50 start-0 d-inline medium-icon"><FirstEditionIcon edition={card?.variant} /></span> <div class="image-grow rounded-4 card-image" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}><img src={`/static/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 w-100 h-100" onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow')?.setAttribute('data-default','true')"/><span class="position-absolute top-50 start-0 d-inline medium-icon"><FirstEditionIcon edition={card?.variant} /></span>
<div class="holo-shine"></div> <div class="holo-shine"></div>
<div class="holo-glare"></div> <div class="holo-glare"></div>
</div> </div>
@@ -309,15 +372,15 @@ const facets = searchResults.results.slice(1).map((result: any) => {
<div class="fs-5 fw-semibold my-0">{card.productName}</div> <div class="fs-5 fw-semibold 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-body-tertiary flex-grow-1 d-none d-lg-flex fst-normal">{card.setName}</div> <div class="text-body-tertiary flex-grow-1 d-none d-lg-flex fst-normal">{card.setName}</div>
<div class="text-body-tertiary flex-grow-1 d-flex d-lg-none fst-normal">{card.setCode}</div>
<div class="text-body-tertiary fst-normal">{card.number}</div> <div class="text-body-tertiary fst-normal">{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 class="text-secondary fst-italic">{card.variant}</div><span class="d-none">{card.productId}</span> <div class="text-secondary fst-italic">{card.variant}</div><span class="d-none">{card.productId}</span>
</div> </div>
))} ))}
{start + 20 < totalHits && {start + 20 < totalHits &&
<div hx-post="/partials/cards" hx-trigger="revealed" hx-include="#searchform" hx-target="#cardGrid" hx-swap="beforeend" hx-on--after-request="afterUpdate(event)"> <div hx-post="/partials/cards" hx-trigger="revealed" hx-include="#searchform" hx-target="#cardGrid" hx-swap="beforeend" hx-on--after-request="afterUpdate(event)">
Loading... Loading...
</div> </div>
} }

View File

@@ -11,9 +11,16 @@ import { db } from '../../db';
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 catalog = formData.get('catalog')?.toString() || '';
const { userId } = Astro.locals.auth(); const { userId } = Astro.locals.auth();
// scope results to the selected catalog (empty / "all" => every catalog)
let filterBy = `userId:=${userId}`;
if (catalog && catalog !== 'all') {
filterBy += ` && catalogName:=\`${catalog}\``;
}
const InventoryDetails = async (inventoryId: string) => { const InventoryDetails = async (inventoryId: string) => {
//console.log('inventoryid', inventoryId); //console.log('inventoryid', inventoryId);
const details = await db.query.inventory.findFirst({ const details = await db.query.inventory.findFirst({
@@ -27,7 +34,7 @@ const InventoryDetails = async (inventoryId: string) => {
// primary search values (for cards) // primary search values (for cards)
let searchArray = [{ let searchArray = [{
collection: 'inventories', collection: 'inventories',
filter_by: `userId:=${userId}`, filter_by: filterBy,
per_page: 20, per_page: 20,
facet_by: '', facet_by: '',
max_facet_values: 0, max_facet_values: 0,
@@ -90,7 +97,7 @@ console.log(`totalHits: ${totalHits}`);
return ( return (
<div class="col equal-height-col"> <div class="col equal-height-col">
<div class="card-trigger position-relative inv-grid-media" data-card-id={card.productId} data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName} data-bs-toggle="modal" data-bs-target="#cardModal"> <div class="card-trigger position-relative inv-grid-media" data-inventory-id={inventory.inventoryId} data-card-id={card.productId} data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName} data-bs-toggle="modal" data-bs-target="#inventoryModal">
<div class="rounded-4 card-image h-100"> <div class="rounded-4 card-image h-100">
<img src={`static/cards/${card.productId}.jpg`} alt={card.productName} loading="lazy" decoding="async" class="img-fluid rounded-4 w-100" onerror="this.onerror=null;this.src='static/cards/default.jpg';this.closest('.image-grow')?.setAttribute('data-default','true')" /> <img src={`static/cards/${card.productId}.jpg`} alt={card.productName} loading="lazy" decoding="async" class="img-fluid rounded-4 w-100" onerror="this.onerror=null;this.src='static/cards/default.jpg';this.closest('.image-grow')?.setAttribute('data-default','true')" />
<span class="position-absolute top-50 start-0 d-inline medium-icon" style="z-index:4"> <span class="position-absolute top-50 start-0 d-inline medium-icon" style="z-index:4">
@@ -98,18 +105,6 @@ console.log(`totalHits: ${totalHits}`);
</span> </span>
</div> </div>
</div> </div>
<div class="d-flex flex-row justify-content-between my-1 align-items-center edit-bar">
<input type="number" class="form-control form-control-sm text-center" style="max-width: 33%;" value={inventory.quantity} min="1" max="999" aria-label="Quantity input" aria-describedby="button-minus button-plus">
<div class="" aria-label="Edit controls">
<button type="button" class="btn btn-sm btn-edit me-2"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" class="edit-svg"><path d="M374.4 146.5L374.4 146.5C395.3 128.4 443.5 85.4 468.5 97.6C484.4 105.9 497.8 112.8 501.3 120.1L501.4 120.2L501.5 120.3L501.6 120.5C507.6 130.2 518.4 150.1 519.6 161.6C513.5 192.1 484 217.1 461.6 240.4C425.7 208.6 404.1 187.1 369.7 150.6C371.3 149.2 372.8 147.9 374.4 146.5zM484.1 307.3C484.3 307.1 484.5 307 484.6 306.8C525.1 265.7 579.4 226.1 583.7 161.7C575.2 64.5 475.6-2.1 395.9 50.5C299.2 114.8 235.3 194.9 152 272.8C118.1 305.6 72.6 334.1 62.3 384.2C60.7 400.9 62.5 429.9 63.3 446.8C63.3 472.2 62.6 516.6 62.3 535.2C65.6 607.8 181.9 562 225.7 560.1C249.2 555.4 267.7 533 280.2 519.6C280.4 519.4 280.5 519.3 280.5 519.3C350.4 450.8 416.9 378.7 484.2 307.4zM416.2 285.7C349.4 358.2 282.1 428.8 211.2 497.7C182.1 502.9 154.5 507.3 126.6 510C128.2 471.4 125.7 426.3 125.8 391.4C138.8 367.7 172.1 340.8 194.2 320.8C239.9 279.4 276.7 234.2 323.2 194.7C356.5 230 380.6 254.2 416.1 285.7z"/></svg></button>
<button type="button" class="btn btn-sm btn-delete"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" class="delete-svg">
<filter id='shadow' color-interpolation-filters="sRGB">
<feDropShadow dx="2" dy="2" stdDeviation="2" flood-opacity="0.5"/>
</filter>
<path d="M345.1 124.8C361 125.2 374.2 126 386.3 127.8C386.2 137.1 385.4 148.3 384.6 157.9C340.9 156.9 297.8 157.6 254.5 158C254.8 149.7 252.4 136.7 255.2 129.7C279.3 123.4 313.6 125.1 345.1 124.7zM448.7 160.7C460.3 66.9 431.8 64.5 346.7 60.8C268.3 59.6 178 54.7 190.5 158.2C157.2 162.7 96.1 137.9 89.6 186.5C88.1 210.7 111.2 222.9 132.8 220.8L132.8 478.6C132.6 530.6 176.7 571.9 228.6 569.3L398.9 569.3C500.4 574.1 515.3 476.9 509.7 395.2C509.1 364.1 511.4 273.6 512.5 223.8C554.7 223.7 554.5 159.5 512.2 159.8L448.6 160.7zM448.5 224.7C446.1 301.9 445.5 378 445.8 440.3C442.6 476.1 442.9 507.5 400.1 505.2L227 505.2C211.1 506.8 196.6 494.9 196.7 478.5L196.7 222.1C280.4 222.3 366.5 219.1 448.4 224.6z"/></svg></button>
</div>
</div>
<div class="d-flex flex-row mt-1"> <div class="d-flex flex-row mt-1">
<div class="text-body-tertiary flex-grow-1 small">{card.setName}</div> <div class="text-body-tertiary flex-grow-1 small">{card.setName}</div>
</div> </div>

View File

@@ -0,0 +1,249 @@
---
import ebay from "/vendors/ebay.svg?raw";
import SetIcon from '../../components/SetIcon.astro';
import EnergyIcon from '../../components/EnergyIcon.astro';
import RarityIcon from '../../components/RarityIcon.astro';
import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
import { db } from '../../db/index';
import { inventory } from '../../db/schema';
import { eq } from 'drizzle-orm';
export const partial = true;
export const prerender = false;
const { userId } = Astro.locals.auth();
const searchParams = Astro.url.searchParams;
const inventoryId = searchParams.get('inventoryId') || '';
const inv = userId && inventoryId
? await db.query.inventory.findFirst({
where: { inventoryId: inventoryId, userId: userId },
with: {
sku: {
with: {
card: { with: { set: true } },
history: true,
},
},
},
})
: null;
const sku = inv?.sku;
const card = sku?.card;
// Distinct catalog names for this user (datalist suggestions)
const catalogRows = userId
? await db
.selectDistinct({ name: inventory.catalogName })
.from(inventory)
.where(eq(inventory.userId, userId))
: [];
const catalogs = catalogRows.map(r => r.name).filter((n): n is string => !!n);
// ── Values from the inventory entry ──────────────────────────────────────
const quantity = inv?.quantity ?? 0;
const purchasePrice = inv?.purchasePrice != null ? Number(inv.purchasePrice) : null;
const purchaseDate = inv?.createdAt ? new Date(inv.createdAt).toISOString().split('T')[0] : null;
const marketPrice = sku?.marketPrice != null ? Number(sku.marketPrice) : null;
const condition = sku?.condition || 'Near Mint';
const gain =
purchasePrice != null && marketPrice != null
? marketPrice - purchasePrice
: null;
const gainPercent =
purchasePrice != null &&
marketPrice != null &&
purchasePrice > 0
? ((marketPrice - purchasePrice) / purchasePrice) * 100
: null;
// ── Price history for this SKU's condition only ──────────────────────────
const priceHistoryForChart = (sku?.history ?? [])
.map(h => ({
calculatedAt: h.calculatedAt
? new Date(h.calculatedAt).toISOString().split('T')[0]
: null,
marketPrice: h.marketPrice != null ? Number(h.marketPrice) : null,
}))
.filter(h => h.calculatedAt !== null && h.marketPrice !== null)
.sort((a, b) => (a.calculatedAt! < b.calculatedAt! ? -1 : 1));
const ebaySearchUrl = (card: any) => {
return `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&LH_Sold=1&Graded=No&_dcat=183454`;
};
const altSearchUrl = (card: any) => {
return `https://alt.xyz/browse?query=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&sortBy=newest_first`;
};
---
{!inv && (
<Fragment>
<div class="modal-header border-0">
<div class="h5 mb-0">Inventory entry not found</div>
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-secondary mb-0">This card is no longer in your inventory.</p>
</div>
</Fragment>
)}
{inv && (
<Fragment>
<div class="modal-header border-0">
<div class="container-fluid row align-items-center">
<div class="h4 card-title pe-2 col-sm-12 col-md-auto mb-sm-1">{card?.productName}</div>
<div class="text-secondary col-auto">{card?.number}</div>
<div class="text-light col-auto">{card?.variant}</div>
</div>
<div class="d-flex gap-2 align-items-center">
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="modal"></button>
</div>
</div>
<div class="modal-body pt-0">
<div class="container-fluid">
<div class="card mb-2 border-0">
<div class="row g-4">
<!-- Card image column -->
<div class="col-sm-12 col-md-3">
<div class="position-relative mt-1">
<div class="card-image-wrap rounded-4" data-energy={card?.energyType} data-rarity={card?.rarityName} data-variant={card?.variant} data-name={card?.productName}>
<img src={`/static/cards/${card?.productId}.jpg`}
class="card-image w-100 img-fluid rounded-4"
alt={card?.productName}
crossorigin="anonymous"
onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow, .card-image-wrap')?.setAttribute('data-default','true')"
onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});"
/>
</div>
<span class="position-absolute top-50 start-0 d-inline"><FirstEditionIcon edition={card?.variant} /></span>
<span class="position-absolute bottom-0 start-0 d-inline"><SetIcon set={card?.set?.setCode} /></span>
<span class="position-absolute top-0 end-0 d-inline"><EnergyIcon energy={card?.energyType} /></span>
<span class="rarity-icon-large position-absolute bottom-0 end-0 d-inline"><RarityIcon rarity={card?.rarityName} /></span>
</div>
<div class="d-flex flex-column flex-lg-row justify-content-between mt-2">
<div class="text-secondary"><span class="d-flex d-xxl-none">{card?.set?.setCode}</span><span class="d-none d-xxl-flex">{card?.set?.setName}</span></div>
<div class="text-secondary">Illus<span class="d-none d-xxl-inline">trator</span>: {card?.artist}</div>
</div>
</div>
<!-- Inventory details + edit column -->
<div class="col-sm-12 col-md-7">
<!-- Static fields (left) + editable Note/Catalog (right), side by side -->
<div class="row gx-3 gy-2">
<!-- Left column: read-only stats -->
<div class="col-12 col-lg-6">
<label class="form-label">Condition</label>
<div class="btn-group condition-input w-100 col-12" role="group" aria-label="Condition">
<input type="radio" class="btn-check" name="condition" id="cond-nm" value="Near Mint" autocomplete="off" checked />
<label class="btn btn-cond-nm" for="cond-nm">NM</label>
<input type="radio" class="btn-check" name="condition" id="cond-lp" value="Lightly Played" autocomplete="off" disabled />
<label class="btn btn-cond-lp" for="cond-lp">LP</label>
<input type="radio" class="btn-check" name="condition" id="cond-mp" value="Moderately Played" autocomplete="off" disabled />
<label class="btn btn-cond-mp" for="cond-mp">MP</label>
<input type="radio" class="btn-check" name="condition" id="cond-hp" value="Heavily Played" autocomplete="off" disabled />
<label class="btn btn-cond-hp" for="cond-hp">HP</label>
<input type="radio" class="btn-check" name="condition" id="cond-dmg" value="Damaged" autocomplete="off" disabled />
<label class="btn btn-cond-dmg" for="cond-dmg">DMG</label>
</div>
<p class="mb-1 my-2">Purchased: <span class="text-secondary">{inv.createdAt ? new Date(inv.createdAt).toLocaleDateString() : '—'}</span></p>
<div class="d-flex flex-wrap justify-content-center flex-row gap-2">
<div class="alert alert-dark d-flex flex-column flex-fill rounded p-2 mb-2">
<p class="mb-auto">Purchase Price</p>
<h6 class="mb-0 mt-1">{purchasePrice != null ? `$${purchasePrice.toFixed(2)}` : '—'}</h6>
</div>
<div class="alert alert-dark d-flex flex-column flex-fill rounded p-2 mb-2">
<p class="mb-auto">Market Price</p>
<h6 class="mb-0 mt-1">{marketPrice != null ? `$${marketPrice.toFixed(2)}` : '—'}</h6>
</div>
</div>
<div class="d-flex flex-wrap justify-content-between flex-row gap-2">
<div class={`alert rounded p-2 flex-fill ${gain == null ? 'alert-dark' : gain >= 0 ? 'alert-success' : 'alert-danger'}`}>
<p class="mb-auto">Gain / Loss ($)</p>
<h6 class="mb-0 mt-1">{gain == null ? '—' : `${gain >= 0 ? '+' : ''}$${Math.abs(gain).toFixed(2)}`}</h6>
</div>
<div class={`alert rounded p-2 flex-fill ${gainPercent == null ? 'alert-dark' : gainPercent >= 0 ? 'alert-success' : 'alert-danger'}`}>
<p class="mb-auto">Gain / Loss (%)</p>
<h6 class="mb-0 mt-1">
{ gainPercent == null ? '—' : `${gainPercent >= 0 ? '+' : ''}${Math.abs(gainPercent).toFixed(2)}%` }
</h6>
</div>
</div>
</div>
<!-- Right column: editable Note + Catalog -->
<div class="col-12 col-lg-6">
<form id="inventoryEditForm" data-inventory-edit-form novalidate>
<input type="hidden" name="action" value="update" />
<input type="hidden" name="inventoryId" value={inv?.inventoryId} />
<input type="hidden" name="cardId" value={card?.cardId} />
<input type="hidden" name="quantity" value={quantity} />
<input type="hidden" name="purchasePrice" value={purchasePrice ?? 0} />
<label for="catalogName" class="form-label">Catalog</label>
<input type="text" class="form-control" id="catalogName" name="catalogName" list="catalogSuggestions" placeholder="Default" maxlength="100" value={inv?.catalogName ?? ''} />
<datalist id="catalogSuggestions">
{catalogs.map(name => <option value={name}></option>)}
</datalist>
<label for="note" class="form-label mt-2">Note</label>
<textarea class="form-control" id="note" name="note" rows="5" maxlength="255" placeholder="e.g. bought at local shop, gift, graded copy…">
{inv?.note ?? ''}
</textarea>
</form>
</div>
</div>
<!-- Market Price History chart for this condition -->
<div class="d-block d-lg-flex gap-1 mt-2 price-chart-container">
<div class="col-12">
<div class="alert alert-dark rounded p-2 mb-0">
<h6>Market Price History <span class="small text-secondary">({condition})</span></h6>
<div id="inventoryPriceEmpty" class="d-none text-secondary text-center py-4">
No price history for this condition
</div>
<div class="position-relative chart-canvas-wrap" style="height: 220px;">
<canvas id="inventoryPriceChart" class="price-history-chart" data-history={JSON.stringify(priceHistoryForChart)} data-purchase-price={purchasePrice ?? ''} data-purchase-date={purchaseDate ?? ''} data-condition={condition}></canvas>
</div>
<div class="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end mt-2" role="group" aria-label="Time range">
<button type="button" class="btn btn-dark inv-price-range-btn" data-range="1m">1M</button>
<button type="button" class="btn btn-dark inv-price-range-btn" data-range="3m">3M</button>
<button type="button" class="btn btn-dark inv-price-range-btn active" data-range="6m">6M</button>
<button type="button" class="btn btn-dark inv-price-range-btn" data-range="1y">1Y</button>
<button type="button" class="btn btn-dark inv-price-range-btn" data-range="all">All</button>
</div>
</div>
</div>
</div>
</div>
<!-- External links column -->
<div class="col-sm-12 col-md-2 mt-0 mt-md-4 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 class="d-flex gap-2 mt-auto align-items-end justify-content-evenly">
<button type="button" class="btn btn-outline-danger flex-fill" data-inventory-delete>Delete</button>
<button type="submit" form="inventoryEditForm" class="btn btn-success flex-fill flex-grow-1">Save</button>
</div>
</div>
</div>
</div>
</div>
</div>
</Fragment>
)}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.5 KiB

61
src/volatility.ts Normal file
View File

@@ -0,0 +1,61 @@
export interface VolatilityResult {
label: 'High' | 'Medium' | 'Low' | '—';
monthlyVol: number;
}
/**
* Computes 30-day rolling volatility from an array of prices using
* log-return standard deviation scaled to a monthly expectation.
*
* @param prices - Ordered array of market prices (oldest → newest)
* @returns label ('High' | 'Medium' | 'Low' | '—') and monthlyVol (01 range)
*/
export function computeVolatility(prices: number[]): VolatilityResult {
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: VolatilityResult['label'] =
monthlyVol >= 0.30 ? 'High'
: monthlyVol >= 0.15 ? 'Medium'
: 'Low';
return { label, monthlyVol: Math.round(monthlyVol * 100) / 100 };
}
/**
* Groups a list of price-history rows by condition and computes volatility
* for each, filtered to a rolling window.
*
* @param rows - Array of { condition, calculatedAt, marketPrice }
* @param windowMs - Rolling window in milliseconds (default: 30 days)
*/
export function volatilityByCondition(
rows: Array<{ condition: string; calculatedAt: string | Date | null; marketPrice: number | string | null }>,
windowMs = 30 * 86_400_000,
): Record<string, VolatilityResult> {
const cutoff = new Date(Date.now() - windowMs);
const grouped: Record<string, number[]> = {};
for (const row of rows) {
if (row.marketPrice == null || !row.calculatedAt) continue;
if (new Date(row.calculatedAt) < cutoff) continue;
const price = Number(row.marketPrice);
if (price <= 0) continue;
if (!grouped[row.condition]) grouped[row.condition] = [];
grouped[row.condition].push(price);
}
const result: Record<string, VolatilityResult> = {};
for (const [condition, prices] of Object.entries(grouped)) {
result[condition] = computeVolatility(prices);
}
return result;
}

View File

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