Compare commits
36 Commits
feat/inven
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c90bf21111 | ||
|
|
3957a86f9c | ||
|
|
4992890503 | ||
|
|
0858d3eaec | ||
|
|
b3799a5318 | ||
|
|
a265aea424 | ||
|
|
f0ee662450 | ||
|
|
b91b64f32c | ||
|
|
1e219b6c26 | ||
|
|
5abd6e4b92 | ||
|
|
4990c3aa04 | ||
|
|
c0055ed713 | ||
|
|
96af72c7f4 | ||
| afae3f445e | |||
| e7c71e1c75 | |||
| 9afc600e63 | |||
| 2cf47d2b15 | |||
| b0dbe7ced5 | |||
| ae0f3d6683 | |||
| 47f18348bf | |||
|
|
6517044821 | ||
|
|
1b5e77d55d | ||
|
|
d43ef99fea | ||
|
|
a566b82036 | ||
|
|
48b0098c6f | ||
| c582a40894 | |||
| 55af3a2e3c | |||
| 601cb7b770 | |||
|
|
85cfd1de64 | ||
|
|
c03a0b36a0 | ||
|
|
5cdf9b1772 | ||
|
|
17465b13c1 | ||
|
|
c61cafecdc | ||
|
|
2b3d5f322e | ||
|
|
53cdddb183 | ||
| 35c8bf25f5 |
2337
package-lock.json
generated
2337
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,18 +9,18 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.5.4",
|
||||
"@astrojs/node": "^10.1.4",
|
||||
"@clerk/astro": "^3.0.1",
|
||||
"@clerk/shared": "^4.0.0",
|
||||
"@clerk/themes": "^2.4.55",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"astro": "^5.17.1",
|
||||
"astro": "^6.4.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
"chalk": "^5.6.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"csv": "^6.4.1",
|
||||
"dotenv": "^17.2.4",
|
||||
"drizzle-orm": "^1.0.0-beta.15-859cf75",
|
||||
"drizzle-orm": "1.0.0-beta.15-859cf75",
|
||||
"pg": "^8.20.0",
|
||||
"sass": "^1.97.3",
|
||||
"typesense": "^3.0.1"
|
||||
@@ -29,7 +29,7 @@
|
||||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/node": "^25.2.1",
|
||||
"@types/pg": "^8.18.0",
|
||||
"drizzle-kit": "^1.0.0-beta.15-859cf75",
|
||||
"drizzle-kit": "1.0.0-beta.15-859cf75",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ async function findMissingImages() {
|
||||
.where(sql`${schema.tcgcards.sealed} = false`);
|
||||
const missingImages: string[] = [];
|
||||
for (const card of cards) {
|
||||
const imagePath = path.join(process.cwd(), 'public', 'cards', `${card.productId}.jpg`);
|
||||
const imagePath = path.join(process.cwd(), 'static', 'cards', `${card.productId}.jpg`);
|
||||
try {
|
||||
await fs.access(imagePath);
|
||||
} catch (err) {
|
||||
|
||||
@@ -12,6 +12,9 @@ const DollarToInt = (dollar: any) => {
|
||||
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) => {
|
||||
@@ -39,7 +42,7 @@ export const GetNumberOrNull = (value: any): number | null => {
|
||||
|
||||
|
||||
// Delete and recreate the 'cards' index
|
||||
export const createCardCollection = async () => {
|
||||
export const createCardCollection = async (log: Logger = defaultLogger) => {
|
||||
try {
|
||||
await client.collections('cards').delete();
|
||||
} catch (error) {
|
||||
@@ -56,9 +59,11 @@ export const createCardCollection = async () => {
|
||||
{ name: 'productLineName', type: 'string', facet: true },
|
||||
{ name: 'rarityName', type: 'string', facet: true },
|
||||
{ name: 'setName', type: 'string', facet: true },
|
||||
{ name: 'setCode', type: 'string' },
|
||||
{ name: 'cardType', type: 'string', facet: true },
|
||||
{ name: 'energyType', type: 'string', facet: true },
|
||||
{ name: 'number', type: 'string', sort: true },
|
||||
{ name: 'number', type: 'string' },
|
||||
{ name: 'inumber', type: 'int32', optional: true, sort: true },
|
||||
{ name: 'Artist', type: 'string' },
|
||||
{ name: 'sealed', type: 'bool' },
|
||||
{ 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 }
|
||||
],
|
||||
});
|
||||
console.log(chalk.green('Collection "cards" created successfully.'));
|
||||
log('Collection "cards" created successfully.');
|
||||
}
|
||||
|
||||
// Delete and recreate the 'skus' index
|
||||
export const createSkuCollection = async () => {
|
||||
export const createSkuCollection = async (log: Logger = defaultLogger) => {
|
||||
try {
|
||||
await client.collections('skus').delete();
|
||||
} catch (error) {
|
||||
@@ -88,11 +93,11 @@ export const createSkuCollection = async () => {
|
||||
{ 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
|
||||
export const createInventoryCollection = async () => {
|
||||
export const createInventoryCollection = async (log: Logger = defaultLogger) => {
|
||||
try {
|
||||
await client.collections('inventories').delete();
|
||||
} catch (error) {
|
||||
@@ -116,16 +121,24 @@ export const createInventoryCollection = async () => {
|
||||
{ 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({
|
||||
with: { set: true, tcgdata: true, prices: true },
|
||||
});
|
||||
await client.collections('cards').documents().import(pokemon.map(card => {
|
||||
const marketPrice = card.tcgdata?.marketPrice ? DollarToInt(card.tcgdata.marketPrice) : null;
|
||||
// Use the NM SKU price matching the card's variant (kept fresh by syncPrices)
|
||||
// Fall back to any NM sku, then to tcgdata price
|
||||
const nmSku = card.prices.find(p => p.condition === 'Near Mint' && p.variant === card.variant)
|
||||
?? card.prices.find(p => p.condition === 'Near Mint');
|
||||
const marketPrice = nmSku?.marketPrice
|
||||
? DollarToInt(nmSku.marketPrice)
|
||||
: card.tcgdata?.marketPrice
|
||||
? DollarToInt(card.tcgdata.marketPrice)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: card.cardId.toString(),
|
||||
@@ -136,21 +149,23 @@ export const upsertCardCollection = async (db:DBInstance) => {
|
||||
productLineName: card.productLineName,
|
||||
rarityName: card.rarityName,
|
||||
setName: card.set?.setName || "",
|
||||
setCode: card.set?.setCode || "",
|
||||
cardType: card.cardType || "",
|
||||
energyType: card.energyType || "",
|
||||
number: card.number,
|
||||
inumber: (card.number !== null) ? parseInt(card.number) : undefined,
|
||||
Artist: card.artist || "",
|
||||
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,
|
||||
...(marketPrice !== null && { marketPrice }),
|
||||
// sku_id: card.prices.map(price => price.skuId.toString())
|
||||
};
|
||||
}), { 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();
|
||||
await client.collections('skus').documents().import(skus.map(sku => ({
|
||||
id: sku.skuId.toString(),
|
||||
@@ -160,10 +175,10 @@ export const upsertSkuCollection = async (db:DBInstance) => {
|
||||
marketPrice: DollarToInt(sku.marketPrice),
|
||||
card_id: sku.cardId.toString(),
|
||||
})), { 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({
|
||||
with: { sku: { with: { card: { with: { set: true } } } } }
|
||||
});
|
||||
@@ -188,25 +203,27 @@ export const upsertInventoryCollection = async (db:DBInstance) => {
|
||||
i.sku?.card?.artist || ""
|
||||
].join(' '),
|
||||
})), { 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
|
||||
set
|
||||
product_name = a.product_name, product_line_name = a.product_line_name, product_url_name = a.product_url_name, rarity_name = a.rarity_name,
|
||||
sealed = a.sealed, set_id = a.set_id, card_type = a.card_type, energy_type = a.energy_type, number = a.number, artist = a.artist
|
||||
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 (
|
||||
select t.product_id, b.variant,
|
||||
coalesce(o.product_name, regexp_replace(regexp_replace(coalesce(nullif(t.product_name, ''), t.product_url_name),' \\\\(.*\\\\)',''),' - .*$','')) as product_name,
|
||||
coalesce(o.product_line_name, t.product_line_name) as product_line_name, coalesce(o.product_url_name, t.product_url_name) as product_url_name,
|
||||
coalesce(o.rarity_name, t.rarity_name) as rarity_name, coalesce(o.sealed, t.sealed) as sealed, coalesce(o.set_id, t.set_id) as set_id,
|
||||
coalesce(o.card_type, t.card_type) as card_type, coalesce(o.energy_type, t.energy_type) as energy_type,
|
||||
coalesce(o.number, t.number) as number, coalesce(o.artist, t.artist) as artist
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
)
|
||||
`);
|
||||
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,
|
||||
coalesce(o.product_name, regexp_replace(regexp_replace(coalesce(nullif(t.product_name, ''), t.product_url_name),' \\\\(.*\\\\)',''),' - .*$','')) as product_name,
|
||||
coalesce(o.product_line_name, t.product_line_name) as product_line_name, coalesce(o.product_url_name, t.product_url_name) as product_url_name, coalesce(o.rarity_name, t.rarity_name) as rarity_name,
|
||||
coalesce(o.sealed, t.sealed) as sealed, coalesce(o.set_id, t.set_id) as set_id, coalesce(o.card_type, t.card_type) as card_type,
|
||||
coalesce(o.energy_type, t.energy_type) as energy_type, coalesce(o.number, t.number) as number, coalesce(o.artist, t.artist) as artist
|
||||
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
|
||||
join (select distinct product_id, variant from skus) b on t.product_id = b.product_id
|
||||
left join tcg_overrides o on t.product_id = o.product_id
|
||||
where not exists (select 1 from cards where product_id=t.product_id and variant=b.variant)
|
||||
`);
|
||||
console.log(`Inserted ${inserts.rowCount} rows into cards table`);
|
||||
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`);
|
||||
console.log(`Updated ${skuUpdates.rowCount} rows in skus table`);
|
||||
log(`Updated ${skuUpdates.rowCount} rows in skus table`);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,106 +1,86 @@
|
||||
import 'dotenv/config';
|
||||
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 path from "node:path";
|
||||
import chalk from 'chalk';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
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 productLines = [ "pokemon", "pokemon-japan" ];
|
||||
|
||||
// work from the available sets within the product line
|
||||
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) {
|
||||
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) {
|
||||
const syncProductLine = async (
|
||||
database: DBInstance,
|
||||
productLine: string,
|
||||
field: string,
|
||||
fieldValue: string,
|
||||
allProductIds: Set<number>,
|
||||
log: Logger,
|
||||
) => {
|
||||
let start = 0;
|
||||
let size = 50;
|
||||
const size = 50;
|
||||
let total = 1000000;
|
||||
|
||||
while (start < total) {
|
||||
console.log(` Fetching items ${start} to ${start + size} of ${total}...`);
|
||||
log(` Fetching items ${start} to ${start + size} of ${total}...`);
|
||||
|
||||
const d = {
|
||||
"algorithm":"sales_dismax",
|
||||
"from":start,
|
||||
"size":size,
|
||||
"filters":{
|
||||
"term":{"productLineName":[productLine], [field]:[fieldValue]} ,
|
||||
"range":{},
|
||||
"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',
|
||||
"algorithm": "sales_dismax",
|
||||
"from": start,
|
||||
"size": size,
|
||||
"filters": {
|
||||
"term": { "productLineName": [productLine], [field]: [fieldValue] },
|
||||
"range": {},
|
||||
"match": {},
|
||||
},
|
||||
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) {
|
||||
console.error('Error notifying sync completion:', response.statusText);
|
||||
process.exit(1);
|
||||
let response: Response | null = null;
|
||||
let lastError: unknown = null;
|
||||
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();
|
||||
@@ -108,28 +88,43 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
|
||||
for (const item of data.results[0].results) {
|
||||
|
||||
// Check if productId already exists and skip if it does (to avoid hitting the API too much)
|
||||
if (allProductIds.size > 0 && allProductIds.has(item.productId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(chalk.blue(` - ${item.productName} (ID: ${item.productId})`));
|
||||
log(` - ${item.productName} (ID: ${item.productId})`);
|
||||
|
||||
// Get product detail
|
||||
const detailResponse = await fetch(`https://mp-search-api.tcgplayer.com/v2/product/${item.productId}/details`, {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!detailResponse.ok) {
|
||||
console.error('Error fetching product details:', detailResponse.statusText);
|
||||
process.exit(1);
|
||||
let detailResponse: Response | null = null;
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
const r = await fetch(`https://mp-search-api.tcgplayer.com/v2/product/${item.productId}/details`, {
|
||||
method: 'GET',
|
||||
});
|
||||
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();
|
||||
|
||||
|
||||
await db.insert(schema.tcgcards).values({
|
||||
await database.insert(schema.tcgcards).values({
|
||||
productId: item.productId,
|
||||
productName: detailData.productName,
|
||||
//productName: cleanProductName(item.productName),
|
||||
rarityName: item.rarityName,
|
||||
productLineName: detailData.productLineName,
|
||||
productLineUrlName: detailData.productLineUrlName,
|
||||
@@ -167,7 +162,6 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
target: schema.tcgcards.productId,
|
||||
set: {
|
||||
productName: detailData.productName,
|
||||
//productName: cleanProductName(item.productName),
|
||||
rarityName: item.rarityName,
|
||||
productLineName: detailData.productLineName,
|
||||
productLineUrlName: detailData.productLineUrlName,
|
||||
@@ -204,11 +198,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
},
|
||||
});
|
||||
|
||||
// console.log(`item: ${item.setId}\tdetail: ${detailData.setId}`);
|
||||
// console.log(`item: ${item.setCode}\tdetail: ${detailData.setCode}`);
|
||||
// console.log(`item: ${item.setName}\tdetail: ${detailData.setName}`);
|
||||
// set is...
|
||||
await db.insert(schema.sets).values({
|
||||
await database.insert(schema.sets).values({
|
||||
setId: detailData.setId,
|
||||
setCode: detailData.setCode,
|
||||
setName: detailData.setName,
|
||||
@@ -222,10 +212,8 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
},
|
||||
});
|
||||
|
||||
// skus are...
|
||||
for (const skuItem of detailData.skus) {
|
||||
|
||||
await db.insert(schema.skus).values({
|
||||
await database.insert(schema.skus).values({
|
||||
skuId: skuItem.sku,
|
||||
productId: detailData.productId,
|
||||
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`);
|
||||
if (!await helper.FileExists(imagePath)) {
|
||||
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();
|
||||
await fs.writeFile(imagePath, Buffer.from(buffer));
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
|
||||
// be nice to the API and not send too many requests in a short time
|
||||
await helper.Sleep(300);
|
||||
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -154,6 +154,7 @@ const updateLatestSales = async (updatedCards: Set<number>) => {
|
||||
const start = Date.now();
|
||||
const updatedCards = await syncPrices();
|
||||
await helper.upsertSkuCollection(db);
|
||||
await helper.upsertCardCollection(db);
|
||||
//console.log(updatedCards);
|
||||
//console.log(updatedCards.size);
|
||||
//await updateLatestSales(updatedCards);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
@import 'bootstrap/scss/containers';
|
||||
@import 'bootstrap/scss/images';
|
||||
@import 'bootstrap/scss/nav';
|
||||
// @import 'bootstrap/scss/accordion';
|
||||
@import 'bootstrap/scss/accordion';
|
||||
@import 'bootstrap/scss/alert';
|
||||
@import 'bootstrap/scss/badge';
|
||||
// @import 'bootstrap/scss/breadcrumb';
|
||||
|
||||
@@ -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-link.active,
|
||||
.nav-item.show .nav-link {
|
||||
@@ -882,6 +886,16 @@ footer .logo-svg > svg { width: var(--logo-width, 8rem); }
|
||||
-------------------------------------------------- */
|
||||
#gridView { row-gap: 1.5rem; }
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.catalog-sidebar {
|
||||
position: sticky;
|
||||
top: var(--navbar-height, 4rem);
|
||||
height: calc(100vh - var(--navbar-height, 4rem));
|
||||
overflow-y: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.inv-grid-card {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
@@ -49,6 +49,13 @@ import BackToTop from "./BackToTop.astro"
|
||||
<script is:inline>
|
||||
(function () {
|
||||
|
||||
// ── Tooltip initializer ───────────────────────────────────────────────────
|
||||
function initTooltips(root = document) {
|
||||
root.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||
bootstrap.Tooltip.getOrCreateInstance(el, { container: 'body' });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Price mode helpers ────────────────────────────────────────────────────
|
||||
// marketPriceByCondition is injected into the modal HTML via a data attribute
|
||||
// on #inventoryEntryList: data-market-prices='{"Near Mint":6.00,...}'
|
||||
@@ -246,6 +253,9 @@ import BackToTop from "./BackToTop.astro"
|
||||
|
||||
// ── Global helpers ────────────────────────────────────────────────────────
|
||||
window.copyImage = async function(img) {
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
@@ -260,10 +270,17 @@ import BackToTop from "./BackToTop.astro"
|
||||
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) {
|
||||
const blob = await new Promise((resolve, reject) => {
|
||||
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
|
||||
});
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||
showCopyToast('📋 Image copied!', '#198754');
|
||||
} else {
|
||||
@@ -282,6 +299,7 @@ import BackToTop from "./BackToTop.astro"
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error('Failed:', err);
|
||||
showCopyToast('❌ Copy failed', '#dc3545');
|
||||
}
|
||||
@@ -414,6 +432,7 @@ import BackToTop from "./BackToTop.astro"
|
||||
|
||||
if (typeof htmx !== 'undefined') htmx.process(modal);
|
||||
initInventoryForms(modal);
|
||||
initTooltips(modal);
|
||||
updateNavButtons(modal);
|
||||
initChartAfterSwap(modal);
|
||||
switchToRequestedTab();
|
||||
@@ -509,6 +528,7 @@ import BackToTop from "./BackToTop.astro"
|
||||
|
||||
if (typeof htmx !== 'undefined') htmx.process(target);
|
||||
initInventoryForms(target);
|
||||
initTooltips(target);
|
||||
|
||||
const destImg = target.querySelector('img.card-image');
|
||||
if (destImg) {
|
||||
@@ -638,6 +658,7 @@ import BackToTop from "./BackToTop.astro"
|
||||
updateNavButtons(cardModal);
|
||||
initChartAfterSwap(cardModal);
|
||||
initInventoryForms(cardModal);
|
||||
initTooltips(cardModal);
|
||||
switchToRequestedTab();
|
||||
});
|
||||
|
||||
@@ -648,6 +669,7 @@ import BackToTop from "./BackToTop.astro"
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initInventoryForms();
|
||||
initTooltips();
|
||||
|
||||
const pending = sessionStorage.getItem('pendingSearch');
|
||||
if (pending) {
|
||||
|
||||
7
src/components/LeftSidebarDesktop.astro
Normal file
7
src/components/LeftSidebarDesktop.astro
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="d-none d-xl-block sticky-top mt-5" style="top: 70px;">
|
||||
<ins class="adsbygoogle"
|
||||
style="display:block"
|
||||
data-ad-format="autorelaxed"
|
||||
data-ad-client="ca-pub-1140571217687341"
|
||||
data-ad-slot="8889263515"></ins>
|
||||
</div>
|
||||
@@ -1,4 +1,8 @@
|
||||
---
|
||||
// auth check for inventory management features
|
||||
const { canAddInventory } = Astro.locals;
|
||||
const { isAdmin } = Astro.locals;
|
||||
// const canAddInventory = false;
|
||||
---
|
||||
<button
|
||||
class="navbar-toggler ms-4 p-1 btn btn-purple border-0"
|
||||
@@ -30,9 +34,16 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-3 border-bottom border-secondary" href="/pokemon">Browse Cards</a>
|
||||
</li>
|
||||
<!--<li class="nav-item">
|
||||
<a class="nav-link py-3" href="/dashboard">Dashboard</a>
|
||||
</li> -->
|
||||
{canAddInventory && (
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-3 border-bottom border-secondary" href="/dashboard">Dashboard</a>
|
||||
</li>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-3" href="/admin">Admin Panel</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,47 +25,77 @@ import { Show } from '@clerk/astro/components'
|
||||
</script>
|
||||
|
||||
<Show when="signed-in">
|
||||
<form
|
||||
class="d-flex align-items-center"
|
||||
role="search"
|
||||
id="searchform"
|
||||
hx-post="/partials/cards"
|
||||
hx-target="#cardGrid"
|
||||
hx-trigger="load, submit"
|
||||
hx-vals='{"start":"0"}'
|
||||
hx-on--after-request="afterUpdate()"
|
||||
hx-on--before-request="beforeSearch()"
|
||||
>
|
||||
{Astro.url.pathname === '/dashboard' ? (
|
||||
<div class="input-group">
|
||||
{Astro.url.pathname === '/pokemon' && (
|
||||
<a class="btn btn-purple" data-bs-toggle="offcanvas" href="#filterBar" type="button" role="button" aria-controls="filterBar" aria-label="filter">
|
||||
<span class="d-block d-md-none filter-icon py-2">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M528.8 96.3C558.6 90.8 571.2 118.9 568.9 142.2C572.3 173.4 570.8 207 553.9 230.8C513.9 283.2 459.3 315.9 414.3 364.3C414.9 418.3 419.8 459.8 423.6 511.2C427.6 552.4 388.7 586.8 346.6 570.1C303.2 550.5 259.4 527.5 230.4 493.3C217 453.1 225.9 407.5 222.2 365.3C222.2 365.3 222.1 365.1 222 365C151.4 319.6 59.3 250.9 61 158.4C59.9 121 91.8 96.1 123.8 96.5C259.3 98.5 394.1 104.4 528.8 96.3zM506.1 161.4C378.3 168.2 252 162.1 125.2 160.5C128.6 227 199 270.8 250 306.8C305.5 335.4 281.6 410.5 288.3 461.7C310.8 478.9 334.6 494.6 358.9 505.8C355.4 458 350.7 415.4 350.2 364.6C349.9 349.2 355.3 333.7 366.5 321.7C384.3 302.6 402.8 287.8 421.5 270.1C446.1 245.2 477.9 225.1 499.7 196.7C509 182.2 504.7 174.5 506 161.5z"/></svg>
|
||||
</span>
|
||||
<span class="d-none d-md-block fw-medium">Filters</span>
|
||||
</a>
|
||||
)}
|
||||
<input type="hidden" name="start" id="start" value="0" />
|
||||
<input type="hidden" name="sort" id="sortInput" value="" />
|
||||
<input type="hidden" name="language" id="languageInput" value="all" />
|
||||
<input type="search" name="q" id="searchInput" class="form-control search-input" placeholder="Search cards" />
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
id="inventorySearchInput"
|
||||
class="form-control search-input"
|
||||
placeholder="Search your inventory"
|
||||
hx-post="/partials/inventory-cards"
|
||||
hx-trigger="keyup[key=='Enter']"
|
||||
hx-target="#gridView"
|
||||
hx-swap="innerHTML"
|
||||
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
type="button"
|
||||
id="inventorySearchBtn"
|
||||
class="btn btn-purple border-start-0"
|
||||
aria-label="search"
|
||||
onclick="
|
||||
const q = this.closest('form').querySelector('[name=q]').value;
|
||||
dataLayer.push({ event: 'view_search_results', search_term: q });
|
||||
if (window.location.pathname !== '/pokemon') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
sessionStorage.setItem('pendingSearch', q);
|
||||
window.location.href = '/pokemon';
|
||||
}
|
||||
"
|
||||
hx-post="/partials/inventory-cards"
|
||||
hx-include="#inventorySearchInput"
|
||||
hx-target="#gridView"
|
||||
hx-swap="innerHTML"
|
||||
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
|
||||
>
|
||||
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
class="d-flex align-items-center"
|
||||
role="search"
|
||||
id="searchform"
|
||||
hx-post="/partials/cards"
|
||||
hx-target="#cardGrid"
|
||||
hx-trigger="load, submit"
|
||||
hx-vals='{"start":"0"}'
|
||||
hx-on--after-request="afterUpdate()"
|
||||
hx-on--before-request="beforeSearch()"
|
||||
>
|
||||
<div class="input-group">
|
||||
{Astro.url.pathname === '/pokemon' && (
|
||||
<a class="btn btn-purple" data-bs-toggle="offcanvas" href="#filterBar" type="button" role="button" aria-controls="filterBar" aria-label="filter">
|
||||
<span class="d-block d-md-none filter-icon py-2">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M528.8 96.3C558.6 90.8 571.2 118.9 568.9 142.2C572.3 173.4 570.8 207 553.9 230.8C513.9 283.2 459.3 315.9 414.3 364.3C414.9 418.3 419.8 459.8 423.6 511.2C427.6 552.4 388.7 586.8 346.6 570.1C303.2 550.5 259.4 527.5 230.4 493.3C217 453.1 225.9 407.5 222.2 365.3C222.2 365.3 222.1 365.1 222 365C151.4 319.6 59.3 250.9 61 158.4C59.9 121 91.8 96.1 123.8 96.5C259.3 98.5 394.1 104.4 528.8 96.3zM506.1 161.4C378.3 168.2 252 162.1 125.2 160.5C128.6 227 199 270.8 250 306.8C305.5 335.4 281.6 410.5 288.3 461.7C310.8 478.9 334.6 494.6 358.9 505.8C355.4 458 350.7 415.4 350.2 364.6C349.9 349.2 355.3 333.7 366.5 321.7C384.3 302.6 402.8 287.8 421.5 270.1C446.1 245.2 477.9 225.1 499.7 196.7C509 182.2 504.7 174.5 506 161.5z"/></svg>
|
||||
</span>
|
||||
<span class="d-none d-md-block fw-medium">Filters</span>
|
||||
</a>
|
||||
)}
|
||||
<input type="hidden" name="start" id="start" value="0" />
|
||||
<input type="hidden" name="sort" id="sortInput" value="" />
|
||||
<input type="hidden" name="language" id="languageInput" value="all" />
|
||||
<input type="search" name="q" id="searchInput" class="form-control search-input" placeholder="Search cards" />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-purple border-start-0"
|
||||
aria-label="search"
|
||||
onclick="
|
||||
const q = this.closest('form').querySelector('[name=q]').value;
|
||||
dataLayer.push({ event: 'view_search_results', search_term: q });
|
||||
if (window.location.pathname !== '/pokemon') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
sessionStorage.setItem('pendingSearch', q);
|
||||
window.location.href = '/pokemon';
|
||||
}
|
||||
"
|
||||
>
|
||||
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Show>
|
||||
@@ -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 team_rocket from "/src/svg/set/team_rocket.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;
|
||||
|
||||
@@ -254,6 +255,7 @@ const setMap = {
|
||||
"DRI": destined_rivals,
|
||||
"SSP": surging_sparks,
|
||||
"ME03": perfect_order,
|
||||
"CRI": chaos_rising,
|
||||
};
|
||||
|
||||
const svg = setMap[set as keyof typeof setMap] ?? "";
|
||||
|
||||
@@ -56,6 +56,7 @@ export const cards = pokeSchema.table('cards', {
|
||||
cardType: varchar({ length: 100 }),
|
||||
energyType: varchar({ length: 100 }),
|
||||
number: varchar({ length: 50 }),
|
||||
inumber: integer(),
|
||||
artist: varchar({ length: 255 }),
|
||||
},
|
||||
(table) => [
|
||||
@@ -136,7 +137,8 @@ export const inventory = pokeSchema.table('inventory',{
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
(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', {
|
||||
|
||||
@@ -19,6 +19,7 @@ const { title } = Astro.props;
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="htmx-config" content='{"historyCacheSize": 50}'/>
|
||||
<meta name="google-adsense-account" content="ca-pub-1140571217687341">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<title>{title}</title>
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { clerkMiddleware, createRouteMatcher, clerkClient } from '@clerk/astro/server';
|
||||
import type { MiddlewareNext } from 'astro';
|
||||
import 'dotenv/config';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
canAddInventory: boolean;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isProtectedRoute = createRouteMatcher(['/pokemon']);
|
||||
const isAdminRoute = createRouteMatcher(['/admin']);
|
||||
const isAdminRoute = createRouteMatcher(['/admin', '/api/reindex', '/api/preload-tcgplayer']);
|
||||
|
||||
const TARGET_ORG_ID = "org_3Baav9czkRLLlC7g89oJWqRRulK";
|
||||
const ADMIN_ORG_IDS = new Set([
|
||||
"org_3Baav9czkRLLlC7g89oJWqRRulK",
|
||||
"org_3ABdwuK3qD7Saq590ZMQWY7AvVz",
|
||||
]);
|
||||
|
||||
export const onRequest = clerkMiddleware(async (auth, context, next) => {
|
||||
const { isAuthenticated, userId, redirectToSignIn, has } = auth();
|
||||
@@ -23,42 +27,31 @@ export const onRequest = clerkMiddleware(async (auth, context, next) => {
|
||||
}
|
||||
|
||||
// ── Inventory visibility check ──────────────────────────────────────────────
|
||||
// Resolves to true if the user belongs to the target org OR has the feature
|
||||
const canAddInventory = process.env.INVENTORY_ACCESS === 'true' ||
|
||||
(
|
||||
isAuthenticated &&
|
||||
userId &&
|
||||
(
|
||||
!!has({ permission: "org:feature:inventory_add" }) || // Clerk feature flag
|
||||
!!has({ permission: "org:feature:inventory_add" }) ||
|
||||
(await getUserOrgIds(context, userId)).includes(TARGET_ORG_ID)
|
||||
)
|
||||
);
|
||||
|
||||
// Expose the flag to your Astro pages via locals
|
||||
context.locals.canAddInventory = Boolean(canAddInventory);
|
||||
|
||||
// ── Admin route guard (unchanged) ───────────────────────────────────────────
|
||||
// ── Admin check ──────────────────────────────────────────────────────────
|
||||
// Computed on every request (not just /admin routes) so pages can use
|
||||
// Astro.locals.isAdmin to conditionally show admin-only UI anywhere.
|
||||
context.locals.isAdmin = isAuthenticated && userId
|
||||
? await checkIsAdmin(context, userId)
|
||||
: false;
|
||||
|
||||
// ── Admin route guard ───────────────────────────────────────────
|
||||
if (isAdminRoute(context.request)) {
|
||||
if (!isAuthenticated || !userId) {
|
||||
return redirectToSignIn();
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await clerkClient(context);
|
||||
const memberships = await client.organizations.getOrganizationMembershipList({
|
||||
organizationId: TARGET_ORG_ID,
|
||||
});
|
||||
|
||||
const userMembership = memberships.data.find(
|
||||
(m) => m.publicUserData?.userId === userId
|
||||
);
|
||||
|
||||
if (!userMembership || userMembership.role !== "org:admin") {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Clerk membership check failed:", e);
|
||||
return context.redirect("/");
|
||||
if (!context.locals.isAdmin) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,3 +69,28 @@ async function getUserOrgIds(context: any, userId: string): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper: is this user an org:admin in any of ADMIN_ORG_IDS? ─────────────
|
||||
async function checkIsAdmin(context: any, userId: string): Promise<boolean> {
|
||||
try {
|
||||
const userOrgIds = await getUserOrgIds(context, userId);
|
||||
const matchingOrgIds = userOrgIds.filter((id) => ADMIN_ORG_IDS.has(id));
|
||||
if (matchingOrgIds.length === 0) return false;
|
||||
|
||||
const client = await clerkClient(context);
|
||||
const membershipLists = await Promise.all(
|
||||
matchingOrgIds.map((orgId) =>
|
||||
client.organizations.getOrganizationMembershipList({ organizationId: orgId })
|
||||
)
|
||||
);
|
||||
|
||||
return membershipLists.some((list) =>
|
||||
list.data.some(
|
||||
(m) => m.publicUserData?.userId === userId && m.role === "org:admin"
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Clerk membership check failed:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
225
src/pages/admin.astro
Normal file
225
src/pages/admin.astro
Normal 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>
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { parse } from 'csv';
|
||||
import { db } from '../../db/index';
|
||||
import { inventory, priceHistory } from '../../db/schema';
|
||||
import { client } from '../../db/typesense';
|
||||
@@ -89,16 +90,42 @@ const getInventory = async (userId: string, cardId: number) => {
|
||||
}
|
||||
|
||||
|
||||
// Build the Typesense document for an inventory row + its card details.
|
||||
const inventoryTsDoc = (i: typeof inventory.$inferSelect, card: any) => ({
|
||||
id: i.inventoryId,
|
||||
userId: i.userId,
|
||||
catalogName: i.catalogName,
|
||||
sku_id: i.skuId.toString(),
|
||||
purchasePrice: DollarToInt(i.purchasePrice),
|
||||
productLineName: card?.productLineName,
|
||||
rarityName: card?.rarityName,
|
||||
setName: card?.set?.setName || "",
|
||||
cardType: card?.cardType || "",
|
||||
energyType: card?.energyType || "",
|
||||
card_id: card?.cardId.toString() || "",
|
||||
content: [
|
||||
card?.productName,
|
||||
card?.productLineName,
|
||||
card?.set?.setName || "",
|
||||
card?.number,
|
||||
card?.rarityName,
|
||||
card?.artist || ""
|
||||
].join(' '),
|
||||
});
|
||||
|
||||
const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
|
||||
// First add to database
|
||||
const inv = await db.insert(inventory).values({
|
||||
userId: userId,
|
||||
skuId: skuId,
|
||||
catalogName: catalogName,
|
||||
purchasePrice: purchasePrice.toFixed(2),
|
||||
quantity: quantity,
|
||||
note: note,
|
||||
}).returning();
|
||||
// Insert one row per copy: a quantity of 5 becomes 5 separate entries of qty 1.
|
||||
const count = Math.max(1, Math.floor(quantity));
|
||||
const inv = await db.insert(inventory).values(
|
||||
Array.from({ length: count }, () => ({
|
||||
userId: userId,
|
||||
skuId: skuId,
|
||||
catalogName: catalogName,
|
||||
purchasePrice: purchasePrice.toFixed(2),
|
||||
quantity: 1,
|
||||
note: note,
|
||||
}))
|
||||
).returning();
|
||||
// Get card details from the database to add to Typesense
|
||||
const card = await db.query.cards.findFirst({
|
||||
where: { cardId: cardId },
|
||||
@@ -107,27 +134,7 @@ const addToInventory = async (userId: string, cardId: number, skuId: number, pur
|
||||
|
||||
try {
|
||||
// And then add to Typesense for searching
|
||||
await client.collections('inventories').documents().import(inv.map(i => ({
|
||||
id: i.inventoryId,
|
||||
userId: i.userId,
|
||||
catalogName: i.catalogName,
|
||||
sku_id: i.skuId.toString(),
|
||||
purchasePrice: DollarToInt(i.purchasePrice),
|
||||
productLineName: card?.productLineName,
|
||||
rarityName: card?.rarityName,
|
||||
setName: card?.set?.setName || "",
|
||||
cardType: card?.cardType || "",
|
||||
energyType: card?.energyType || "",
|
||||
card_id: card?.cardId.toString() || "",
|
||||
content: [
|
||||
card?.productName,
|
||||
card?.productLineName,
|
||||
card?.set?.setName || "",
|
||||
card?.number,
|
||||
card?.rarityName,
|
||||
card?.artist || ""
|
||||
].join(' '),
|
||||
})));
|
||||
await client.collections('inventories').documents().import(inv.map(i => inventoryTsDoc(i, card)));
|
||||
} catch (error) {
|
||||
console.error('Error adding inventory to Typesense:', error);
|
||||
}
|
||||
@@ -138,16 +145,139 @@ const removeFromInventory = async (inventoryId: string) => {
|
||||
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
|
||||
await db.update(inventory).set({
|
||||
quantity: quantity,
|
||||
purchasePrice: purchasePrice.toFixed(2),
|
||||
note: note,
|
||||
catalogName: catalogName,
|
||||
}).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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bulk CSV import ─────────────────────────────────────────────────────────
|
||||
// Expected columns (header row, case-insensitive): name, set, condition, qty,
|
||||
// price, market. An optional `date` column is used as the inventory createdAt;
|
||||
// rows without a (valid) date default to the current time.
|
||||
const CONDITION_MAP: Record<string, string> = {
|
||||
'nm': 'Near Mint', 'near mint': 'Near Mint', 'mint': 'Near Mint',
|
||||
'lp': 'Lightly Played', 'lightly played': 'Lightly Played',
|
||||
'mp': 'Moderately Played', 'moderately played': 'Moderately Played',
|
||||
'hp': 'Heavily Played', 'heavily played': 'Heavily Played',
|
||||
'dmg': 'Damaged', 'dm': 'Damaged', 'damaged': 'Damaged',
|
||||
};
|
||||
|
||||
const normalizeCondition = (value?: string) => {
|
||||
const key = (value || '').trim().toLowerCase();
|
||||
return CONDITION_MAP[key] || 'Near Mint';
|
||||
};
|
||||
|
||||
// Parse an optional date cell into a Date, or null when empty/invalid.
|
||||
const parseImportDate = (value?: string): Date | null => {
|
||||
const raw = (value || '').trim();
|
||||
if (!raw) return null;
|
||||
const date = new Date(raw);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
};
|
||||
|
||||
const parseCsv = (text: string): Promise<Record<string, string>[]> =>
|
||||
new Promise((resolve, reject) => {
|
||||
parse(text, { columns: true, trim: true, skip_empty_lines: true, bom: true },
|
||||
(err, records) => (err ? reject(err) : resolve(records)));
|
||||
});
|
||||
|
||||
// Case-insensitive column lookup for a parsed CSV row.
|
||||
const lowerKeys = (row: Record<string, string>) => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(row)) out[k.trim().toLowerCase()] = v;
|
||||
return out;
|
||||
};
|
||||
|
||||
const findCardId = async (name: string, set: string): Promise<number | undefined> => {
|
||||
const escapedSet = set.replace(/`/g, '');
|
||||
let result = await client.collections('cards').documents().search({
|
||||
q: name, query_by: 'productName', per_page: 1,
|
||||
...(escapedSet ? { filter_by: `setName:\`${escapedSet}\`` } : {}),
|
||||
});
|
||||
// Fall back to a name-only match if the set filter found nothing.
|
||||
if (!result.hits?.length && escapedSet) {
|
||||
result = await client.collections('cards').documents().search({
|
||||
q: name, query_by: 'productName', per_page: 1,
|
||||
});
|
||||
}
|
||||
return result.hits?.[0]?.document?.cardId as number | undefined;
|
||||
};
|
||||
|
||||
const importInventory = async (userId: string, rows: Record<string, string>[], catalogName: string) => {
|
||||
const skipped: { row: number; name: string; reason: string }[] = [];
|
||||
const tsDocs: ReturnType<typeof inventoryTsDoc>[] = [];
|
||||
let imported = 0;
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const rowNumber = i + 2; // account for header row (1-indexed for humans)
|
||||
const row = lowerKeys(rows[i]);
|
||||
const name = (row.name || '').trim();
|
||||
const set = (row.set || '').trim();
|
||||
|
||||
if (!name) { skipped.push({ row: rowNumber, name, reason: 'Missing card name' }); continue; }
|
||||
|
||||
const createdAt = parseImportDate(row.date);
|
||||
if ((row.date || '').trim() && !createdAt) {
|
||||
skipped.push({ row: rowNumber, name, reason: `Invalid date "${row.date.trim()}"` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const condition = normalizeCondition(row.condition);
|
||||
const count = Math.max(1, parseInt(row.qty, 10) || 1);
|
||||
const purchasePrice = parseFloat(String(row.price ?? '').replace(/[^0-9.]/g, '')) || 0;
|
||||
|
||||
const cardId = await findCardId(name, set);
|
||||
if (!cardId) { skipped.push({ row: rowNumber, name, reason: 'Card not found' }); continue; }
|
||||
|
||||
const sku = await db.query.skus.findFirst({
|
||||
where: { cardId: cardId, condition: condition },
|
||||
columns: { skuId: true },
|
||||
});
|
||||
if (!sku?.skuId) { skipped.push({ row: rowNumber, name, reason: `No "${condition}" SKU for card` }); continue; }
|
||||
|
||||
// One row per copy: a qty of 5 becomes 5 separate entries of qty 1.
|
||||
const inserted = await db.insert(inventory).values(
|
||||
Array.from({ length: count }, () => ({
|
||||
userId,
|
||||
skuId: sku.skuId,
|
||||
catalogName,
|
||||
purchasePrice: purchasePrice.toFixed(2),
|
||||
quantity: 1,
|
||||
note: '',
|
||||
...(createdAt ? { createdAt } : {}),
|
||||
}))
|
||||
).returning();
|
||||
|
||||
const card = await db.query.cards.findFirst({
|
||||
where: { cardId: cardId },
|
||||
with: { set: true },
|
||||
});
|
||||
for (const entry of inserted) tsDocs.push(inventoryTsDoc(entry, card));
|
||||
imported += inserted.length;
|
||||
}
|
||||
|
||||
if (tsDocs.length) {
|
||||
try {
|
||||
await client.collections('inventories').documents().import(tsDocs);
|
||||
} catch (error) {
|
||||
console.error('Error importing inventory to Typesense:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped };
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
// Access form data from the request body
|
||||
const formData = await request.formData();
|
||||
@@ -173,6 +303,33 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
await addToInventory(userId!, cardId, skuId, purchasePrice, quantity, note, catalogName);
|
||||
break;
|
||||
|
||||
case 'import': {
|
||||
if (!userId) {
|
||||
return new Response(JSON.stringify({ error: 'Not authenticated' }), {
|
||||
status: 401, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const file = formData.get('file') as File | null;
|
||||
if (!file) {
|
||||
return new Response(JSON.stringify({ error: 'No file uploaded' }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const importCatalog = formData.get('catalogName')?.toString()?.trim() || 'Default';
|
||||
try {
|
||||
const rows = await parseCsv(await file.text());
|
||||
const summary = await importInventory(userId, rows, importCatalog);
|
||||
return new Response(JSON.stringify(summary), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error importing inventory CSV:', error);
|
||||
return new Response(JSON.stringify({ error: 'Could not parse CSV file' }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
case 'remove':
|
||||
const inventoryId = formData.get('inventoryId')?.toString() || '';
|
||||
await removeFromInventory(inventoryId);
|
||||
@@ -183,7 +340,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const qty = Number(formData.get('quantity')) || 1;
|
||||
const price = Number(formData.get('purchasePrice')) || 0;
|
||||
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;
|
||||
|
||||
default:
|
||||
|
||||
43
src/pages/api/preload-tcgplayer.ts
Normal file
43
src/pages/api/preload-tcgplayer.ts
Normal 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
71
src/pages/api/reindex.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,8 +1,6 @@
|
||||
---
|
||||
import Layout from "../layouts/Main.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
import BackToTop from "../components/BackToTop.astro";
|
||||
import FirstEditionIcon from "../components/FirstEditionIcon.astro";
|
||||
export const prerender = false;
|
||||
import Layout from '../layouts/Main.astro';
|
||||
import { db } from '../db/index';
|
||||
import { inventory, skus } from '../db/schema';
|
||||
import { sql, sum, eq } from "drizzle-orm";
|
||||
@@ -26,207 +24,109 @@ const totalQty = summary.totalQty || 0;
|
||||
const totalValue = summary.totalValue || 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">
|
||||
<div class="container-fluid container-sm mt-3" slot="page">
|
||||
<BackToTop />
|
||||
<div class="container-fluid container-sm" slot="page">
|
||||
<div class="row mb-4">
|
||||
<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">
|
||||
<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>
|
||||
<aside
|
||||
class="offcanvas-xl offcanvas-start col-12 col-xl-2 border-end border-secondary bg-dark p-3 d-flex flex-column gap-3 catalog-sidebar"
|
||||
tabindex="-1"
|
||||
id="catalogSidebar"
|
||||
aria-labelledby="catalogSidebarLabel"
|
||||
>
|
||||
<div class="offcanvas-header d-xl-none">
|
||||
<h6 class="offcanvas-title text-uppercase text-secondary fw-bold ls-wide" id="catalogSidebarLabel" style="letter-spacing:.08em">Catalogs</h6>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" data-bs-target="#catalogSidebar" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<ul id="catalogList" class="list-group list-group-flush">
|
||||
<li
|
||||
class="list-group-item list-group-item-action fw-semibold border-0 rounded p-2 d-flex align-items-center active"
|
||||
data-catalog="all"
|
||||
role="button"
|
||||
style="cursor:pointer"
|
||||
>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
View all cards
|
||||
</span>
|
||||
<span class="badge rounded-pill text-bg-secondary small ms-auto">{totalQty}</span>
|
||||
</li>
|
||||
<div class="offcanvas-body d-xl-flex flex-column gap-3 p-0">
|
||||
<div class="d-none d-xl-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>
|
||||
</div>
|
||||
|
||||
{["Case Cards", "Japanese Singles", "Bulk"].map((name) => (
|
||||
<ul id="catalogList" class="list-group list-group-flush">
|
||||
<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"
|
||||
data-catalog={name}
|
||||
class="list-group-item list-group-item-action fw-semibold border-0 rounded p-2 d-flex align-items-center active"
|
||||
data-catalog="all"
|
||||
role="button"
|
||||
style="cursor:pointer"
|
||||
hx-post="/partials/inventory-cards"
|
||||
hx-vals={JSON.stringify({ catalog: "all" })}
|
||||
hx-target="#gridView"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
{name}
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
All cards
|
||||
</span>
|
||||
<span class="badge rounded-pill text-bg-secondary small ms-auto">{totalQty}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div class="mt-auto pt-3 border-top border-secondary small text-secondary">
|
||||
<div class="d-flex justify-content-between mb-1"><span>Total Cards</span><span class="text-light fw-semibold">{totalQty}</span></div>
|
||||
<div class="d-flex justify-content-between mb-1"><span>Market Value</span><span class="text-success fw-semibold">${totalValue.toFixed(0)}</span></div>
|
||||
<div class="d-flex justify-content-between"><span>Gain/Loss</span><span class={`fw-semibold ${totalGain >= 0 ? "text-success" : "text-danger"}`}>{totalGain >= 0 ? "+" : ""}${Math.abs(totalGain).toFixed(0)}</span></div>
|
||||
{catalogs.map((name) => (
|
||||
<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"
|
||||
data-catalog={name}
|
||||
role="button"
|
||||
style="cursor:pointer"
|
||||
hx-post="/partials/inventory-cards"
|
||||
hx-vals={JSON.stringify({ catalog: name })}
|
||||
hx-target="#gridView"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
{name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div class="mt-auto pt-3 border-top border-secondary small text-secondary">
|
||||
<div class="d-flex justify-content-between mb-1"><span>Total Cards</span><span class="text-light fw-semibold">{totalQty}</span></div>
|
||||
<div class="d-flex justify-content-between mb-1"><span>Market Value</span><span class="text-success fw-semibold">${totalValue.toFixed(0)}</span></div>
|
||||
<div class="d-flex justify-content-between"><span>Gain/Loss</span><span class={`fw-semibold ${totalGain >= 0 ? "text-success" : "text-danger"}`}>{totalGain >= 0 ? "+" : ""}${Math.abs(totalGain).toFixed(0)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="col-12 col-md-10 p-4">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center 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 Card</a>
|
||||
<main class="col-12 col-xl-10 mt-4">
|
||||
<div class="d-flex flex-wrap flex-row gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary d-xl-none me-auto"
|
||||
data-bs-toggle="offcanvas"
|
||||
data-bs-target="#catalogSidebar"
|
||||
aria-controls="catalogSidebar"
|
||||
>
|
||||
Catalogs
|
||||
</button>
|
||||
<a href="/pokemon" class="btn btn-vendor ms-auto">Add cards</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
class="btn btn-outline-secondary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#bulkImportModal"
|
||||
>Bulk Import</button>
|
||||
|
||||
<div class="ms-auto position-relative">
|
||||
<div class="input-group">
|
||||
<input type="hidden" name="start" id="start" value="0" />
|
||||
<input type="hidden" name="sort" id="sortInput" value="" />
|
||||
<input type="hidden" name="language" id="languageInput" value="all" />
|
||||
<input type="search" name="i" id="searchInput" class="form-control search-input" placeholder="Search your inventory" />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-purple border-start-0"
|
||||
aria-label="search"
|
||||
onclick="
|
||||
const i = this.closest('form').querySelector('[name=i]').value;
|
||||
dataLayer.push({ event: 'view_inventory_results', search_term: i });
|
||||
if (window.location.pathname !== '/dashboard') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
sessionStorage.setItem('pendingSearch', 1);
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
"
|
||||
>
|
||||
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-lg-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load">
|
||||
</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>
|
||||
</main>
|
||||
</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-dialog modal-lg modal-dialog-centered">
|
||||
@@ -236,60 +136,403 @@ const totalGain = summary.totalGain || 0;
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="small text-secondary mb-3">
|
||||
<p class="small text-secondary mb-2">
|
||||
Upload a CSV exported from Collectr, TCGPlayer, or any marketplace. Columns: <code>name, set, condition, qty, price, market</code>.
|
||||
</p>
|
||||
<p class="small text-secondary mb-3">
|
||||
Optionally add a <code>date</code> column (e.g. <code>2026-07-16</code>) to record when each card was acquired — it’s used as the entry’s added/purchase date. Rows without a date default to today.
|
||||
</p>
|
||||
<label class="form-label small text-secondary text-uppercase fw-semibold" for="csvFileInput">Choose File</label>
|
||||
<input id="csvFileInput" type="file" accept=".csv" class="form-control bg-dark-subtle text-light border-secondary" />
|
||||
<div id="csvPreview" class="mt-3 d-none">
|
||||
<p class="small text-secondary fw-semibold mb-1">Preview</p>
|
||||
<div class="border border-secondary rounded p-2 small text-secondary" id="csvPreviewContent">—</div>
|
||||
<div class="border border-secondary rounded p-2 small text-secondary overflow-auto" id="csvPreviewContent" style="max-height: 12rem;">—</div>
|
||||
</div>
|
||||
<div id="csvImportResult" class="mt-3 d-none"></div>
|
||||
</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="csvUploadBtn">Upload & Preview</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>
|
||||
<button type="button" class="btn btn-outline-danger" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" id="csvUploadBtn">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="addCardModal" tabindex="-1" aria-labelledby="addCardLabel" aria-modal="true" role="dialog">
|
||||
<div class="modal-dialog modal-lg 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="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>
|
||||
<!-- Inventory detail modal (loaded from /partials/inventory-modal on open) -->
|
||||
<div class="modal card-modal" id="inventoryModal" tabindex="-1" aria-labelledby="inventoryModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
|
||||
<div class="modal-content p-2">Loading...</div>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
const catalogList = document.getElementById('catalogList');
|
||||
const searchInput = document.getElementById('inventorySearchInput');
|
||||
const searchBtn = document.getElementById('inventorySearchBtn');
|
||||
|
||||
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');
|
||||
// switching catalogs clears the search box so its contents don't misrepresent the grid
|
||||
if (searchInput) searchInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
// analytics only — the search request itself is fired declaratively via htmx attributes
|
||||
function pushSearchEvent() {
|
||||
if (window.dataLayer) {
|
||||
window.dataLayer.push({ event: 'view_inventory_results', search_term: searchInput?.value.trim() || '' });
|
||||
}
|
||||
}
|
||||
searchBtn?.addEventListener('click', pushSearchEvent);
|
||||
searchInput?.addEventListener('keyup', (e) => { if (e.key === 'Enter') pushSearchEvent(); });
|
||||
})();
|
||||
|
||||
// ── Bulk CSV import ──────────────────────────────────────────────────────
|
||||
(function () {
|
||||
const importModal = document.getElementById('bulkImportModal');
|
||||
const fileInput = document.getElementById('csvFileInput');
|
||||
const uploadBtn = document.getElementById('csvUploadBtn');
|
||||
const preview = document.getElementById('csvPreview');
|
||||
const previewContent = document.getElementById('csvPreviewContent');
|
||||
const result = document.getElementById('csvImportResult');
|
||||
if (!importModal || !fileInput || !uploadBtn) return;
|
||||
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
|
||||
let selectedFile = null;
|
||||
let didImport = false;
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
selectedFile = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null;
|
||||
result.classList.add('d-none');
|
||||
if (!selectedFile) { preview.classList.add('d-none'); return; }
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const lines = String(reader.result || '').split(/\r?\n/).filter((l) => l.trim()).slice(0, 6);
|
||||
previewContent.innerHTML = lines
|
||||
.map((l, i) => `<div class="${i === 0 ? 'text-light fw-semibold' : ''}">${esc(l)}</div>`)
|
||||
.join('');
|
||||
preview.classList.remove('d-none');
|
||||
};
|
||||
reader.readAsText(selectedFile);
|
||||
});
|
||||
|
||||
uploadBtn.addEventListener('click', async () => {
|
||||
if (!selectedFile) { alert('Choose a CSV file first.'); return; }
|
||||
const original = uploadBtn.textContent;
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.textContent = 'Importing…';
|
||||
result.classList.add('d-none');
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append('action', 'import');
|
||||
body.append('file', selectedFile);
|
||||
const res = await fetch('/api/inventory', { method: 'POST', body });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
result.className = 'mt-3 alert alert-danger py-2 small';
|
||||
result.textContent = data.error || 'Import failed.';
|
||||
result.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
didImport = data.imported > 0;
|
||||
const skipped = data.skipped || [];
|
||||
let html = `<div class="fw-semibold ${data.imported > 0 ? 'text-success' : ''}">Imported ${data.imported} card${data.imported === 1 ? '' : 's'}.</div>`;
|
||||
if (skipped.length) {
|
||||
html += `<div class="mt-1 text-warning fw-semibold">Skipped ${skipped.length}:</div>`;
|
||||
html += '<ul class="mb-0 ps-3">' + skipped
|
||||
.map((s) => `<li>Row ${s.row}${s.name ? ' (' + esc(s.name) + ')' : ''}: ${esc(s.reason)}</li>`)
|
||||
.join('') + '</ul>';
|
||||
}
|
||||
result.className = 'mt-3 alert alert-dark py-2 small overflow-auto';
|
||||
result.style.maxHeight = '14rem';
|
||||
result.innerHTML = html;
|
||||
result.classList.remove('d-none');
|
||||
uploadBtn.textContent = didImport ? 'Done' : 'Upload';
|
||||
} catch (e) {
|
||||
result.className = 'mt-3 alert alert-danger py-2 small';
|
||||
result.textContent = 'Import failed.';
|
||||
result.classList.remove('d-none');
|
||||
} finally {
|
||||
uploadBtn.disabled = false;
|
||||
if (uploadBtn.textContent === 'Importing…') uploadBtn.textContent = original;
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh the dashboard (grid, catalogs, totals) after a successful import.
|
||||
importModal.addEventListener('hidden.bs.modal', () => {
|
||||
if (didImport) window.location.reload();
|
||||
});
|
||||
})();
|
||||
|
||||
(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>
|
||||
<Footer slot="footer" />
|
||||
</Layout>
|
||||
@@ -11,8 +11,8 @@ import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
|
||||
import { Tooltip } from "bootstrap";
|
||||
|
||||
// auth check for inventory management features
|
||||
//const { canAddInventory } = Astro.locals;
|
||||
const canAddInventory = false;
|
||||
const { canAddInventory } = Astro.locals;
|
||||
// const canAddInventory = false;
|
||||
|
||||
export const partial = true;
|
||||
export const prerender = false;
|
||||
@@ -55,9 +55,36 @@ const calculatedAt = (() => {
|
||||
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
|
||||
? 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);
|
||||
@@ -76,41 +103,6 @@ const historyRows = skuIds.length
|
||||
.orderBy(priceHistory.calculatedAt)
|
||||
: [];
|
||||
|
||||
// Rolling 30-day cutoff for volatility calculation
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000);
|
||||
|
||||
const byCondition: Record<string, number[]> = {};
|
||||
for (const row of historyRows) {
|
||||
if (row.marketPrice == null) continue;
|
||||
if (!row.calculatedAt) continue;
|
||||
if (new Date(row.calculatedAt) < thirtyDaysAgo) continue;
|
||||
const price = Number(row.marketPrice);
|
||||
if (price <= 0) continue;
|
||||
if (!byCondition[row.condition]) byCondition[row.condition] = [];
|
||||
byCondition[row.condition].push(price);
|
||||
}
|
||||
|
||||
function computeVolatility(prices: number[]): { label: string; monthlyVol: number } {
|
||||
if (prices.length < 2) return { label: '—', monthlyVol: 0 };
|
||||
const returns: number[] = [];
|
||||
for (let i = 1; i < prices.length; i++) {
|
||||
returns.push(Math.log(prices[i] / prices[i - 1]));
|
||||
}
|
||||
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
|
||||
const variance = returns.reduce((sum, r) => sum + (r - mean) ** 2, 0) / (returns.length - 1);
|
||||
const monthlyVol = Math.sqrt(variance) * Math.sqrt(30);
|
||||
const label = monthlyVol >= 0.30 ? 'High'
|
||||
: monthlyVol >= 0.15 ? 'Medium'
|
||||
: 'Low';
|
||||
return { label, monthlyVol: Math.round(monthlyVol * 100) / 100 };
|
||||
}
|
||||
|
||||
const volatilityByCondition: Record<string, { label: string; monthlyVol: number }> = {};
|
||||
for (const [condition, prices] of Object.entries(byCondition)) {
|
||||
volatilityByCondition[condition] = computeVolatility(prices);
|
||||
}
|
||||
|
||||
// ── Price history for chart (full history, not windowed) ──────────────────
|
||||
const priceHistoryForChart = historyRows.map(row => ({
|
||||
condition: row.condition,
|
||||
calculatedAt: row.calculatedAt
|
||||
@@ -119,29 +111,11 @@ const priceHistoryForChart = historyRows.map(row => ({
|
||||
marketPrice: row.marketPrice,
|
||||
})).filter(r => r.calculatedAt !== null);
|
||||
|
||||
// ── Determine which range buttons to show ────────────────────────────────
|
||||
const now = Date.now();
|
||||
const oldestDate = historyRows.length
|
||||
? Math.min(...historyRows
|
||||
.filter(r => r.calculatedAt)
|
||||
.map(r => new Date(r.calculatedAt!).getTime()))
|
||||
: now;
|
||||
|
||||
const dataSpanDays = (now - oldestDate) / 86_400_000;
|
||||
|
||||
const showRanges = {
|
||||
'1m': dataSpanDays >= 1,
|
||||
'3m': dataSpanDays >= 60,
|
||||
'6m': dataSpanDays >= 180,
|
||||
'1y': dataSpanDays >= 365,
|
||||
'all': dataSpanDays >= 400,
|
||||
};
|
||||
|
||||
const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
|
||||
|
||||
const conditionAttributes = (price: any) => {
|
||||
const condition: string = price?.condition || "Near Mint";
|
||||
const vol = volatilityByCondition[condition] ?? { label: '—', monthlyVol: 0 };
|
||||
const vol = volatilityByCondition[condition] ?? { label: '—', spread: 0 };
|
||||
|
||||
const volatilityClass = (() => {
|
||||
switch (vol.label) {
|
||||
@@ -154,7 +128,7 @@ const conditionAttributes = (price: any) => {
|
||||
|
||||
const volatilityDisplay = vol.label === '—'
|
||||
? '—'
|
||||
: `${vol.label} (${(vol.monthlyVol * 100).toFixed(0)}%)`;
|
||||
: `${vol.label} (${(vol.spread * 100).toFixed(0)}%)`;
|
||||
|
||||
return {
|
||||
"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 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) => {
|
||||
@@ -205,9 +179,6 @@ const altSearchUrl = (card: any) => {
|
||||
<!-- Card image column -->
|
||||
<div class="col-sm-12 col-md-3">
|
||||
<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
|
||||
class="card-image-wrap rounded-4"
|
||||
data-energy={card?.energyType}
|
||||
@@ -287,11 +258,11 @@ const altSearchUrl = (card: any) => {
|
||||
<p class="mb-0 mt-1">${price.marketPrice}</p>
|
||||
</div>
|
||||
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
|
||||
<h6 class="mb-auto">Lowest Price</h6>
|
||||
<h6 class="mb-auto">Low Price <span class="small p text-secondary">(30 day)</span></h6>
|
||||
<p class="mb-0 mt-1">${price.lowestPrice}</p>
|
||||
</div>
|
||||
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
|
||||
<h6 class="mb-auto">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>
|
||||
</div>
|
||||
<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-html="true"
|
||||
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'>
|
||||
<p class="mb-1">
|
||||
<strong>What this measures:</strong> how much the market price tends to move day-to-day,
|
||||
scaled up to a monthly expectation.
|
||||
<strong>What this measures:</strong> how wide the gap between the 30-day low and high is,
|
||||
relative to the market price.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
A card with <strong>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>
|
||||
</div>
|
||||
`}
|
||||
@@ -502,7 +473,7 @@ const altSearchUrl = (card: any) => {
|
||||
|
||||
</datalist>
|
||||
<div class="form-text">
|
||||
Type a name or pick an existing catalog.
|
||||
Enter a name or pick an existing catalog.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
@@ -566,16 +537,18 @@ const altSearchUrl = (card: any) => {
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end mt-2" role="group" aria-label="Time range">
|
||||
{showRanges['1m'] && <button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button>}
|
||||
{showRanges['3m'] && <button type="button" class="btn btn-dark price-range-btn" data-range="3m">3M</button>}
|
||||
{showRanges['6m'] && <button type="button" class="btn btn-dark price-range-btn" data-range="6m">6M</button>}
|
||||
{showRanges['1y'] && <button type="button" class="btn btn-dark price-range-btn" data-range="1y">1Y</button>}
|
||||
{showRanges['all'] && <button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>}
|
||||
<button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button>
|
||||
<button type="button" class="btn btn-dark price-range-btn" data-range="3m">3M</button>
|
||||
<button type="button" class="btn btn-dark price-range-btn" data-range="6m">6M</button>
|
||||
<button type="button" class="btn btn-dark price-range-btn" data-range="1y">1Y</button>
|
||||
<button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- External links column -->
|
||||
|
||||
@@ -7,8 +7,8 @@ export const prerender = false;
|
||||
import * as util from 'util';
|
||||
|
||||
// auth check for inventory management features
|
||||
//const { canAddInventory } = Astro.locals;
|
||||
const canAddInventory = false;
|
||||
const { canAddInventory } = Astro.locals;
|
||||
// const canAddInventory = false;
|
||||
|
||||
// all the facet fields we want to use for filtering
|
||||
const facetFields:any = {
|
||||
@@ -22,14 +22,14 @@ const facetFields:any = {
|
||||
|
||||
// ── Allowed sort values ───────────────────────────────────────────────────
|
||||
const sortMap: Record<string, string> = {
|
||||
'releaseDate:desc,number:asc': '_text_match:asc,releaseDate:desc,number:asc',
|
||||
'releaseDate:asc,number:asc': '_text_match:asc,releaseDate:asc,number:asc',
|
||||
'marketPrice:desc': 'marketPrice:desc,releaseDate:desc,number:asc',
|
||||
'marketPrice:asc': 'marketPrice:asc,releaseDate:desc,number:asc',
|
||||
'number:asc': '_text_match:asc,number:asc',
|
||||
'number:desc': '_text_match:asc,number:desc',
|
||||
'releaseDate:desc,number:asc': '_text_match:asc,releaseDate:desc,inumber(missing_values:last):asc',
|
||||
'releaseDate:asc,number:asc': '_text_match:asc,releaseDate:asc,inumber(missing_values:last):asc',
|
||||
'marketPrice:desc': 'marketPrice:desc,releaseDate:desc,inumber(missing_values:last):asc',
|
||||
'marketPrice:asc': 'marketPrice:asc,releaseDate:desc,inumber(missing_values:last):asc',
|
||||
'number:asc': '_text_match:asc,inumber(missing_values:last):asc',
|
||||
'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
|
||||
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)
|
||||
// and rewrites them into a direct filter, clearing the query so it doesn't
|
||||
// also try to text-match against card names.
|
||||
const EREADER_SETS = ['Expedition Base Set', 'Aquapolis', 'Skyridge', 'Battle-e'];
|
||||
const EREADER_RE = /^(e-?reader|e reader)$/i;
|
||||
|
||||
const ALIAS_FILTERS = [
|
||||
// ── Era / set groupings ───────────────────────────────────────────────
|
||||
{ re: /^(e-?reader|e reader)$/i, field: 'setName',
|
||||
values: ['Expedition Base Set', 'Aquapolis', 'Skyridge', 'Battle-e'] },
|
||||
|
||||
{ re: /^neo$/i, field: 'setName',
|
||||
values: ['Neo Genesis', 'Neo Discovery', 'Neo Revelation', 'Neo Destiny'] },
|
||||
|
||||
{ re: /^(wotc|wizards)$/i, field: 'setName',
|
||||
values: ['Base Set', 'Jungle', 'Fossil', 'Base Set 2', 'Team Rocket',
|
||||
'Gym Heroes', 'Gym Challenge', 'Neo Genesis', 'Neo Discovery',
|
||||
'Neo Revelation', 'Neo Destiny', 'Expedition Base Set',
|
||||
'Aquapolis', 'Skyridge', 'Battle-e'] },
|
||||
|
||||
{ re: /^(sun\s*(&|and)\s*moon|s(&|and)m|sm)$/i, field: 'setName',
|
||||
values: ['Sun & Moon', 'Guardians Rising', 'Burning Shadows', 'Crimson Invasion',
|
||||
'Ultra Prism', 'Forbidden Light', 'Celestial Storm', 'Dragon Majesty',
|
||||
'Lost Thunder', 'Team Up', 'Unbroken Bonds', 'Unified Minds',
|
||||
'Hidden Fates', 'Cosmic Eclipse', 'Detective Pikachu'] },
|
||||
|
||||
{ re: /^(sword\s*(&|and)\s*shield|s(&|and)s|swsh)$/i, field: 'setName',
|
||||
values: ['Sword & Shield', 'Rebel Clash', 'Darkness Ablaze', 'Vivid Voltage',
|
||||
'Battle Styles', 'Chilling Reign', 'Evolving Skies', 'Fusion Strike',
|
||||
'Brilliant Stars', 'Astral Radiance', 'Pokemon GO', 'Lost Origin',
|
||||
'Silver Tempest', 'Crown Zenith'] },
|
||||
|
||||
// ── Card type shorthands ──────────────────────────────────────────────
|
||||
{ re: /^trainers?$/i, field: 'cardType', values: ['Trainer'] },
|
||||
{ re: /^supporters?$/i, field: 'cardType', values: ['Supporter'] },
|
||||
{ re: /^stadiums?$/i, field: 'cardType', values: ['Stadium'] },
|
||||
{ re: /^items?$/i, field: 'cardType', values: ['Item'] },
|
||||
{ re: /^(energys?|energies)$/i, field: 'cardType', values: ['Energy'] },
|
||||
|
||||
// ── Rarity shorthands ─────────────────────────────────────────────────
|
||||
{ re: /^promos?$/i, field: 'rarityName', values: ['Promo'] },
|
||||
];
|
||||
|
||||
let resolvedQuery = query;
|
||||
let queryFilter = '';
|
||||
|
||||
if (EREADER_RE.test(query.trim())) {
|
||||
resolvedQuery = '';
|
||||
queryFilter = `setName:=[${EREADER_SETS.map(s => '`' + s + '`').join(',')}]`;
|
||||
for (const alias of ALIAS_FILTERS) {
|
||||
if (alias.re.test(query.trim())) {
|
||||
resolvedQuery = '';
|
||||
queryFilter = `${alias.field}:=[${alias.values.map(s => '`' + s + '`').join(',')}]`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const filters = Array.from(formData.entries())
|
||||
@@ -91,15 +129,15 @@ const facetFilter = (facet:string) => {
|
||||
|
||||
|
||||
// primary search values (for cards)
|
||||
let searchArray = [{
|
||||
// Note: no `$skus(...)` join here — see the sku fetch below for why.
|
||||
let searchArray: any[] = [{
|
||||
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,
|
||||
facet_by: '',
|
||||
max_facet_values: 0,
|
||||
page: Math.floor(start / 20) + 1,
|
||||
sort_by: resolvedSort,
|
||||
include_fields: '$skus(*)',
|
||||
}];
|
||||
|
||||
// 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 commonSearchParams = {
|
||||
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
|
||||
@@ -131,6 +172,28 @@ const cardResults = searchResults.results[0] as any;
|
||||
const pokemon = cardResults.hits?.map((hit: any) => hit.document) ?? [];
|
||||
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 "–"
|
||||
const formatPrice = (condition:string, skus: any) => {
|
||||
@@ -280,20 +343,20 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
||||
}
|
||||
|
||||
{pokemon.length === 0 && (
|
||||
<div id="notfound" hx-swap-oob="true">
|
||||
Pokemon not found
|
||||
<div id="notfound" class="mt-4 h6" hx-swap-oob="true">
|
||||
No cards found! Please modify your search and try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pokemon.map((card:any) => (
|
||||
<div class="col">
|
||||
<div class="col equal-height-col">
|
||||
{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>
|
||||
)}
|
||||
<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-glare"></div>
|
||||
</div>
|
||||
@@ -309,12 +372,12 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
||||
<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="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>
|
||||
<span class="ps-2 small-icon"><RarityIcon rarity={card.rarityName} /></span>
|
||||
</div>
|
||||
<div class="text-secondary fst-italic">{card.variant}</div><span class="d-none">{card.productId}</span>
|
||||
</div>
|
||||
|
||||
))}
|
||||
{start + 20 < totalHits &&
|
||||
<div hx-post="/partials/cards" hx-trigger="revealed" hx-include="#searchform" hx-target="#cardGrid" hx-swap="beforeend" hx-on--after-request="afterUpdate(event)">
|
||||
|
||||
@@ -11,9 +11,16 @@ import { db } from '../../db';
|
||||
const formData = await Astro.request.formData();
|
||||
const query = formData.get('q')?.toString() || '';
|
||||
const start = Number(formData.get('start')?.toString() || '0');
|
||||
const catalog = formData.get('catalog')?.toString() || '';
|
||||
|
||||
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) => {
|
||||
//console.log('inventoryid', inventoryId);
|
||||
const details = await db.query.inventory.findFirst({
|
||||
@@ -27,7 +34,7 @@ const InventoryDetails = async (inventoryId: string) => {
|
||||
// primary search values (for cards)
|
||||
let searchArray = [{
|
||||
collection: 'inventories',
|
||||
filter_by: `userId:=${userId}`,
|
||||
filter_by: filterBy,
|
||||
per_page: 20,
|
||||
facet_by: '',
|
||||
max_facet_values: 0,
|
||||
@@ -90,7 +97,7 @@ console.log(`totalHits: ${totalHits}`);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
@@ -98,18 +105,6 @@ console.log(`totalHits: ${totalHits}`);
|
||||
</span>
|
||||
</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="text-body-tertiary flex-grow-1 small">{card.setName}</div>
|
||||
</div>
|
||||
@@ -142,4 +137,16 @@ console.log(`totalHits: ${totalHits}`);
|
||||
</div>
|
||||
);
|
||||
|
||||
})
|
||||
})}
|
||||
{start + 20 < totalHits &&
|
||||
<div
|
||||
hx-post="/partials/inventory-cards"
|
||||
hx-trigger="revealed"
|
||||
hx-vals={JSON.stringify({ start: start + 20, catalog, q: query })}
|
||||
hx-target="#gridView"
|
||||
hx-swap="beforeend"
|
||||
hx-on--after-request="this.remove()"
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
}
|
||||
|
||||
247
src/pages/partials/inventory-modal.astro
Normal file
247
src/pages/partials/inventory-modal.astro
Normal file
@@ -0,0 +1,247 @@
|
||||
---
|
||||
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>
|
||||
)}
|
||||
1
src/svg/set/chaos_rising.svg
Normal file
1
src/svg/set/chaos_rising.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.5 KiB |
61
src/volatility.ts
Normal file
61
src/volatility.ts
Normal 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 (0–1 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;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "src/**/*"],
|
||||
"include": [".astro/types.d.ts", "src/**/*", "scripts/**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user