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"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^9.5.4",
|
"@astrojs/node": "^10.1.4",
|
||||||
"@clerk/astro": "^3.0.1",
|
"@clerk/astro": "^3.0.1",
|
||||||
"@clerk/shared": "^4.0.0",
|
"@clerk/shared": "^4.0.0",
|
||||||
"@clerk/themes": "^2.4.55",
|
"@clerk/themes": "^2.4.55",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"astro": "^5.17.1",
|
"astro": "^6.4.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"csv": "^6.4.1",
|
"csv": "^6.4.1",
|
||||||
"dotenv": "^17.2.4",
|
"dotenv": "^17.2.4",
|
||||||
"drizzle-orm": "^1.0.0-beta.15-859cf75",
|
"drizzle-orm": "1.0.0-beta.15-859cf75",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"sass": "^1.97.3",
|
"sass": "^1.97.3",
|
||||||
"typesense": "^3.0.1"
|
"typesense": "^3.0.1"
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"@types/bootstrap": "^5.2.10",
|
"@types/bootstrap": "^5.2.10",
|
||||||
"@types/node": "^25.2.1",
|
"@types/node": "^25.2.1",
|
||||||
"@types/pg": "^8.18.0",
|
"@types/pg": "^8.18.0",
|
||||||
"drizzle-kit": "^1.0.0-beta.15-859cf75",
|
"drizzle-kit": "1.0.0-beta.15-859cf75",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ async function findMissingImages() {
|
|||||||
.where(sql`${schema.tcgcards.sealed} = false`);
|
.where(sql`${schema.tcgcards.sealed} = false`);
|
||||||
const missingImages: string[] = [];
|
const missingImages: string[] = [];
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
const imagePath = path.join(process.cwd(), 'public', 'cards', `${card.productId}.jpg`);
|
const imagePath = path.join(process.cwd(), 'static', 'cards', `${card.productId}.jpg`);
|
||||||
try {
|
try {
|
||||||
await fs.access(imagePath);
|
await fs.access(imagePath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ const DollarToInt = (dollar: any) => {
|
|||||||
return Math.round(dollar * 100);
|
return Math.round(dollar * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Logger = (msg: string) => void;
|
||||||
|
const defaultLogger: Logger = (msg) => console.log(chalk.green(msg));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const Sleep = (ms: number) => {
|
export const Sleep = (ms: number) => {
|
||||||
@@ -39,7 +42,7 @@ export const GetNumberOrNull = (value: any): number | null => {
|
|||||||
|
|
||||||
|
|
||||||
// Delete and recreate the 'cards' index
|
// Delete and recreate the 'cards' index
|
||||||
export const createCardCollection = async () => {
|
export const createCardCollection = async (log: Logger = defaultLogger) => {
|
||||||
try {
|
try {
|
||||||
await client.collections('cards').delete();
|
await client.collections('cards').delete();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -56,9 +59,11 @@ export const createCardCollection = async () => {
|
|||||||
{ name: 'productLineName', type: 'string', facet: true },
|
{ name: 'productLineName', type: 'string', facet: true },
|
||||||
{ name: 'rarityName', type: 'string', facet: true },
|
{ name: 'rarityName', type: 'string', facet: true },
|
||||||
{ name: 'setName', type: 'string', facet: true },
|
{ name: 'setName', type: 'string', facet: true },
|
||||||
|
{ name: 'setCode', type: 'string' },
|
||||||
{ name: 'cardType', type: 'string', facet: true },
|
{ name: 'cardType', type: 'string', facet: true },
|
||||||
{ name: 'energyType', type: 'string', facet: true },
|
{ name: 'energyType', type: 'string', facet: true },
|
||||||
{ name: 'number', type: 'string', sort: true },
|
{ name: 'number', type: 'string' },
|
||||||
|
{ name: 'inumber', type: 'int32', optional: true, sort: true },
|
||||||
{ name: 'Artist', type: 'string' },
|
{ name: 'Artist', type: 'string' },
|
||||||
{ name: 'sealed', type: 'bool' },
|
{ name: 'sealed', type: 'bool' },
|
||||||
{ name: 'releaseDate', type: 'int32' },
|
{ name: 'releaseDate', type: 'int32' },
|
||||||
@@ -67,11 +72,11 @@ export const createCardCollection = async () => {
|
|||||||
// { name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
|
// { name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
console.log(chalk.green('Collection "cards" created successfully.'));
|
log('Collection "cards" created successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete and recreate the 'skus' index
|
// Delete and recreate the 'skus' index
|
||||||
export const createSkuCollection = async () => {
|
export const createSkuCollection = async (log: Logger = defaultLogger) => {
|
||||||
try {
|
try {
|
||||||
await client.collections('skus').delete();
|
await client.collections('skus').delete();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -88,11 +93,11 @@ export const createSkuCollection = async () => {
|
|||||||
{ name: 'card_id', type: 'string', reference: 'cards.id', async_reference: true },
|
{ name: 'card_id', type: 'string', reference: 'cards.id', async_reference: true },
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
console.log(chalk.green('Collection "skus" created successfully.'));
|
log('Collection "skus" created successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete and recreate the 'inventory' index
|
// Delete and recreate the 'inventory' index
|
||||||
export const createInventoryCollection = async () => {
|
export const createInventoryCollection = async (log: Logger = defaultLogger) => {
|
||||||
try {
|
try {
|
||||||
await client.collections('inventories').delete();
|
await client.collections('inventories').delete();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -116,16 +121,24 @@ export const createInventoryCollection = async () => {
|
|||||||
{ name: 'cardType', type: 'string' },
|
{ name: 'cardType', type: 'string' },
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
console.log(chalk.green('Collection "inventories" created successfully.'));
|
log('Collection "inventories" created successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const upsertCardCollection = async (db:DBInstance) => {
|
export const upsertCardCollection = async (db:DBInstance, log: Logger = defaultLogger) => {
|
||||||
const pokemon = await db.query.cards.findMany({
|
const pokemon = await db.query.cards.findMany({
|
||||||
with: { set: true, tcgdata: true, prices: true },
|
with: { set: true, tcgdata: true, prices: true },
|
||||||
});
|
});
|
||||||
await client.collections('cards').documents().import(pokemon.map(card => {
|
await client.collections('cards').documents().import(pokemon.map(card => {
|
||||||
const marketPrice = card.tcgdata?.marketPrice ? DollarToInt(card.tcgdata.marketPrice) : null;
|
// Use the NM SKU price matching the card's variant (kept fresh by syncPrices)
|
||||||
|
// Fall back to any NM sku, then to tcgdata price
|
||||||
|
const nmSku = card.prices.find(p => p.condition === 'Near Mint' && p.variant === card.variant)
|
||||||
|
?? card.prices.find(p => p.condition === 'Near Mint');
|
||||||
|
const marketPrice = nmSku?.marketPrice
|
||||||
|
? DollarToInt(nmSku.marketPrice)
|
||||||
|
: card.tcgdata?.marketPrice
|
||||||
|
? DollarToInt(card.tcgdata.marketPrice)
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: card.cardId.toString(),
|
id: card.cardId.toString(),
|
||||||
@@ -136,21 +149,23 @@ export const upsertCardCollection = async (db:DBInstance) => {
|
|||||||
productLineName: card.productLineName,
|
productLineName: card.productLineName,
|
||||||
rarityName: card.rarityName,
|
rarityName: card.rarityName,
|
||||||
setName: card.set?.setName || "",
|
setName: card.set?.setName || "",
|
||||||
|
setCode: card.set?.setCode || "",
|
||||||
cardType: card.cardType || "",
|
cardType: card.cardType || "",
|
||||||
energyType: card.energyType || "",
|
energyType: card.energyType || "",
|
||||||
number: card.number,
|
number: card.number,
|
||||||
|
inumber: (card.number !== null) ? parseInt(card.number) : undefined,
|
||||||
Artist: card.artist || "",
|
Artist: card.artist || "",
|
||||||
sealed: card.sealed,
|
sealed: card.sealed,
|
||||||
content: [card.productName, card.productLineName, card.set?.setName || "", card.number, card.rarityName, card.artist || ""].join(' '),
|
content: [card.productName, card.productLineName, card.set?.setName || "", card.set?.setCode || "", card.number, card.rarityName, card.artist || ""].join(' '),
|
||||||
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
|
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
|
||||||
...(marketPrice !== null && { marketPrice }),
|
...(marketPrice !== null && { marketPrice }),
|
||||||
// sku_id: card.prices.map(price => price.skuId.toString())
|
// sku_id: card.prices.map(price => price.skuId.toString())
|
||||||
};
|
};
|
||||||
}), { action: 'upsert' });
|
}), { action: 'upsert' });
|
||||||
console.log(chalk.green('Collection "cards" indexed successfully.'));
|
log('Collection "cards" indexed successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const upsertSkuCollection = async (db:DBInstance) => {
|
export const upsertSkuCollection = async (db:DBInstance, log: Logger = defaultLogger) => {
|
||||||
const skus = await db.query.skus.findMany();
|
const skus = await db.query.skus.findMany();
|
||||||
await client.collections('skus').documents().import(skus.map(sku => ({
|
await client.collections('skus').documents().import(skus.map(sku => ({
|
||||||
id: sku.skuId.toString(),
|
id: sku.skuId.toString(),
|
||||||
@@ -160,10 +175,10 @@ export const upsertSkuCollection = async (db:DBInstance) => {
|
|||||||
marketPrice: DollarToInt(sku.marketPrice),
|
marketPrice: DollarToInt(sku.marketPrice),
|
||||||
card_id: sku.cardId.toString(),
|
card_id: sku.cardId.toString(),
|
||||||
})), { action: 'upsert' });
|
})), { action: 'upsert' });
|
||||||
console.log(chalk.green('Collection "skus" indexed successfully.'));
|
log('Collection "skus" indexed successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const upsertInventoryCollection = async (db:DBInstance) => {
|
export const upsertInventoryCollection = async (db:DBInstance, log: Logger = defaultLogger) => {
|
||||||
const inv = await db.query.inventory.findMany({
|
const inv = await db.query.inventory.findMany({
|
||||||
with: { sku: { with: { card: { with: { set: true } } } } }
|
with: { sku: { with: { card: { with: { set: true } } } } }
|
||||||
});
|
});
|
||||||
@@ -188,25 +203,27 @@ export const upsertInventoryCollection = async (db:DBInstance) => {
|
|||||||
i.sku?.card?.artist || ""
|
i.sku?.card?.artist || ""
|
||||||
].join(' '),
|
].join(' '),
|
||||||
})), { action: 'upsert' });
|
})), { action: 'upsert' });
|
||||||
console.log(chalk.green('Collection "inventories" indexed successfully.'));
|
log('Collection "inventories" indexed successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const UpdateVariants = async (db:DBInstance) => {
|
export const UpdateVariants = async (db:DBInstance, log: Logger = (m) => console.log(m)) => {
|
||||||
const updates = await db.execute(sql`update cards as c
|
const updates = await db.execute(sql`update cards as c
|
||||||
set
|
set
|
||||||
product_name = a.product_name, product_line_name = a.product_line_name, product_url_name = a.product_url_name, rarity_name = a.rarity_name,
|
product_name = a.product_name, product_line_name = a.product_line_name, product_url_name = a.product_url_name, rarity_name = a.rarity_name,
|
||||||
sealed = a.sealed, set_id = a.set_id, card_type = a.card_type, energy_type = a.energy_type, number = a.number, artist = a.artist
|
sealed = a.sealed, set_id = a.set_id, card_type = a.card_type, energy_type = a.energy_type, number = a.number, inumber = a.inumber, artist = a.artist
|
||||||
from (
|
from (
|
||||||
select t.product_id, b.variant,
|
select t.product_id, b.variant,
|
||||||
coalesce(o.product_name, regexp_replace(regexp_replace(coalesce(nullif(t.product_name, ''), t.product_url_name),' \\\\(.*\\\\)',''),' - .*$','')) as product_name,
|
coalesce(o.product_name, regexp_replace(regexp_replace(coalesce(nullif(t.product_name, ''), t.product_url_name),' \\\\(.*\\\\)',''),' - .*$','')) as product_name,
|
||||||
coalesce(o.product_line_name, t.product_line_name) as product_line_name, coalesce(o.product_url_name, t.product_url_name) as product_url_name,
|
coalesce(o.product_line_name, t.product_line_name) as product_line_name, coalesce(o.product_url_name, t.product_url_name) as product_url_name,
|
||||||
coalesce(o.rarity_name, t.rarity_name) as rarity_name, coalesce(o.sealed, t.sealed) as sealed, coalesce(o.set_id, t.set_id) as set_id,
|
coalesce(o.rarity_name, t.rarity_name) as rarity_name, coalesce(o.sealed, t.sealed) as sealed, coalesce(o.set_id, t.set_id) as set_id,
|
||||||
coalesce(o.card_type, t.card_type) as card_type, coalesce(o.energy_type, t.energy_type) as energy_type,
|
coalesce(o.card_type, t.card_type) as card_type, coalesce(o.energy_type, t.energy_type) as energy_type,
|
||||||
coalesce(o.number, t.number) as number, coalesce(o.artist, t.artist) as artist
|
coalesce(o.number, regexp_replace(t.number,'^0+','')) as number,
|
||||||
|
nullif(regexp_replace(regexp_replace(coalesce(o.number,t.number),'/.*',''),'[^0-9]','','g'),'')::integer as inumber,
|
||||||
|
coalesce(o.artist, t.artist) as artist
|
||||||
from tcg_cards t
|
from tcg_cards t
|
||||||
join (select distinct product_id, variant from skus) b on t.product_id = b.product_id
|
join (select distinct product_id, variant from skus) b on t.product_id = b.product_id
|
||||||
left join tcg_overrides o on t.product_id = o.product_id
|
left join tcg_overrides o on t.product_id = o.product_id
|
||||||
@@ -219,22 +236,23 @@ where c.product_id = a.product_id and c.variant = a.variant and
|
|||||||
c.energy_type is distinct from a.energy_type or c."number" is distinct from a."number" or c.artist is distinct from a.artist
|
c.energy_type is distinct from a.energy_type or c."number" is distinct from a."number" or c.artist is distinct from a.artist
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
console.log(`Updated ${updates.rowCount} rows in cards table`);
|
log(`Updated ${updates.rowCount} rows in cards table`);
|
||||||
|
|
||||||
const inserts = await db.execute(sql`insert into cards (product_id, variant, product_name, product_line_name, product_url_name, rarity_name, sealed, set_id, card_type, energy_type, "number", artist)
|
const inserts = await db.execute(sql`insert into cards (product_id, variant, product_name, product_line_name, product_url_name, rarity_name, sealed, set_id, card_type, energy_type, "number", inumber, artist)
|
||||||
select t.product_id, b.variant,
|
select t.product_id, b.variant,
|
||||||
coalesce(o.product_name, regexp_replace(regexp_replace(coalesce(nullif(t.product_name, ''), t.product_url_name),' \\\\(.*\\\\)',''),' - .*$','')) as product_name,
|
coalesce(o.product_name, regexp_replace(regexp_replace(coalesce(nullif(t.product_name, ''), t.product_url_name),' \\\\(.*\\\\)',''),' - .*$','')) as product_name,
|
||||||
coalesce(o.product_line_name, t.product_line_name) as product_line_name, coalesce(o.product_url_name, t.product_url_name) as product_url_name, coalesce(o.rarity_name, t.rarity_name) as rarity_name,
|
coalesce(o.product_line_name, t.product_line_name) as product_line_name, coalesce(o.product_url_name, t.product_url_name) as product_url_name, coalesce(o.rarity_name, t.rarity_name) as rarity_name,
|
||||||
coalesce(o.sealed, t.sealed) as sealed, coalesce(o.set_id, t.set_id) as set_id, coalesce(o.card_type, t.card_type) as card_type,
|
coalesce(o.sealed, t.sealed) as sealed, coalesce(o.set_id, t.set_id) as set_id, coalesce(o.card_type, t.card_type) as card_type,
|
||||||
coalesce(o.energy_type, t.energy_type) as energy_type, coalesce(o.number, t.number) as number, coalesce(o.artist, t.artist) as artist
|
coalesce(o.energy_type, t.energy_type) as energy_type, coalesce(o.number, regexp_replace(t.number,'^0+','')) as number,
|
||||||
|
nullif(regexp_replace(regexp_replace(coalesce(o.number,t.number),'/.*',''),'[^0-9]','','g'),'')::integer as inumber, coalesce(o.artist, t.artist) as artist
|
||||||
from tcg_cards t
|
from tcg_cards t
|
||||||
join (select distinct product_id, variant from skus) b on t.product_id = b.product_id
|
join (select distinct product_id, variant from skus) b on t.product_id = b.product_id
|
||||||
left join tcg_overrides o on t.product_id = o.product_id
|
left join tcg_overrides o on t.product_id = o.product_id
|
||||||
where not exists (select 1 from cards where product_id=t.product_id and variant=b.variant)
|
where not exists (select 1 from cards where product_id=t.product_id and variant=b.variant)
|
||||||
`);
|
`);
|
||||||
console.log(`Inserted ${inserts.rowCount} rows into cards table`);
|
log(`Inserted ${inserts.rowCount} rows into cards table`);
|
||||||
|
|
||||||
const skuUpdates = await db.execute(sql`update skus s set card_id = c.card_id from cards c where s.product_id = c.product_id and s.variant = c.variant and s.card_id is distinct from c.card_id`);
|
const skuUpdates = await db.execute(sql`update skus s set card_id = c.card_id from cards c where s.product_id = c.product_id and s.variant = c.variant and s.card_id is distinct from c.card_id`);
|
||||||
console.log(`Updated ${skuUpdates.rowCount} rows in skus table`);
|
log(`Updated ${skuUpdates.rowCount} rows in skus table`);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +1,86 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import * as schema from '../src/db/schema.ts';
|
import * as schema from '../src/db/schema.ts';
|
||||||
import { db, ClosePool } from '../src/db/index.ts';
|
import { db, ClosePool, type DBInstance } from '../src/db/index.ts';
|
||||||
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import chalk from 'chalk';
|
import { pathToFileURL } from 'node:url';
|
||||||
import * as helper from './pokemon-helper.ts';
|
import * as helper from './pokemon-helper.ts';
|
||||||
//import util from 'util';
|
|
||||||
|
export type Logger = (msg: string) => void;
|
||||||
|
const consoleLogger: Logger = (m) => console.log(m);
|
||||||
|
|
||||||
|
export type RunImportOptions = {
|
||||||
|
sets?: string[];
|
||||||
|
log?: Logger;
|
||||||
|
runUpdateVariants?: boolean;
|
||||||
|
runCardUpsert?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
async function syncTcgplayer(cardSets:string[] = []) {
|
const syncProductLine = async (
|
||||||
|
database: DBInstance,
|
||||||
const productLines = [ "pokemon", "pokemon-japan" ];
|
productLine: string,
|
||||||
|
field: string,
|
||||||
// work from the available sets within the product line
|
fieldValue: string,
|
||||||
for (const productLine of productLines) {
|
allProductIds: Set<number>,
|
||||||
const d = {"algorithm":"sales_dismax","from":0,"size":1,"filters":{"term":{"productLineName":[productLine]}},"settings":{"useFuzzySearch":false}};
|
log: Logger,
|
||||||
|
) => {
|
||||||
const response = await fetch('https://mp-search-api.tcgplayer.com/v1/search/request?q=&isList=false', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json',},
|
|
||||||
body: JSON.stringify(d),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('Error notifying sync completion:', response.statusText);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const setNames = data.results[0].aggregations.setName;
|
|
||||||
for (const setName of setNames) {
|
|
||||||
let processSet = true;
|
|
||||||
if (cardSets.length > 0) {
|
|
||||||
processSet = cardSets.some(set => setName.value.toLowerCase().includes(set.toLowerCase()));
|
|
||||||
}
|
|
||||||
if (processSet) {
|
|
||||||
console.log(chalk.blue(`Syncing product line "${productLine}" with setName "${setName.urlValue}"...`));
|
|
||||||
await syncProductLine(productLine, "setName", setName.urlValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(chalk.green('✓ All TCGPlayer data synchronized successfully!'));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function syncProductLine(productLine: string, field: string, fieldValue: string) {
|
|
||||||
let start = 0;
|
let start = 0;
|
||||||
let size = 50;
|
const size = 50;
|
||||||
let total = 1000000;
|
let total = 1000000;
|
||||||
|
|
||||||
while (start < total) {
|
while (start < total) {
|
||||||
console.log(` Fetching items ${start} to ${start + size} of ${total}...`);
|
log(` Fetching items ${start} to ${start + size} of ${total}...`);
|
||||||
|
|
||||||
const d = {
|
const d = {
|
||||||
"algorithm":"sales_dismax",
|
"algorithm": "sales_dismax",
|
||||||
"from":start,
|
"from": start,
|
||||||
"size":size,
|
"size": size,
|
||||||
"filters":{
|
"filters": {
|
||||||
"term":{"productLineName":[productLine], [field]:[fieldValue]} ,
|
"term": { "productLineName": [productLine], [field]: [fieldValue] },
|
||||||
"range":{},
|
"range": {},
|
||||||
"match":{}
|
"match": {},
|
||||||
},
|
|
||||||
"listingSearch":{
|
|
||||||
"context":{"cart":{}},
|
|
||||||
"filters":{"term":{
|
|
||||||
"sellerStatus":"Live",
|
|
||||||
"channelId":0
|
|
||||||
},
|
|
||||||
"range":{
|
|
||||||
"quantity":{"gte":1}
|
|
||||||
},
|
|
||||||
"exclude":{"channelExclusion":0}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"context":{
|
|
||||||
"cart":{},
|
|
||||||
"shippingCountry":"US",
|
|
||||||
"userProfile":{}
|
|
||||||
},
|
|
||||||
"settings":{
|
|
||||||
"useFuzzySearch":false,
|
|
||||||
"didYouMean":{}
|
|
||||||
},
|
|
||||||
"sort":{}
|
|
||||||
};
|
|
||||||
|
|
||||||
//console.log(util.inspect(d, { depth: null }));
|
|
||||||
//process.exit(1);
|
|
||||||
|
|
||||||
const response = await fetch('https://mp-search-api.tcgplayer.com/v1/search/request?q=&isList=false', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify(d),
|
"listingSearch": {
|
||||||
});
|
"context": { "cart": {} },
|
||||||
|
"filters": {
|
||||||
|
"term": { "sellerStatus": "Live", "channelId": 0 },
|
||||||
|
"range": { "quantity": { "gte": 1 } },
|
||||||
|
"exclude": { "channelExclusion": 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"context": { "cart": {}, "shippingCountry": "US", "userProfile": {} },
|
||||||
|
"settings": { "useFuzzySearch": false, "didYouMean": {} },
|
||||||
|
"sort": {},
|
||||||
|
};
|
||||||
|
|
||||||
if (!response.ok) {
|
let response: Response | null = null;
|
||||||
console.error('Error notifying sync completion:', response.statusText);
|
let lastError: unknown = null;
|
||||||
process.exit(1);
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
try {
|
||||||
|
const r = await fetch('https://mp-search-api.tcgplayer.com/v1/search/request?q=&isList=false', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(d),
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
response = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lastError = new Error(`TCGPlayer search request failed: ${r.status} ${r.statusText}`);
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
}
|
||||||
|
if (attempt < 3) {
|
||||||
|
log(` retry ${attempt}/2 for search request in 5s...`);
|
||||||
|
await helper.Sleep(5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!response) {
|
||||||
|
throw lastError instanceof Error
|
||||||
|
? lastError
|
||||||
|
: new Error(`TCGPlayer search request failed: ${String(lastError)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -108,28 +88,43 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
|||||||
|
|
||||||
for (const item of data.results[0].results) {
|
for (const item of data.results[0].results) {
|
||||||
|
|
||||||
// Check if productId already exists and skip if it does (to avoid hitting the API too much)
|
|
||||||
if (allProductIds.size > 0 && allProductIds.has(item.productId)) {
|
if (allProductIds.size > 0 && allProductIds.has(item.productId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(chalk.blue(` - ${item.productName} (ID: ${item.productId})`));
|
log(` - ${item.productName} (ID: ${item.productId})`);
|
||||||
|
|
||||||
// Get product detail
|
let detailResponse: Response | null = null;
|
||||||
const detailResponse = await fetch(`https://mp-search-api.tcgplayer.com/v2/product/${item.productId}/details`, {
|
let lastError: unknown = null;
|
||||||
method: 'GET',
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
});
|
try {
|
||||||
if (!detailResponse.ok) {
|
const r = await fetch(`https://mp-search-api.tcgplayer.com/v2/product/${item.productId}/details`, {
|
||||||
console.error('Error fetching product details:', detailResponse.statusText);
|
method: 'GET',
|
||||||
process.exit(1);
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
detailResponse = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lastError = new Error(`Error fetching product details for ${item.productId}: ${r.statusText}`);
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
}
|
||||||
|
if (attempt < 3) {
|
||||||
|
log(` retry ${attempt}/2 for product ${item.productId} in 5s...`);
|
||||||
|
await helper.Sleep(5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!detailResponse) {
|
||||||
|
throw lastError instanceof Error
|
||||||
|
? lastError
|
||||||
|
: new Error(`Error fetching product details for ${item.productId}: ${String(lastError)}`);
|
||||||
}
|
}
|
||||||
const detailData = await detailResponse.json();
|
const detailData = await detailResponse.json();
|
||||||
|
|
||||||
|
|
||||||
await db.insert(schema.tcgcards).values({
|
await database.insert(schema.tcgcards).values({
|
||||||
productId: item.productId,
|
productId: item.productId,
|
||||||
productName: detailData.productName,
|
productName: detailData.productName,
|
||||||
//productName: cleanProductName(item.productName),
|
|
||||||
rarityName: item.rarityName,
|
rarityName: item.rarityName,
|
||||||
productLineName: detailData.productLineName,
|
productLineName: detailData.productLineName,
|
||||||
productLineUrlName: detailData.productLineUrlName,
|
productLineUrlName: detailData.productLineUrlName,
|
||||||
@@ -167,7 +162,6 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
|||||||
target: schema.tcgcards.productId,
|
target: schema.tcgcards.productId,
|
||||||
set: {
|
set: {
|
||||||
productName: detailData.productName,
|
productName: detailData.productName,
|
||||||
//productName: cleanProductName(item.productName),
|
|
||||||
rarityName: item.rarityName,
|
rarityName: item.rarityName,
|
||||||
productLineName: detailData.productLineName,
|
productLineName: detailData.productLineName,
|
||||||
productLineUrlName: detailData.productLineUrlName,
|
productLineUrlName: detailData.productLineUrlName,
|
||||||
@@ -204,11 +198,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log(`item: ${item.setId}\tdetail: ${detailData.setId}`);
|
await database.insert(schema.sets).values({
|
||||||
// console.log(`item: ${item.setCode}\tdetail: ${detailData.setCode}`);
|
|
||||||
// console.log(`item: ${item.setName}\tdetail: ${detailData.setName}`);
|
|
||||||
// set is...
|
|
||||||
await db.insert(schema.sets).values({
|
|
||||||
setId: detailData.setId,
|
setId: detailData.setId,
|
||||||
setCode: detailData.setCode,
|
setCode: detailData.setCode,
|
||||||
setName: detailData.setName,
|
setName: detailData.setName,
|
||||||
@@ -222,10 +212,8 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// skus are...
|
|
||||||
for (const skuItem of detailData.skus) {
|
for (const skuItem of detailData.skus) {
|
||||||
|
await database.insert(schema.skus).values({
|
||||||
await db.insert(schema.skus).values({
|
|
||||||
skuId: skuItem.sku,
|
skuId: skuItem.sku,
|
||||||
productId: detailData.productId,
|
productId: detailData.productId,
|
||||||
condition: skuItem.condition,
|
condition: skuItem.condition,
|
||||||
@@ -241,7 +229,6 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// get image if it doesn't already exist
|
|
||||||
const imagePath = path.join(process.cwd(), 'static', 'cards', `${item.productId}.jpg`);
|
const imagePath = path.join(process.cwd(), 'static', 'cards', `${item.productId}.jpg`);
|
||||||
if (!await helper.FileExists(imagePath)) {
|
if (!await helper.FileExists(imagePath)) {
|
||||||
const imageResponse = await fetch(`https://tcgplayer-cdn.tcgplayer.com/product/${item.productId}_in_1000x1000.jpg`);
|
const imageResponse = await fetch(`https://tcgplayer-cdn.tcgplayer.com/product/${item.productId}_in_1000x1000.jpg`);
|
||||||
@@ -249,37 +236,88 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
|||||||
const buffer = await imageResponse.arrayBuffer();
|
const buffer = await imageResponse.arrayBuffer();
|
||||||
await fs.writeFile(imagePath, Buffer.from(buffer));
|
await fs.writeFile(imagePath, Buffer.from(buffer));
|
||||||
} else {
|
} else {
|
||||||
console.error(chalk.yellow(`Error fetching ${item.productId}: ${item.productName} image:`, imageResponse.statusText));
|
log(`Error fetching ${item.productId}: ${item.productName} image: ${imageResponse.statusText}`);
|
||||||
await fs.appendFile('missing_images.log', `${item.productId}: ${item.productName}\n`, 'utf-8');
|
await fs.appendFile('missing_images.log', `${item.productId}: ${item.productName}\n`, 'utf-8');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// be nice to the API and not send too many requests in a short time
|
|
||||||
await helper.Sleep(300);
|
await helper.Sleep(300);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start += size;
|
start += size;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const syncTcgplayer = async (database: DBInstance, cardSets: string[], allProductIds: Set<number>, log: Logger) => {
|
||||||
|
const productLines = ["pokemon", "pokemon-japan"];
|
||||||
|
|
||||||
|
for (const productLine of productLines) {
|
||||||
|
const d = {
|
||||||
|
"algorithm": "sales_dismax", "from": 0, "size": 1,
|
||||||
|
"filters": { "term": { "productLineName": [productLine] } },
|
||||||
|
"settings": { "useFuzzySearch": false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('https://mp-search-api.tcgplayer.com/v1/search/request?q=&isList=false', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(d),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`TCGPlayer setName aggregation failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const setNames = data.results[0].aggregations.setName;
|
||||||
|
for (const setName of setNames) {
|
||||||
|
let processSet = true;
|
||||||
|
if (cardSets.length > 0) {
|
||||||
|
processSet = cardSets.some(set => setName.value.toLowerCase().includes(set.toLowerCase()));
|
||||||
|
}
|
||||||
|
if (processSet) {
|
||||||
|
log(`Syncing product line "${productLine}" with setName "${setName.urlValue}"...`);
|
||||||
|
await syncProductLine(database, productLine, "setName", setName.urlValue, allProductIds, log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log('All TCGPlayer data synchronized successfully!');
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const runImport = async (opts: RunImportOptions = {}) => {
|
||||||
|
const { sets = [], log = consoleLogger, runUpdateVariants = true, runCardUpsert = true } = opts;
|
||||||
|
|
||||||
|
await fs.rm('missing_images.log', { force: true });
|
||||||
|
|
||||||
|
// When no set filter is provided, skip productIds already in the cards table
|
||||||
|
// (matches the CLI script's "no args" behavior).
|
||||||
|
const allProductIds = sets.length === 0
|
||||||
|
? new Set<number>(
|
||||||
|
await db.select({ productId: schema.cards.productId }).from(schema.cards)
|
||||||
|
.then(rows => rows.map(row => row.productId))
|
||||||
|
)
|
||||||
|
: new Set<number>();
|
||||||
|
|
||||||
|
await syncTcgplayer(db, sets, allProductIds, log);
|
||||||
|
|
||||||
|
if (runUpdateVariants) {
|
||||||
|
log('Updating card variants...');
|
||||||
|
await helper.UpdateVariants(db, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runCardUpsert) {
|
||||||
|
log('Reindexing "cards" collection...');
|
||||||
|
await helper.upsertCardCollection(db, log);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// CLI entry point — preserves the original `tsx scripts/preload-tcgplayer.ts [set...]` usage.
|
||||||
|
const isCli = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||||
|
if (isCli) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
await runImport({ sets: args });
|
||||||
|
await ClosePool();
|
||||||
}
|
}
|
||||||
|
|
||||||
// clear the log file
|
|
||||||
await fs.rm('missing_images.log', { force: true });
|
|
||||||
let allProductIds = new Set();
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
if (args.length === 0) {
|
|
||||||
allProductIds = new Set(await db.select({ productId: schema.cards.productId }).from(schema.cards).then(rows => rows.map(row => row.productId)));
|
|
||||||
await syncTcgplayer();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
await syncTcgplayer(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
// update the card table with new/updated variants
|
|
||||||
await helper.UpdateVariants(db);
|
|
||||||
|
|
||||||
// index the card updates
|
|
||||||
await helper.upsertCardCollection(db);
|
|
||||||
|
|
||||||
await ClosePool();
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ const updateLatestSales = async (updatedCards: Set<number>) => {
|
|||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const updatedCards = await syncPrices();
|
const updatedCards = await syncPrices();
|
||||||
await helper.upsertSkuCollection(db);
|
await helper.upsertSkuCollection(db);
|
||||||
|
await helper.upsertCardCollection(db);
|
||||||
//console.log(updatedCards);
|
//console.log(updatedCards);
|
||||||
//console.log(updatedCards.size);
|
//console.log(updatedCards.size);
|
||||||
//await updateLatestSales(updatedCards);
|
//await updateLatestSales(updatedCards);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
@import 'bootstrap/scss/containers';
|
@import 'bootstrap/scss/containers';
|
||||||
@import 'bootstrap/scss/images';
|
@import 'bootstrap/scss/images';
|
||||||
@import 'bootstrap/scss/nav';
|
@import 'bootstrap/scss/nav';
|
||||||
// @import 'bootstrap/scss/accordion';
|
@import 'bootstrap/scss/accordion';
|
||||||
@import 'bootstrap/scss/alert';
|
@import 'bootstrap/scss/alert';
|
||||||
@import 'bootstrap/scss/badge';
|
@import 'bootstrap/scss/badge';
|
||||||
// @import 'bootstrap/scss/breadcrumb';
|
// @import 'bootstrap/scss/breadcrumb';
|
||||||
|
|||||||
@@ -185,6 +185,10 @@ $colors: mauve, lilac, "purple", "orchid", "snow";
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-nav > .nav-item > .nav-link:hover {
|
||||||
|
color: rgba(255, 255, 255, 1);
|
||||||
|
}
|
||||||
|
|
||||||
.nav-tabs {
|
.nav-tabs {
|
||||||
.nav-link.active,
|
.nav-link.active,
|
||||||
.nav-item.show .nav-link {
|
.nav-item.show .nav-link {
|
||||||
@@ -882,6 +886,16 @@ footer .logo-svg > svg { width: var(--logo-width, 8rem); }
|
|||||||
-------------------------------------------------- */
|
-------------------------------------------------- */
|
||||||
#gridView { row-gap: 1.5rem; }
|
#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 {
|
.inv-grid-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
<script is:inline>
|
<script is:inline>
|
||||||
(function () {
|
(function () {
|
||||||
|
|
||||||
|
// ── Tooltip initializer ───────────────────────────────────────────────────
|
||||||
|
function initTooltips(root = document) {
|
||||||
|
root.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||||
|
bootstrap.Tooltip.getOrCreateInstance(el, { container: 'body' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Price mode helpers ────────────────────────────────────────────────────
|
// ── Price mode helpers ────────────────────────────────────────────────────
|
||||||
// marketPriceByCondition is injected into the modal HTML via a data attribute
|
// marketPriceByCondition is injected into the modal HTML via a data attribute
|
||||||
// on #inventoryEntryList: data-market-prices='{"Near Mint":6.00,...}'
|
// on #inventoryEntryList: data-market-prices='{"Near Mint":6.00,...}'
|
||||||
@@ -246,6 +253,9 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
|
|
||||||
// ── Global helpers ────────────────────────────────────────────────────────
|
// ── Global helpers ────────────────────────────────────────────────────────
|
||||||
window.copyImage = async function(img) {
|
window.copyImage = async function(img) {
|
||||||
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||||
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
@@ -260,10 +270,17 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
clean.src = img.src;
|
clean.src = img.src;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const blob = await new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isIOS) {
|
||||||
|
const file = new File([blob], 'card.png', { type: 'image/png' });
|
||||||
|
await navigator.share({ files: [file] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (navigator.clipboard && navigator.clipboard.write) {
|
if (navigator.clipboard && navigator.clipboard.write) {
|
||||||
const blob = await new Promise((resolve, reject) => {
|
|
||||||
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
|
|
||||||
});
|
|
||||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||||
showCopyToast('📋 Image copied!', '#198754');
|
showCopyToast('📋 Image copied!', '#198754');
|
||||||
} else {
|
} else {
|
||||||
@@ -282,6 +299,7 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') return;
|
||||||
console.error('Failed:', err);
|
console.error('Failed:', err);
|
||||||
showCopyToast('❌ Copy failed', '#dc3545');
|
showCopyToast('❌ Copy failed', '#dc3545');
|
||||||
}
|
}
|
||||||
@@ -414,6 +432,7 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
|
|
||||||
if (typeof htmx !== 'undefined') htmx.process(modal);
|
if (typeof htmx !== 'undefined') htmx.process(modal);
|
||||||
initInventoryForms(modal);
|
initInventoryForms(modal);
|
||||||
|
initTooltips(modal);
|
||||||
updateNavButtons(modal);
|
updateNavButtons(modal);
|
||||||
initChartAfterSwap(modal);
|
initChartAfterSwap(modal);
|
||||||
switchToRequestedTab();
|
switchToRequestedTab();
|
||||||
@@ -509,6 +528,7 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
|
|
||||||
if (typeof htmx !== 'undefined') htmx.process(target);
|
if (typeof htmx !== 'undefined') htmx.process(target);
|
||||||
initInventoryForms(target);
|
initInventoryForms(target);
|
||||||
|
initTooltips(target);
|
||||||
|
|
||||||
const destImg = target.querySelector('img.card-image');
|
const destImg = target.querySelector('img.card-image');
|
||||||
if (destImg) {
|
if (destImg) {
|
||||||
@@ -638,6 +658,7 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
updateNavButtons(cardModal);
|
updateNavButtons(cardModal);
|
||||||
initChartAfterSwap(cardModal);
|
initChartAfterSwap(cardModal);
|
||||||
initInventoryForms(cardModal);
|
initInventoryForms(cardModal);
|
||||||
|
initTooltips(cardModal);
|
||||||
switchToRequestedTab();
|
switchToRequestedTab();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -648,6 +669,7 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initInventoryForms();
|
initInventoryForms();
|
||||||
|
initTooltips();
|
||||||
|
|
||||||
const pending = sessionStorage.getItem('pendingSearch');
|
const pending = sessionStorage.getItem('pendingSearch');
|
||||||
if (pending) {
|
if (pending) {
|
||||||
|
|||||||
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
|
<button
|
||||||
class="navbar-toggler ms-4 p-1 btn btn-purple border-0"
|
class="navbar-toggler ms-4 p-1 btn btn-purple border-0"
|
||||||
@@ -30,9 +34,16 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link py-3 border-bottom border-secondary" href="/pokemon">Browse Cards</a>
|
<a class="nav-link py-3 border-bottom border-secondary" href="/pokemon">Browse Cards</a>
|
||||||
</li>
|
</li>
|
||||||
<!--<li class="nav-item">
|
{canAddInventory && (
|
||||||
<a class="nav-link py-3" href="/dashboard">Dashboard</a>
|
<li class="nav-item">
|
||||||
</li> -->
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,47 +25,77 @@ import { Show } from '@clerk/astro/components'
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Show when="signed-in">
|
<Show when="signed-in">
|
||||||
<form
|
{Astro.url.pathname === '/dashboard' ? (
|
||||||
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">
|
<div class="input-group">
|
||||||
{Astro.url.pathname === '/pokemon' && (
|
<input
|
||||||
<a class="btn btn-purple" data-bs-toggle="offcanvas" href="#filterBar" type="button" role="button" aria-controls="filterBar" aria-label="filter">
|
type="search"
|
||||||
<span class="d-block d-md-none filter-icon py-2">
|
name="q"
|
||||||
<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>
|
id="inventorySearchInput"
|
||||||
</span>
|
class="form-control search-input"
|
||||||
<span class="d-none d-md-block fw-medium">Filters</span>
|
placeholder="Search your inventory"
|
||||||
</a>
|
hx-post="/partials/inventory-cards"
|
||||||
)}
|
hx-trigger="keyup[key=='Enter']"
|
||||||
<input type="hidden" name="start" id="start" value="0" />
|
hx-target="#gridView"
|
||||||
<input type="hidden" name="sort" id="sortInput" value="" />
|
hx-swap="innerHTML"
|
||||||
<input type="hidden" name="language" id="languageInput" value="all" />
|
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
|
||||||
<input type="search" name="q" id="searchInput" class="form-control search-input" placeholder="Search cards" />
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
|
id="inventorySearchBtn"
|
||||||
class="btn btn-purple border-start-0"
|
class="btn btn-purple border-start-0"
|
||||||
aria-label="search"
|
aria-label="search"
|
||||||
onclick="
|
hx-post="/partials/inventory-cards"
|
||||||
const q = this.closest('form').querySelector('[name=q]').value;
|
hx-include="#inventorySearchInput"
|
||||||
dataLayer.push({ event: 'view_search_results', search_term: q });
|
hx-target="#gridView"
|
||||||
if (window.location.pathname !== '/pokemon') {
|
hx-swap="innerHTML"
|
||||||
event.preventDefault();
|
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
|
||||||
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>
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
</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 surging_sparks from "/src/svg/set/surging_sparks.svg?raw";
|
||||||
import team_rocket from "/src/svg/set/team_rocket.svg?raw";
|
import team_rocket from "/src/svg/set/team_rocket.svg?raw";
|
||||||
import perfect_order from "/src/svg/set/perfect_order.svg?raw";
|
import perfect_order from "/src/svg/set/perfect_order.svg?raw";
|
||||||
|
import chaos_rising from "/src/svg/set/chaos_rising.svg?raw";
|
||||||
|
|
||||||
const { set } = Astro.props;
|
const { set } = Astro.props;
|
||||||
|
|
||||||
@@ -254,6 +255,7 @@ const setMap = {
|
|||||||
"DRI": destined_rivals,
|
"DRI": destined_rivals,
|
||||||
"SSP": surging_sparks,
|
"SSP": surging_sparks,
|
||||||
"ME03": perfect_order,
|
"ME03": perfect_order,
|
||||||
|
"CRI": chaos_rising,
|
||||||
};
|
};
|
||||||
|
|
||||||
const svg = setMap[set as keyof typeof setMap] ?? "";
|
const svg = setMap[set as keyof typeof setMap] ?? "";
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const cards = pokeSchema.table('cards', {
|
|||||||
cardType: varchar({ length: 100 }),
|
cardType: varchar({ length: 100 }),
|
||||||
energyType: varchar({ length: 100 }),
|
energyType: varchar({ length: 100 }),
|
||||||
number: varchar({ length: 50 }),
|
number: varchar({ length: 50 }),
|
||||||
|
inumber: integer(),
|
||||||
artist: varchar({ length: 255 }),
|
artist: varchar({ length: 255 }),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
@@ -136,7 +137,8 @@ export const inventory = pokeSchema.table('inventory',{
|
|||||||
createdAt: timestamp().notNull().defaultNow(),
|
createdAt: timestamp().notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index('idx_userid_skuId').on(table.userId, table.skuId)
|
index('idx_userid_skuId').on(table.userId, table.skuId),
|
||||||
|
index('idx_userid_catalogName').on(table.userId, table.catalogName)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const processingSkus = pokeSchema.table('processing_skus', {
|
export const processingSkus = pokeSchema.table('processing_skus', {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const { title } = Astro.props;
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="htmx-config" content='{"historyCacheSize": 50}'/>
|
<meta name="htmx-config" content='{"historyCacheSize": 50}'/>
|
||||||
|
<meta name="google-adsense-account" content="ca-pub-1140571217687341">
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { clerkMiddleware, createRouteMatcher, clerkClient } from '@clerk/astro/server';
|
import { clerkMiddleware, createRouteMatcher, clerkClient } from '@clerk/astro/server';
|
||||||
import type { MiddlewareNext } from 'astro';
|
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
interface Locals {
|
interface Locals {
|
||||||
canAddInventory: boolean;
|
canAddInventory: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isProtectedRoute = createRouteMatcher(['/pokemon']);
|
const isProtectedRoute = createRouteMatcher(['/pokemon']);
|
||||||
const isAdminRoute = createRouteMatcher(['/admin']);
|
const isAdminRoute = createRouteMatcher(['/admin', '/api/reindex', '/api/preload-tcgplayer']);
|
||||||
|
|
||||||
const TARGET_ORG_ID = "org_3Baav9czkRLLlC7g89oJWqRRulK";
|
const TARGET_ORG_ID = "org_3Baav9czkRLLlC7g89oJWqRRulK";
|
||||||
|
const ADMIN_ORG_IDS = new Set([
|
||||||
|
"org_3Baav9czkRLLlC7g89oJWqRRulK",
|
||||||
|
"org_3ABdwuK3qD7Saq590ZMQWY7AvVz",
|
||||||
|
]);
|
||||||
|
|
||||||
export const onRequest = clerkMiddleware(async (auth, context, next) => {
|
export const onRequest = clerkMiddleware(async (auth, context, next) => {
|
||||||
const { isAuthenticated, userId, redirectToSignIn, has } = auth();
|
const { isAuthenticated, userId, redirectToSignIn, has } = auth();
|
||||||
@@ -23,42 +27,31 @@ export const onRequest = clerkMiddleware(async (auth, context, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Inventory visibility check ──────────────────────────────────────────────
|
// ── 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' ||
|
const canAddInventory = process.env.INVENTORY_ACCESS === 'true' ||
|
||||||
(
|
(
|
||||||
isAuthenticated &&
|
isAuthenticated &&
|
||||||
userId &&
|
userId &&
|
||||||
(
|
(
|
||||||
!!has({ permission: "org:feature:inventory_add" }) || // Clerk feature flag
|
!!has({ permission: "org:feature:inventory_add" }) ||
|
||||||
(await getUserOrgIds(context, userId)).includes(TARGET_ORG_ID)
|
(await getUserOrgIds(context, userId)).includes(TARGET_ORG_ID)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Expose the flag to your Astro pages via locals
|
|
||||||
context.locals.canAddInventory = Boolean(canAddInventory);
|
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 (isAdminRoute(context.request)) {
|
||||||
if (!isAuthenticated || !userId) {
|
if (!isAuthenticated || !userId) {
|
||||||
return redirectToSignIn();
|
return redirectToSignIn();
|
||||||
}
|
}
|
||||||
|
if (!context.locals.isAdmin) {
|
||||||
try {
|
return new Response(null, { status: 404 });
|
||||||
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("/");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,3 +69,28 @@ async function getUserOrgIds(context: any, userId: string): Promise<string[]> {
|
|||||||
return [];
|
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 type { APIRoute } from 'astro';
|
||||||
|
import { parse } from 'csv';
|
||||||
import { db } from '../../db/index';
|
import { db } from '../../db/index';
|
||||||
import { inventory, priceHistory } from '../../db/schema';
|
import { inventory, priceHistory } from '../../db/schema';
|
||||||
import { client } from '../../db/typesense';
|
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) => {
|
const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
|
||||||
// First add to database
|
// Insert one row per copy: a quantity of 5 becomes 5 separate entries of qty 1.
|
||||||
const inv = await db.insert(inventory).values({
|
const count = Math.max(1, Math.floor(quantity));
|
||||||
userId: userId,
|
const inv = await db.insert(inventory).values(
|
||||||
skuId: skuId,
|
Array.from({ length: count }, () => ({
|
||||||
catalogName: catalogName,
|
userId: userId,
|
||||||
purchasePrice: purchasePrice.toFixed(2),
|
skuId: skuId,
|
||||||
quantity: quantity,
|
catalogName: catalogName,
|
||||||
note: note,
|
purchasePrice: purchasePrice.toFixed(2),
|
||||||
}).returning();
|
quantity: 1,
|
||||||
|
note: note,
|
||||||
|
}))
|
||||||
|
).returning();
|
||||||
// Get card details from the database to add to Typesense
|
// Get card details from the database to add to Typesense
|
||||||
const card = await db.query.cards.findFirst({
|
const card = await db.query.cards.findFirst({
|
||||||
where: { cardId: cardId },
|
where: { cardId: cardId },
|
||||||
@@ -107,27 +134,7 @@ const addToInventory = async (userId: string, cardId: number, skuId: number, pur
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// And then add to Typesense for searching
|
// And then add to Typesense for searching
|
||||||
await client.collections('inventories').documents().import(inv.map(i => ({
|
await client.collections('inventories').documents().import(inv.map(i => inventoryTsDoc(i, card)));
|
||||||
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(' '),
|
|
||||||
})));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding inventory to Typesense:', 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();
|
await client.collections('inventories').documents(inventoryId).delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateInventory = async (inventoryId: string, quantity: number, purchasePrice: number, note: string) => {
|
const updateInventory = async (inventoryId: string, quantity: number, purchasePrice: number, note: string, catalogName: string) => {
|
||||||
// Update the database
|
// Update the database
|
||||||
await db.update(inventory).set({
|
await db.update(inventory).set({
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
purchasePrice: purchasePrice.toFixed(2),
|
purchasePrice: purchasePrice.toFixed(2),
|
||||||
note: note,
|
note: note,
|
||||||
|
catalogName: catalogName,
|
||||||
}).where(eq(inventory.inventoryId, inventoryId));
|
}).where(eq(inventory.inventoryId, inventoryId));
|
||||||
// No need to update Typesense since we don't search by quantity or price
|
// Quantity/price/note aren't searched, but catalogName is faceted in Typesense.
|
||||||
|
try {
|
||||||
|
await client.collections('inventories').documents(inventoryId).update({ catalogName });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating inventory catalog in Typesense:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
// Access form data from the request body
|
// Access form data from the request body
|
||||||
const formData = await request.formData();
|
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);
|
await addToInventory(userId!, cardId, skuId, purchasePrice, quantity, note, catalogName);
|
||||||
break;
|
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':
|
case 'remove':
|
||||||
const inventoryId = formData.get('inventoryId')?.toString() || '';
|
const inventoryId = formData.get('inventoryId')?.toString() || '';
|
||||||
await removeFromInventory(inventoryId);
|
await removeFromInventory(inventoryId);
|
||||||
@@ -183,7 +340,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const qty = Number(formData.get('quantity')) || 1;
|
const qty = Number(formData.get('quantity')) || 1;
|
||||||
const price = Number(formData.get('purchasePrice')) || 0;
|
const price = Number(formData.get('purchasePrice')) || 0;
|
||||||
const invNote = formData.get('note')?.toString() || '';
|
const invNote = formData.get('note')?.toString() || '';
|
||||||
await updateInventory(invId, qty, price, invNote);
|
const invCatalog = formData.get('catalogName')?.toString() || 'Default';
|
||||||
|
await updateInventory(invId, qty, price, invNote, invCatalog);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
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";
|
export const prerender = false;
|
||||||
import Footer from "../components/Footer.astro";
|
import Layout from '../layouts/Main.astro';
|
||||||
import BackToTop from "../components/BackToTop.astro";
|
|
||||||
import FirstEditionIcon from "../components/FirstEditionIcon.astro";
|
|
||||||
import { db } from '../db/index';
|
import { db } from '../db/index';
|
||||||
import { inventory, skus } from '../db/schema';
|
import { inventory, skus } from '../db/schema';
|
||||||
import { sql, sum, eq } from "drizzle-orm";
|
import { sql, sum, eq } from "drizzle-orm";
|
||||||
@@ -26,207 +24,109 @@ const totalQty = summary.totalQty || 0;
|
|||||||
const totalValue = summary.totalValue || 0;
|
const totalValue = summary.totalValue || 0;
|
||||||
const totalGain = summary.totalGain || 0;
|
const totalGain = summary.totalGain || 0;
|
||||||
|
|
||||||
|
// distinct catalog names for this user, for the sidebar catalog list
|
||||||
|
const catalogRows = userId
|
||||||
|
? await db
|
||||||
|
.selectDistinct({ name: inventory.catalogName })
|
||||||
|
.from(inventory)
|
||||||
|
.where(eq(inventory.userId, userId))
|
||||||
|
: [];
|
||||||
|
const catalogs = catalogRows
|
||||||
|
.map(r => r.name)
|
||||||
|
.filter((n): n is string => !!n)
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Inventory Dashboard">
|
<Layout title="Inventory Dashboard">
|
||||||
<div class="container-fluid container-sm mt-3" slot="page">
|
<div class="container-fluid container-sm" slot="page">
|
||||||
<BackToTop />
|
|
||||||
<div class="row mb-4">
|
<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">
|
<aside
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
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"
|
||||||
<h6 class="mb-0 text-uppercase text-secondary fw-bold ls-wide" style="letter-spacing:.08em">Catalogs</h6>
|
tabindex="-1"
|
||||||
<button
|
id="catalogSidebar"
|
||||||
class="btn btn-purple-secondary fs-7"
|
aria-labelledby="catalogSidebarLabel"
|
||||||
title="New catalog"
|
>
|
||||||
type="button"
|
<div class="offcanvas-header d-xl-none">
|
||||||
data-bs-toggle="modal"
|
<h6 class="offcanvas-title text-uppercase text-secondary fw-bold ls-wide" id="catalogSidebarLabel" style="letter-spacing:.08em">Catalogs</h6>
|
||||||
data-bs-target="#newCatalogModal"
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" data-bs-target="#catalogSidebar" aria-label="Close"></button>
|
||||||
>+ New</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul id="catalogList" class="list-group list-group-flush">
|
<div class="offcanvas-body d-xl-flex flex-column gap-3 p-0">
|
||||||
<li
|
<div class="d-none d-xl-flex justify-content-between align-items-center">
|
||||||
class="list-group-item list-group-item-action fw-semibold border-0 rounded p-2 d-flex align-items-center active"
|
<h6 class="mb-0 text-uppercase text-secondary fw-bold ls-wide" style="letter-spacing:.08em">Catalogs</h6>
|
||||||
data-catalog="all"
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
{["Case Cards", "Japanese Singles", "Bulk"].map((name) => (
|
<ul id="catalogList" class="list-group list-group-flush">
|
||||||
<li
|
<li
|
||||||
class="ms-2 list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center gap-2"
|
class="list-group-item list-group-item-action fw-semibold border-0 rounded p-2 d-flex align-items-center active"
|
||||||
data-catalog={name}
|
data-catalog="all"
|
||||||
role="button"
|
role="button"
|
||||||
style="cursor:pointer"
|
style="cursor:pointer"
|
||||||
|
hx-post="/partials/inventory-cards"
|
||||||
|
hx-vals={JSON.stringify({ catalog: "all" })}
|
||||||
|
hx-target="#gridView"
|
||||||
|
hx-swap="innerHTML"
|
||||||
>
|
>
|
||||||
{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>
|
</li>
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="mt-auto pt-3 border-top border-secondary small text-secondary">
|
{catalogs.map((name) => (
|
||||||
<div class="d-flex justify-content-between mb-1"><span>Total Cards</span><span class="text-light fw-semibold">{totalQty}</span></div>
|
<li
|
||||||
<div class="d-flex justify-content-between mb-1"><span>Market Value</span><span class="text-success fw-semibold">${totalValue.toFixed(0)}</span></div>
|
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"
|
||||||
<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>
|
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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="col-12 col-md-10 p-4">
|
<main class="col-12 col-xl-10 mt-4">
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-center mb-4">
|
<div class="d-flex flex-wrap flex-row gap-2 mb-4">
|
||||||
<div class="d-flex align-items-center gap-1">
|
<button
|
||||||
<button id="btnGrid" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn active" title="Images view">
|
type="button"
|
||||||
<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>
|
class="btn btn-secondary d-xl-none me-auto"
|
||||||
<span class="ms-1">Grid</span>
|
data-bs-toggle="offcanvas"
|
||||||
</button>
|
data-bs-target="#catalogSidebar"
|
||||||
<button id="btnTable" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn" title="List view">
|
aria-controls="catalogSidebar"
|
||||||
<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>
|
Catalogs
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<a href="/pokemon" class="btn btn-vendor ms-auto">Add cards</a>
|
||||||
|
|
||||||
<div class="vr opacity-25 mx-1"></div>
|
|
||||||
|
|
||||||
<a href="/pokemon" class="btn btn-vendor">+ Add Card</a>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary"
|
class="btn btn-outline-secondary"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#bulkImportModal"
|
data-bs-target="#bulkImportModal"
|
||||||
>Bulk Import</button>
|
>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>
|
||||||
|
|
||||||
<div id="inventoryView">
|
<div id="inventoryView">
|
||||||
<div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load">
|
<div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div id="tableView" style="display:none">
|
|
||||||
<div class="inv-list-wrap">
|
|
||||||
<table class="table align-middle mb-0 inv-list-table">
|
|
||||||
<tbody id="inventoryRows">
|
|
||||||
{inventory.map(card => {
|
|
||||||
const market = nmPrice(card);
|
|
||||||
const purchase = nmPurchase(card);
|
|
||||||
const diff = market - purchase;
|
|
||||||
const pct = purchase > 0 ? (diff / purchase) * 100 : 0;
|
|
||||||
const isGain = diff >= 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr class="inv-list-row">
|
|
||||||
<td class="inv-list-cardcell">
|
|
||||||
<div class="inv-list-card">
|
|
||||||
<div
|
|
||||||
class="inv-list-thumb card-trigger"
|
|
||||||
data-card-id={card.productId}
|
|
||||||
data-bs-toggle="modal"
|
|
||||||
data-bs-target="#cardModal"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={`/cards/${card.productId}.jpg`}
|
|
||||||
alt={card.productName}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
onerror="this.onerror=null;this.src='/cards/default.jpg';"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inv-list-info">
|
|
||||||
<div
|
|
||||||
class="inv-list-name"
|
|
||||||
data-card-id={card.productId}
|
|
||||||
data-bs-toggle="modal"
|
|
||||||
data-bs-target="#cardModal"
|
|
||||||
style="cursor:pointer"
|
|
||||||
>
|
|
||||||
{card.productName}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inv-list-meta">
|
|
||||||
<div class="inv-list-setlink">{card.setName}</div>
|
|
||||||
<div>{card.rarityName}</div>
|
|
||||||
<div>{card.number}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inv-list-condition">
|
|
||||||
<span>Near Mint</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{card.variant !== "Normal" ? card.variant : "Holofoil"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inv-list-right">
|
|
||||||
<div class={`inv-list-price-line ${isGain ? "up" : "down"}`}>
|
|
||||||
<span class="inv-grid-arrow small">{isGain ? "▲" : "▼"}</span>
|
|
||||||
<span class="inv-list-price">${market.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
<div class={`inv-list-delta ${isGain ? "up" : "down"}`}>
|
|
||||||
{isGain ? "+" : "-"}${Math.abs(diff).toFixed(2)} ({isGain ? "+" : "-"}{Math.abs(pct).toFixed(2)}%)
|
|
||||||
</div>
|
|
||||||
<div class="inv-list-qty">Qty: {card.qty}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="text-secondary small mt-2 ps-1" id="rowCount"></div>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div class="modal fade" id="newCatalogModal" tabindex="-1" aria-labelledby="newCatalogLabel" aria-modal="true" role="dialog">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content bg-dark text-light border border-secondary">
|
|
||||||
<div class="modal-header border-secondary">
|
|
||||||
<h5 class="modal-title" id="newCatalogLabel">Create Catalog</h5>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<label class="form-label small text-secondary text-uppercase fw-semibold" for="catalogNameInput">Catalog Name</label>
|
|
||||||
<input id="catalogNameInput" type="text" class="form-control bg-dark-subtle text-light border-secondary" placeholder="e.g. Japanese Holos" />
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer border-secondary">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-success" id="createCatalogBtn">Create</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>-->
|
|
||||||
|
|
||||||
<div class="modal fade" id="bulkImportModal" tabindex="-1" aria-labelledby="bulkImportLabel" aria-modal="true" role="dialog">
|
<div class="modal fade" id="bulkImportModal" tabindex="-1" aria-labelledby="bulkImportLabel" aria-modal="true" role="dialog">
|
||||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||||
@@ -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>
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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>.
|
Upload a CSV exported from Collectr, TCGPlayer, or any marketplace. Columns: <code>name, set, condition, qty, price, market</code>.
|
||||||
</p>
|
</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>
|
<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" />
|
<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">
|
<div id="csvPreview" class="mt-3 d-none">
|
||||||
<p class="small text-secondary fw-semibold mb-1">Preview</p>
|
<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>
|
||||||
|
<div id="csvImportResult" class="mt-3 d-none"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer border-secondary">
|
<div class="modal-footer border-secondary">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-danger" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-success" id="csvUploadBtn">Upload & Preview</button>
|
<button type="button" class="btn btn-success" id="csvUploadBtn">Upload</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!--
|
|
||||||
<div class="modal fade" id="inventoryEditModal" tabindex="-1" aria-labelledby="inventoryEditLabel" aria-modal="true" role="dialog">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content bg-dark text-light border border-secondary">
|
|
||||||
<div class="modal-header border-secondary">
|
|
||||||
<h5 class="modal-title" id="inventoryEditLabel">Edit Inventory</h5>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" id="inventoryEditBody">
|
|
||||||
<p class="text-secondary small">Select a card to edit its quantity and purchase price.</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer border-secondary">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-success">Save</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="addCardModal" tabindex="-1" aria-labelledby="addCardLabel" aria-modal="true" role="dialog">
|
<!-- Inventory detail modal (loaded from /partials/inventory-modal on open) -->
|
||||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
<div class="modal card-modal" id="inventoryModal" tabindex="-1" aria-labelledby="inventoryModalLabel" aria-hidden="true">
|
||||||
<div class="modal-content bg-dark text-light border border-secondary">
|
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
|
||||||
<div class="modal-header border-secondary">
|
<div class="modal-content p-2">Loading...</div>
|
||||||
<h5 class="modal-title" id="addCardLabel">Add Card</h5>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<input type="text" class="form-control bg-dark-subtle text-light border-secondary mb-3" placeholder="Search card name…" id="addCardSearch" />
|
|
||||||
<p class="text-secondary small">Search results will appear here. Connect to your card database API to enable live search.</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer border-secondary">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" disabled>Add Selected</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div> -->
|
</div>
|
||||||
|
|
||||||
|
<script is:inline>
|
||||||
|
(function () {
|
||||||
|
const catalogList = document.getElementById('catalogList');
|
||||||
|
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>
|
</div>
|
||||||
<Footer slot="footer" />
|
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -11,8 +11,8 @@ import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
|
|||||||
import { Tooltip } from "bootstrap";
|
import { Tooltip } from "bootstrap";
|
||||||
|
|
||||||
// auth check for inventory management features
|
// auth check for inventory management features
|
||||||
//const { canAddInventory } = Astro.locals;
|
const { canAddInventory } = Astro.locals;
|
||||||
const canAddInventory = false;
|
// const canAddInventory = false;
|
||||||
|
|
||||||
export const partial = true;
|
export const partial = true;
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
@@ -55,9 +55,36 @@ const calculatedAt = (() => {
|
|||||||
return new Date(Math.max(...dates.map(d => d.getTime())));
|
return new Date(Math.max(...dates.map(d => d.getTime())));
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ── Fetch price history + compute volatility ──────────────────────────────
|
// ── Spread-based volatility (high - low) / low ────────────────────────────
|
||||||
|
// Log-return volatility was unreliable because marketPrice is a smoothed daily
|
||||||
|
// value, not transaction-driven. The 30-day high/low spread is a more honest
|
||||||
|
// proxy for price movement over the period.
|
||||||
|
|
||||||
|
const volatilityByCondition: Record<string, { label: string; spread: number }> = {};
|
||||||
|
|
||||||
|
for (const price of card?.prices ?? []) {
|
||||||
|
const condition = price.condition;
|
||||||
|
const low = Number(price.lowestPrice);
|
||||||
|
const high = Number(price.highestPrice);
|
||||||
|
const market = Number(price.marketPrice);
|
||||||
|
|
||||||
|
if (!low || !high || !market || market <= 0) {
|
||||||
|
volatilityByCondition[condition] = { label: '—', spread: 0 };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spread = (high - low) / market;
|
||||||
|
|
||||||
|
const label = spread >= 0.50 ? 'High'
|
||||||
|
: spread >= 0.25 ? 'Medium'
|
||||||
|
: 'Low';
|
||||||
|
|
||||||
|
volatilityByCondition[condition] = { label, spread: Math.round(spread * 100) / 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Price history for chart ───────────────────────────────────────────────
|
||||||
const cardSkus = card?.prices?.length
|
const cardSkus = card?.prices?.length
|
||||||
? await db.select().from(skus).where(eq(skus.cardId, cardId))
|
? await db.select().from(skus).where(eq(skus.cardId, card.cardId))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const skuIds = cardSkus.map(s => s.skuId);
|
const skuIds = cardSkus.map(s => s.skuId);
|
||||||
@@ -76,41 +103,6 @@ const historyRows = skuIds.length
|
|||||||
.orderBy(priceHistory.calculatedAt)
|
.orderBy(priceHistory.calculatedAt)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Rolling 30-day cutoff for volatility calculation
|
|
||||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000);
|
|
||||||
|
|
||||||
const byCondition: Record<string, number[]> = {};
|
|
||||||
for (const row of historyRows) {
|
|
||||||
if (row.marketPrice == null) continue;
|
|
||||||
if (!row.calculatedAt) continue;
|
|
||||||
if (new Date(row.calculatedAt) < thirtyDaysAgo) continue;
|
|
||||||
const price = Number(row.marketPrice);
|
|
||||||
if (price <= 0) continue;
|
|
||||||
if (!byCondition[row.condition]) byCondition[row.condition] = [];
|
|
||||||
byCondition[row.condition].push(price);
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeVolatility(prices: number[]): { label: string; monthlyVol: number } {
|
|
||||||
if (prices.length < 2) return { label: '—', monthlyVol: 0 };
|
|
||||||
const returns: number[] = [];
|
|
||||||
for (let i = 1; i < prices.length; i++) {
|
|
||||||
returns.push(Math.log(prices[i] / prices[i - 1]));
|
|
||||||
}
|
|
||||||
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
|
|
||||||
const variance = returns.reduce((sum, r) => sum + (r - mean) ** 2, 0) / (returns.length - 1);
|
|
||||||
const monthlyVol = Math.sqrt(variance) * Math.sqrt(30);
|
|
||||||
const label = monthlyVol >= 0.30 ? 'High'
|
|
||||||
: monthlyVol >= 0.15 ? 'Medium'
|
|
||||||
: 'Low';
|
|
||||||
return { label, monthlyVol: Math.round(monthlyVol * 100) / 100 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const volatilityByCondition: Record<string, { label: string; monthlyVol: number }> = {};
|
|
||||||
for (const [condition, prices] of Object.entries(byCondition)) {
|
|
||||||
volatilityByCondition[condition] = computeVolatility(prices);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Price history for chart (full history, not windowed) ──────────────────
|
|
||||||
const priceHistoryForChart = historyRows.map(row => ({
|
const priceHistoryForChart = historyRows.map(row => ({
|
||||||
condition: row.condition,
|
condition: row.condition,
|
||||||
calculatedAt: row.calculatedAt
|
calculatedAt: row.calculatedAt
|
||||||
@@ -119,29 +111,11 @@ const priceHistoryForChart = historyRows.map(row => ({
|
|||||||
marketPrice: row.marketPrice,
|
marketPrice: row.marketPrice,
|
||||||
})).filter(r => r.calculatedAt !== null);
|
})).filter(r => r.calculatedAt !== null);
|
||||||
|
|
||||||
// ── Determine which range buttons to show ────────────────────────────────
|
|
||||||
const now = Date.now();
|
|
||||||
const oldestDate = historyRows.length
|
|
||||||
? Math.min(...historyRows
|
|
||||||
.filter(r => r.calculatedAt)
|
|
||||||
.map(r => new Date(r.calculatedAt!).getTime()))
|
|
||||||
: now;
|
|
||||||
|
|
||||||
const dataSpanDays = (now - oldestDate) / 86_400_000;
|
|
||||||
|
|
||||||
const showRanges = {
|
|
||||||
'1m': dataSpanDays >= 1,
|
|
||||||
'3m': dataSpanDays >= 60,
|
|
||||||
'6m': dataSpanDays >= 180,
|
|
||||||
'1y': dataSpanDays >= 365,
|
|
||||||
'all': dataSpanDays >= 400,
|
|
||||||
};
|
|
||||||
|
|
||||||
const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
|
const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
|
||||||
|
|
||||||
const conditionAttributes = (price: any) => {
|
const conditionAttributes = (price: any) => {
|
||||||
const condition: string = price?.condition || "Near Mint";
|
const condition: string = price?.condition || "Near Mint";
|
||||||
const vol = volatilityByCondition[condition] ?? { label: '—', monthlyVol: 0 };
|
const vol = volatilityByCondition[condition] ?? { label: '—', spread: 0 };
|
||||||
|
|
||||||
const volatilityClass = (() => {
|
const volatilityClass = (() => {
|
||||||
switch (vol.label) {
|
switch (vol.label) {
|
||||||
@@ -154,7 +128,7 @@ const conditionAttributes = (price: any) => {
|
|||||||
|
|
||||||
const volatilityDisplay = vol.label === '—'
|
const volatilityDisplay = vol.label === '—'
|
||||||
? '—'
|
? '—'
|
||||||
: `${vol.label} (${(vol.monthlyVol * 100).toFixed(0)}%)`;
|
: `${vol.label} (${(vol.spread * 100).toFixed(0)}%)`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"Near Mint": { label: "nav-nm", volatility: volatilityDisplay, volatilityClass, class: "show active" },
|
"Near Mint": { label: "nav-nm", volatility: volatilityDisplay, volatilityClass, class: "show active" },
|
||||||
@@ -177,7 +151,7 @@ for (const price of card?.prices ?? []) {
|
|||||||
const availableVariants = [...new Set(cardSkus.map(s => s.variant))].sort();
|
const availableVariants = [...new Set(cardSkus.map(s => s.variant))].sort();
|
||||||
|
|
||||||
const ebaySearchUrl = (card: any) => {
|
const ebaySearchUrl = (card: any) => {
|
||||||
return `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&LH_Sold=1&Graded=No&_dcat=183454`;
|
return `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}+${encodeURIComponent(card?.variant)}&LH_Sold=1&Graded=No&_dcat=183454`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const altSearchUrl = (card: any) => {
|
const altSearchUrl = (card: any) => {
|
||||||
@@ -205,9 +179,6 @@ const altSearchUrl = (card: any) => {
|
|||||||
<!-- Card image column -->
|
<!-- Card image column -->
|
||||||
<div class="col-sm-12 col-md-3">
|
<div class="col-sm-12 col-md-3">
|
||||||
<div class="position-relative mt-1">
|
<div class="position-relative mt-1">
|
||||||
|
|
||||||
<!-- card-image-wrap gives the modal image shimmer effects
|
|
||||||
without the hover lift/scale that image-grow has in main.scss -->
|
|
||||||
<div
|
<div
|
||||||
class="card-image-wrap rounded-4"
|
class="card-image-wrap rounded-4"
|
||||||
data-energy={card?.energyType}
|
data-energy={card?.energyType}
|
||||||
@@ -287,11 +258,11 @@ const altSearchUrl = (card: any) => {
|
|||||||
<p class="mb-0 mt-1">${price.marketPrice}</p>
|
<p class="mb-0 mt-1">${price.marketPrice}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
|
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
|
||||||
<h6 class="mb-auto">Lowest Price</h6>
|
<h6 class="mb-auto">Low Price <span class="small p text-secondary">(30 day)</span></h6>
|
||||||
<p class="mb-0 mt-1">${price.lowestPrice}</p>
|
<p class="mb-0 mt-1">${price.lowestPrice}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
|
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
|
||||||
<h6 class="mb-auto">Highest Price</h6>
|
<h6 class="mb-auto">High Price <span class="small p text-secondary">(30 day)</span></h6>
|
||||||
<p class="mb-0 mt-1">${price.highestPrice}</p>
|
<p class="mb-0 mt-1">${price.highestPrice}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class={`alert rounded p-2 flex-fill d-flex flex-column mb-0 ${attributes?.volatilityClass}`}>
|
<div class={`alert rounded p-2 flex-fill d-flex flex-column mb-0 ${attributes?.volatilityClass}`}>
|
||||||
@@ -306,14 +277,14 @@ const altSearchUrl = (card: any) => {
|
|||||||
data-bs-trigger="hover focus click"
|
data-bs-trigger="hover focus click"
|
||||||
data-bs-html="true"
|
data-bs-html="true"
|
||||||
data-bs-title={`
|
data-bs-title={`
|
||||||
<div class='tooltip-heading fw-bold mb-1'>Monthly Volatility</div>
|
<div class='tooltip-heading fw-bold mb-1'>30-Day Price Spread</div>
|
||||||
<div class='small'>
|
<div class='small'>
|
||||||
<p class="mb-1">
|
<p class="mb-1">
|
||||||
<strong>What this measures:</strong> how much the market price tends to move day-to-day,
|
<strong>What this measures:</strong> how wide the gap between the 30-day low and high is,
|
||||||
scaled up to a monthly expectation.
|
relative to the market price.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-0">
|
<p class="mb-0">
|
||||||
A card with <strong>30% volatility</strong> typically swings ±30% over a month.
|
A card with <strong>50%+ spread</strong> has seen significant price swings over the past month.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
@@ -502,7 +473,7 @@ const altSearchUrl = (card: any) => {
|
|||||||
|
|
||||||
</datalist>
|
</datalist>
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
Type a name or pick an existing catalog.
|
Enter a name or pick an existing catalog.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -566,16 +537,18 @@ const altSearchUrl = (card: any) => {
|
|||||||
</canvas>
|
</canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end mt-2" role="group" aria-label="Time range">
|
<div class="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end mt-2" role="group" aria-label="Time range">
|
||||||
{showRanges['1m'] && <button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button>}
|
<button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button>
|
||||||
{showRanges['3m'] && <button type="button" class="btn btn-dark price-range-btn" data-range="3m">3M</button>}
|
<button type="button" class="btn btn-dark price-range-btn" data-range="3m">3M</button>
|
||||||
{showRanges['6m'] && <button type="button" class="btn btn-dark price-range-btn" data-range="6m">6M</button>}
|
<button type="button" class="btn btn-dark price-range-btn" data-range="6m">6M</button>
|
||||||
{showRanges['1y'] && <button type="button" class="btn btn-dark price-range-btn" data-range="1y">1Y</button>}
|
<button type="button" class="btn btn-dark price-range-btn" data-range="1y">1Y</button>
|
||||||
{showRanges['all'] && <button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>}
|
<button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- External links column -->
|
<!-- External links column -->
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export const prerender = false;
|
|||||||
import * as util from 'util';
|
import * as util from 'util';
|
||||||
|
|
||||||
// auth check for inventory management features
|
// auth check for inventory management features
|
||||||
//const { canAddInventory } = Astro.locals;
|
const { canAddInventory } = Astro.locals;
|
||||||
const canAddInventory = false;
|
// const canAddInventory = false;
|
||||||
|
|
||||||
// all the facet fields we want to use for filtering
|
// all the facet fields we want to use for filtering
|
||||||
const facetFields:any = {
|
const facetFields:any = {
|
||||||
@@ -22,14 +22,14 @@ const facetFields:any = {
|
|||||||
|
|
||||||
// ── Allowed sort values ───────────────────────────────────────────────────
|
// ── Allowed sort values ───────────────────────────────────────────────────
|
||||||
const sortMap: Record<string, string> = {
|
const sortMap: Record<string, string> = {
|
||||||
'releaseDate:desc,number:asc': '_text_match:asc,releaseDate:desc,number:asc',
|
'releaseDate:desc,number:asc': '_text_match:asc,releaseDate:desc,inumber(missing_values:last):asc',
|
||||||
'releaseDate:asc,number:asc': '_text_match:asc,releaseDate:asc,number:asc',
|
'releaseDate:asc,number:asc': '_text_match:asc,releaseDate:asc,inumber(missing_values:last):asc',
|
||||||
'marketPrice:desc': 'marketPrice:desc,releaseDate:desc,number:asc',
|
'marketPrice:desc': 'marketPrice:desc,releaseDate:desc,inumber(missing_values:last):asc',
|
||||||
'marketPrice:asc': 'marketPrice:asc,releaseDate:desc,number:asc',
|
'marketPrice:asc': 'marketPrice:asc,releaseDate:desc,inumber(missing_values:last):asc',
|
||||||
'number:asc': '_text_match:asc,number:asc',
|
'number:asc': '_text_match:asc,inumber(missing_values:last):asc',
|
||||||
'number:desc': '_text_match:asc,number:desc',
|
'number:desc': '_text_match:asc,inumber(missing_values:last):desc',
|
||||||
};
|
};
|
||||||
const DEFAULT_SORT = '_text_match:asc,releaseDate:desc,number:asc';
|
const DEFAULT_SORT = '_text_match:asc,releaseDate:desc,inumber(missing_values:last):asc';
|
||||||
|
|
||||||
// get the query from post request using form data
|
// get the query from post request using form data
|
||||||
const formData = await Astro.request.formData();
|
const formData = await Astro.request.formData();
|
||||||
@@ -51,15 +51,53 @@ const languageFilter = language === 'en' ? " && productLineName:=`Pokemon`"
|
|||||||
// synonyms alone (e.g. terms that need to match across multiple set names)
|
// synonyms alone (e.g. terms that need to match across multiple set names)
|
||||||
// and rewrites them into a direct filter, clearing the query so it doesn't
|
// and rewrites them into a direct filter, clearing the query so it doesn't
|
||||||
// also try to text-match against card names.
|
// also try to text-match against card names.
|
||||||
const EREADER_SETS = ['Expedition Base Set', 'Aquapolis', 'Skyridge', 'Battle-e'];
|
|
||||||
const EREADER_RE = /^(e-?reader|e reader)$/i;
|
const ALIAS_FILTERS = [
|
||||||
|
// ── Era / set groupings ───────────────────────────────────────────────
|
||||||
|
{ re: /^(e-?reader|e reader)$/i, field: 'setName',
|
||||||
|
values: ['Expedition Base Set', 'Aquapolis', 'Skyridge', 'Battle-e'] },
|
||||||
|
|
||||||
|
{ re: /^neo$/i, field: 'setName',
|
||||||
|
values: ['Neo Genesis', 'Neo Discovery', 'Neo Revelation', 'Neo Destiny'] },
|
||||||
|
|
||||||
|
{ re: /^(wotc|wizards)$/i, field: 'setName',
|
||||||
|
values: ['Base Set', 'Jungle', 'Fossil', 'Base Set 2', 'Team Rocket',
|
||||||
|
'Gym Heroes', 'Gym Challenge', 'Neo Genesis', 'Neo Discovery',
|
||||||
|
'Neo Revelation', 'Neo Destiny', 'Expedition Base Set',
|
||||||
|
'Aquapolis', 'Skyridge', 'Battle-e'] },
|
||||||
|
|
||||||
|
{ re: /^(sun\s*(&|and)\s*moon|s(&|and)m|sm)$/i, field: 'setName',
|
||||||
|
values: ['Sun & Moon', 'Guardians Rising', 'Burning Shadows', 'Crimson Invasion',
|
||||||
|
'Ultra Prism', 'Forbidden Light', 'Celestial Storm', 'Dragon Majesty',
|
||||||
|
'Lost Thunder', 'Team Up', 'Unbroken Bonds', 'Unified Minds',
|
||||||
|
'Hidden Fates', 'Cosmic Eclipse', 'Detective Pikachu'] },
|
||||||
|
|
||||||
|
{ re: /^(sword\s*(&|and)\s*shield|s(&|and)s|swsh)$/i, field: 'setName',
|
||||||
|
values: ['Sword & Shield', 'Rebel Clash', 'Darkness Ablaze', 'Vivid Voltage',
|
||||||
|
'Battle Styles', 'Chilling Reign', 'Evolving Skies', 'Fusion Strike',
|
||||||
|
'Brilliant Stars', 'Astral Radiance', 'Pokemon GO', 'Lost Origin',
|
||||||
|
'Silver Tempest', 'Crown Zenith'] },
|
||||||
|
|
||||||
|
// ── Card type shorthands ──────────────────────────────────────────────
|
||||||
|
{ re: /^trainers?$/i, field: 'cardType', values: ['Trainer'] },
|
||||||
|
{ re: /^supporters?$/i, field: 'cardType', values: ['Supporter'] },
|
||||||
|
{ re: /^stadiums?$/i, field: 'cardType', values: ['Stadium'] },
|
||||||
|
{ re: /^items?$/i, field: 'cardType', values: ['Item'] },
|
||||||
|
{ re: /^(energys?|energies)$/i, field: 'cardType', values: ['Energy'] },
|
||||||
|
|
||||||
|
// ── Rarity shorthands ─────────────────────────────────────────────────
|
||||||
|
{ re: /^promos?$/i, field: 'rarityName', values: ['Promo'] },
|
||||||
|
];
|
||||||
|
|
||||||
let resolvedQuery = query;
|
let resolvedQuery = query;
|
||||||
let queryFilter = '';
|
let queryFilter = '';
|
||||||
|
|
||||||
if (EREADER_RE.test(query.trim())) {
|
for (const alias of ALIAS_FILTERS) {
|
||||||
resolvedQuery = '';
|
if (alias.re.test(query.trim())) {
|
||||||
queryFilter = `setName:=[${EREADER_SETS.map(s => '`' + s + '`').join(',')}]`;
|
resolvedQuery = '';
|
||||||
|
queryFilter = `${alias.field}:=[${alias.values.map(s => '`' + s + '`').join(',')}]`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = Array.from(formData.entries())
|
const filters = Array.from(formData.entries())
|
||||||
@@ -91,15 +129,15 @@ const facetFilter = (facet:string) => {
|
|||||||
|
|
||||||
|
|
||||||
// primary search values (for cards)
|
// primary search values (for cards)
|
||||||
let searchArray = [{
|
// Note: no `$skus(...)` join here — see the sku fetch below for why.
|
||||||
|
let searchArray: any[] = [{
|
||||||
collection: 'cards',
|
collection: 'cards',
|
||||||
filter_by: `$skus(id:*) && sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${filterBy ? ` && ${filterBy}` : ''}`,
|
filter_by: `sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${filterBy ? ` && ${filterBy}` : ''}`,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
facet_by: '',
|
facet_by: '',
|
||||||
max_facet_values: 0,
|
max_facet_values: 0,
|
||||||
page: Math.floor(start / 20) + 1,
|
page: Math.floor(start / 20) + 1,
|
||||||
sort_by: resolvedSort,
|
sort_by: resolvedSort,
|
||||||
include_fields: '$skus(*)',
|
|
||||||
}];
|
}];
|
||||||
|
|
||||||
// on first load (start === 0) we want to get the facets for the filters
|
// on first load (start === 0) we want to get the facets for the filters
|
||||||
@@ -121,7 +159,10 @@ if (start === 0) {
|
|||||||
const searchRequests = { searches: searchArray };
|
const searchRequests = { searches: searchArray };
|
||||||
const commonSearchParams = {
|
const commonSearchParams = {
|
||||||
q: resolvedQuery,
|
q: resolvedQuery,
|
||||||
query_by: 'content,setName,productLineName,rarityName,energyType,cardType'
|
query_by: 'content,setName,setCode,productName,Artist',
|
||||||
|
query_by_weights: '10,6,8,9,8',
|
||||||
|
num_typos: '2,1,0,1,2',
|
||||||
|
prefix: 'true,true,false,false,false',
|
||||||
};
|
};
|
||||||
|
|
||||||
// use typesense to search for cards matching the query and return the productIds of the results
|
// use typesense to search for cards matching the query and return the productIds of the results
|
||||||
@@ -131,6 +172,28 @@ const cardResults = searchResults.results[0] as any;
|
|||||||
const pokemon = cardResults.hits?.map((hit: any) => hit.document) ?? [];
|
const pokemon = cardResults.hits?.map((hit: any) => hit.document) ?? [];
|
||||||
const totalHits = cardResults?.found;
|
const totalHits = cardResults?.found;
|
||||||
|
|
||||||
|
// Skus aren't used for searching or sorting — they only supply the per-condition
|
||||||
|
// prices displayed on each card. Joining them into the primary search via
|
||||||
|
// `$skus(id:*)` forces Typesense to materialize the reverse join across the whole
|
||||||
|
// filtered result set before pagination (~20x slower). Instead, fetch skus for
|
||||||
|
// just the visible cards in one direct, indexed filter and attach them by id.
|
||||||
|
if (pokemon.length > 0) {
|
||||||
|
const cardIds = pokemon.map((c: any) => c.id);
|
||||||
|
const skuSearch = await client.collections('skus').documents().search({
|
||||||
|
q: '*',
|
||||||
|
query_by: 'condition',
|
||||||
|
filter_by: `card_id:=[${cardIds.map((id: string) => '`' + id + '`').join(',')}]`,
|
||||||
|
per_page: 250,
|
||||||
|
include_fields: 'condition,marketPrice,card_id',
|
||||||
|
});
|
||||||
|
const skusByCard: Record<string, any[]> = {};
|
||||||
|
for (const hit of (skuSearch.hits ?? []) as any[]) {
|
||||||
|
const sku = hit.document;
|
||||||
|
(skusByCard[sku.card_id] ??= []).push(sku);
|
||||||
|
}
|
||||||
|
for (const card of pokemon) card.skus = skusByCard[card.id] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// format price to 2 decimal places (or 0 if price >=100) and adds a $ sign, if the price is null it returns "–"
|
// format price to 2 decimal places (or 0 if price >=100) and adds a $ sign, if the price is null it returns "–"
|
||||||
const formatPrice = (condition:string, skus: any) => {
|
const formatPrice = (condition:string, skus: any) => {
|
||||||
@@ -280,20 +343,20 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
{pokemon.length === 0 && (
|
{pokemon.length === 0 && (
|
||||||
<div id="notfound" hx-swap-oob="true">
|
<div id="notfound" class="mt-4 h6" hx-swap-oob="true">
|
||||||
Pokemon not found
|
No cards found! Please modify your search and try again.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pokemon.map((card:any) => (
|
{pokemon.map((card:any) => (
|
||||||
<div class="col">
|
<div class="col equal-height-col">
|
||||||
{canAddInventory && (
|
{canAddInventory && (
|
||||||
<button type="button" class="btn btn-sm inventory-button position-relative float-end shadow-filter text-center p-2 fw-bold" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="event.stopPropagation(); sessionStorage.setItem('openModalTab', 'nav-vendor');">
|
<button type="button" class="btn btn-sm inventory-button position-relative float-end shadow-filter text-center p-2 fw-bold" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="event.stopPropagation(); sessionStorage.setItem('openModalTab', 'nav-vendor');">
|
||||||
+/–
|
+/–
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div class="card-trigger position-relative" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});">
|
<div class="card-trigger position-relative" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});">
|
||||||
<div class="image-grow rounded-4 card-image" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}><img src={`/static/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 w-100" onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow')?.setAttribute('data-default','true')"/><span class="position-absolute top-50 start-0 d-inline medium-icon"><FirstEditionIcon edition={card?.variant} /></span>
|
<div class="image-grow rounded-4 card-image" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}><img src={`/static/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 w-100 h-100" onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow')?.setAttribute('data-default','true')"/><span class="position-absolute top-50 start-0 d-inline medium-icon"><FirstEditionIcon edition={card?.variant} /></span>
|
||||||
<div class="holo-shine"></div>
|
<div class="holo-shine"></div>
|
||||||
<div class="holo-glare"></div>
|
<div class="holo-glare"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,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="fs-5 fw-semibold my-0">{card.productName}</div>
|
||||||
<div class="d-flex flex-row lh-1 mt-1 justify-content-between">
|
<div class="d-flex flex-row lh-1 mt-1 justify-content-between">
|
||||||
<div class="text-body-tertiary flex-grow-1 d-none d-lg-flex fst-normal">{card.setName}</div>
|
<div class="text-body-tertiary flex-grow-1 d-none d-lg-flex fst-normal">{card.setName}</div>
|
||||||
|
<div class="text-body-tertiary flex-grow-1 d-flex d-lg-none fst-normal">{card.setCode}</div>
|
||||||
<div class="text-body-tertiary fst-normal">{card.number}</div>
|
<div class="text-body-tertiary fst-normal">{card.number}</div>
|
||||||
<span class="ps-2 small-icon"><RarityIcon rarity={card.rarityName} /></span>
|
<span class="ps-2 small-icon"><RarityIcon rarity={card.rarityName} /></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-secondary fst-italic">{card.variant}</div><span class="d-none">{card.productId}</span>
|
<div class="text-secondary fst-italic">{card.variant}</div><span class="d-none">{card.productId}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
))}
|
))}
|
||||||
{start + 20 < totalHits &&
|
{start + 20 < totalHits &&
|
||||||
<div hx-post="/partials/cards" hx-trigger="revealed" hx-include="#searchform" hx-target="#cardGrid" hx-swap="beforeend" hx-on--after-request="afterUpdate(event)">
|
<div hx-post="/partials/cards" hx-trigger="revealed" hx-include="#searchform" hx-target="#cardGrid" hx-swap="beforeend" hx-on--after-request="afterUpdate(event)">
|
||||||
|
|||||||
@@ -11,9 +11,16 @@ import { db } from '../../db';
|
|||||||
const formData = await Astro.request.formData();
|
const formData = await Astro.request.formData();
|
||||||
const query = formData.get('q')?.toString() || '';
|
const query = formData.get('q')?.toString() || '';
|
||||||
const start = Number(formData.get('start')?.toString() || '0');
|
const start = Number(formData.get('start')?.toString() || '0');
|
||||||
|
const catalog = formData.get('catalog')?.toString() || '';
|
||||||
|
|
||||||
const { userId } = Astro.locals.auth();
|
const { userId } = Astro.locals.auth();
|
||||||
|
|
||||||
|
// scope results to the selected catalog (empty / "all" => every catalog)
|
||||||
|
let filterBy = `userId:=${userId}`;
|
||||||
|
if (catalog && catalog !== 'all') {
|
||||||
|
filterBy += ` && catalogName:=\`${catalog}\``;
|
||||||
|
}
|
||||||
|
|
||||||
const InventoryDetails = async (inventoryId: string) => {
|
const InventoryDetails = async (inventoryId: string) => {
|
||||||
//console.log('inventoryid', inventoryId);
|
//console.log('inventoryid', inventoryId);
|
||||||
const details = await db.query.inventory.findFirst({
|
const details = await db.query.inventory.findFirst({
|
||||||
@@ -27,7 +34,7 @@ const InventoryDetails = async (inventoryId: string) => {
|
|||||||
// primary search values (for cards)
|
// primary search values (for cards)
|
||||||
let searchArray = [{
|
let searchArray = [{
|
||||||
collection: 'inventories',
|
collection: 'inventories',
|
||||||
filter_by: `userId:=${userId}`,
|
filter_by: filterBy,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
facet_by: '',
|
facet_by: '',
|
||||||
max_facet_values: 0,
|
max_facet_values: 0,
|
||||||
@@ -90,7 +97,7 @@ console.log(`totalHits: ${totalHits}`);
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="col equal-height-col">
|
<div class="col equal-height-col">
|
||||||
<div class="card-trigger position-relative inv-grid-media" data-card-id={card.productId} data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName} data-bs-toggle="modal" data-bs-target="#cardModal">
|
<div class="card-trigger position-relative inv-grid-media" data-inventory-id={inventory.inventoryId} data-card-id={card.productId} data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName} data-bs-toggle="modal" data-bs-target="#inventoryModal">
|
||||||
<div class="rounded-4 card-image h-100">
|
<div class="rounded-4 card-image h-100">
|
||||||
<img src={`static/cards/${card.productId}.jpg`} alt={card.productName} loading="lazy" decoding="async" class="img-fluid rounded-4 w-100" onerror="this.onerror=null;this.src='static/cards/default.jpg';this.closest('.image-grow')?.setAttribute('data-default','true')" />
|
<img src={`static/cards/${card.productId}.jpg`} alt={card.productName} loading="lazy" decoding="async" class="img-fluid rounded-4 w-100" onerror="this.onerror=null;this.src='static/cards/default.jpg';this.closest('.image-grow')?.setAttribute('data-default','true')" />
|
||||||
<span class="position-absolute top-50 start-0 d-inline medium-icon" style="z-index:4">
|
<span class="position-absolute top-50 start-0 d-inline medium-icon" style="z-index:4">
|
||||||
@@ -98,18 +105,6 @@ console.log(`totalHits: ${totalHits}`);
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-row justify-content-between my-1 align-items-center edit-bar">
|
|
||||||
<input type="number" class="form-control form-control-sm text-center" style="max-width: 33%;" value={inventory.quantity} min="1" max="999" aria-label="Quantity input" aria-describedby="button-minus button-plus">
|
|
||||||
<div class="" aria-label="Edit controls">
|
|
||||||
<button type="button" class="btn btn-sm btn-edit me-2"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" class="edit-svg"><path d="M374.4 146.5L374.4 146.5C395.3 128.4 443.5 85.4 468.5 97.6C484.4 105.9 497.8 112.8 501.3 120.1L501.4 120.2L501.5 120.3L501.6 120.5C507.6 130.2 518.4 150.1 519.6 161.6C513.5 192.1 484 217.1 461.6 240.4C425.7 208.6 404.1 187.1 369.7 150.6C371.3 149.2 372.8 147.9 374.4 146.5zM484.1 307.3C484.3 307.1 484.5 307 484.6 306.8C525.1 265.7 579.4 226.1 583.7 161.7C575.2 64.5 475.6-2.1 395.9 50.5C299.2 114.8 235.3 194.9 152 272.8C118.1 305.6 72.6 334.1 62.3 384.2C60.7 400.9 62.5 429.9 63.3 446.8C63.3 472.2 62.6 516.6 62.3 535.2C65.6 607.8 181.9 562 225.7 560.1C249.2 555.4 267.7 533 280.2 519.6C280.4 519.4 280.5 519.3 280.5 519.3C350.4 450.8 416.9 378.7 484.2 307.4zM416.2 285.7C349.4 358.2 282.1 428.8 211.2 497.7C182.1 502.9 154.5 507.3 126.6 510C128.2 471.4 125.7 426.3 125.8 391.4C138.8 367.7 172.1 340.8 194.2 320.8C239.9 279.4 276.7 234.2 323.2 194.7C356.5 230 380.6 254.2 416.1 285.7z"/></svg></button>
|
|
||||||
|
|
||||||
<button type="button" class="btn btn-sm btn-delete"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" class="delete-svg">
|
|
||||||
<filter id='shadow' color-interpolation-filters="sRGB">
|
|
||||||
<feDropShadow dx="2" dy="2" stdDeviation="2" flood-opacity="0.5"/>
|
|
||||||
</filter>
|
|
||||||
<path d="M345.1 124.8C361 125.2 374.2 126 386.3 127.8C386.2 137.1 385.4 148.3 384.6 157.9C340.9 156.9 297.8 157.6 254.5 158C254.8 149.7 252.4 136.7 255.2 129.7C279.3 123.4 313.6 125.1 345.1 124.7zM448.7 160.7C460.3 66.9 431.8 64.5 346.7 60.8C268.3 59.6 178 54.7 190.5 158.2C157.2 162.7 96.1 137.9 89.6 186.5C88.1 210.7 111.2 222.9 132.8 220.8L132.8 478.6C132.6 530.6 176.7 571.9 228.6 569.3L398.9 569.3C500.4 574.1 515.3 476.9 509.7 395.2C509.1 364.1 511.4 273.6 512.5 223.8C554.7 223.7 554.5 159.5 512.2 159.8L448.6 160.7zM448.5 224.7C446.1 301.9 445.5 378 445.8 440.3C442.6 476.1 442.9 507.5 400.1 505.2L227 505.2C211.1 506.8 196.6 494.9 196.7 478.5L196.7 222.1C280.4 222.3 366.5 219.1 448.4 224.6z"/></svg></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex flex-row mt-1">
|
<div class="d-flex flex-row mt-1">
|
||||||
<div class="text-body-tertiary flex-grow-1 small">{card.setName}</div>
|
<div class="text-body-tertiary flex-grow-1 small">{card.setName}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,4 +137,16 @@ console.log(`totalHits: ${totalHits}`);
|
|||||||
</div>
|
</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",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"include": [".astro/types.d.ts", "src/**/*"],
|
"include": [".astro/types.d.ts", "src/**/*", "scripts/**/*"],
|
||||||
"exclude": ["dist"]
|
"exclude": ["dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user