Compare commits
28 Commits
feat/csv-p
...
5d716a4d8e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d716a4d8e | ||
|
|
d06c6fb9bf | ||
|
|
404355304c | ||
|
|
87235ab37a | ||
| 12a42b87b8 | |||
| 03394d81e8 | |||
| 86da8a91ad | |||
| 66290fcb97 | |||
| 7a1b7eb5fe | |||
| 3be17fe84c | |||
| 38f041d86f | |||
|
|
b65e2a2859 | ||
|
|
91823174d2 | ||
|
|
943bd33c9a | ||
|
|
9975db20cb | ||
|
|
db12844dea | ||
| 3f9b1accda | |||
| 03e606e152 | |||
| b871385fba | |||
| 4c6922f76b | |||
| 171ce294f4 | |||
|
|
023cd87319 | ||
|
|
04ea65eeeb | ||
|
|
9d9524e654 | ||
|
|
bc99be51ea | ||
|
|
b06e24d382 | ||
|
|
7b4e06733f | ||
|
|
f72d479c1d |
3
.gitignore
vendored
@@ -26,6 +26,9 @@ pnpm-debug.log*
|
||||
# imges from tcgplayer
|
||||
public/cards/*
|
||||
|
||||
# static assets
|
||||
/static/
|
||||
|
||||
# anything test
|
||||
test.*
|
||||
|
||||
|
||||
58
CLAUDE.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Pokemon TCG card database and inventory management app. Users search cards, track market prices, and manage their collections. Closed beta.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server (binds 0.0.0.0:4321)
|
||||
npm run build # Production build → ./dist/
|
||||
npm run preview # Preview production build
|
||||
```
|
||||
|
||||
No test framework or linter is configured.
|
||||
|
||||
Utility scripts in `scripts/` are run directly with `tsx` (e.g., `npx tsx scripts/reindex.ts`).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Astro 5** (SSR mode, Node.js standalone adapter)
|
||||
- **PostgreSQL** via Drizzle ORM (schema in `pokemon` namespace, snake_case DB columns)
|
||||
- **Typesense** for full-text card search
|
||||
- **Clerk** for authentication
|
||||
- **HTMX** for dynamic interactions (no SPA framework)
|
||||
- **Bootstrap 5** with custom SCSS overrides, dark theme
|
||||
- **Chart.js** for price history charts
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow
|
||||
|
||||
TCGPlayer source data → `tcg_cards` → denormalized `cards` (per variant) → `skus` (per condition/language) → `price_history` / `sales_history`. User collections stored in `inventory` table linked to Clerk userId.
|
||||
|
||||
PostgreSQL is source of truth. Typesense mirrors card/sku/inventory data for search. Both must be kept in sync — see `src/pages/api/inventory.ts` for the sync pattern (write to PG, then upsert/delete in Typesense).
|
||||
|
||||
### Key Directories
|
||||
|
||||
- `src/pages/` — Astro routes and API endpoints
|
||||
- `src/pages/partials/` — HTMX partial responses (HTML fragments returned to `hx-post` targets)
|
||||
- `src/pages/api/` — JSON/file API endpoints (`upload.ts` for CSV, `inventory.ts` for CRUD)
|
||||
- `src/components/` — Reusable `.astro` components
|
||||
- `src/db/` — Drizzle schema (`schema.ts`), relations (`relations.ts`), DB connection (`index.ts`), Typesense client (`typesense.ts`)
|
||||
- `scripts/` — Data ingestion and indexing utilities (not part of the app runtime)
|
||||
|
||||
### Authentication
|
||||
|
||||
Clerk middleware in `src/middleware.ts` protects routes via `createRouteMatcher`. Auth context accessed via `Astro.locals.auth()` in pages/API routes.
|
||||
|
||||
### Database Schema
|
||||
|
||||
Drizzle config uses `casing: 'snake_case'` — define schema fields in camelCase, they map to snake_case columns automatically. Migrations live in `./drizzle/`. Schema is scoped to the `pokemon` PostgreSQL schema.
|
||||
|
||||
### Frontend Patterns
|
||||
|
||||
Pages use HTMX for interactivity — forms POST to `/partials/*` endpoints that return HTML fragments. No client-side routing. View Transitions API enabled for page navigation animations. Card modals and inventory forms are HTMX-driven with `hx-post`, `hx-target`, and `hx-swap` attributes.
|
||||
@@ -18,5 +18,17 @@ export default defineConfig({
|
||||
output: "server",
|
||||
security: {
|
||||
checkOrigin: false
|
||||
}
|
||||
},
|
||||
vite: {
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
// Silences deprecation warnings from dependencies
|
||||
quietDeps: true,
|
||||
// Specifically silence color function warnings
|
||||
silenceDeprecations: ['color-functions', 'import','global-builtin'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
1
package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@clerk/astro": "^3.0.1",
|
||||
"@clerk/shared": "^4.0.0",
|
||||
"@clerk/themes": "^2.4.55",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"astro": "^5.17.1",
|
||||
"bootstrap": "^5.3.8",
|
||||
"chalk": "^5.6.2",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@clerk/astro": "^3.0.1",
|
||||
"@clerk/shared": "^4.0.0",
|
||||
"@clerk/themes": "^2.4.55",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"astro": "^5.17.1",
|
||||
"bootstrap": "^5.3.8",
|
||||
"chalk": "^5.6.2",
|
||||
|
||||
BIN
public/holofoils/ancient.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/holofoils/angular.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/holofoils/cosmos-bottom-trans.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
public/holofoils/cosmos-bottom.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
public/holofoils/cosmos-middle-trans.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
public/holofoils/cosmos-middle.gif
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
public/holofoils/cosmos-middle.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
public/holofoils/cosmos-top-trans.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/holofoils/cosmos-top.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/holofoils/cosmos.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/holofoils/crossover.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/holofoils/galaxy-source.png
Normal file
|
After Width: | Height: | Size: 561 KiB |
BIN
public/holofoils/galaxy.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
public/holofoils/geometric.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/holofoils/glitter.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
public/holofoils/grain.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
public/holofoils/illusion-mask.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
public/holofoils/illusion.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/holofoils/illusion2.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/holofoils/metal.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/holofoils/rainbow.jpg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/holofoils/stylish.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/holofoils/stylish2.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/holofoils/trainerbg.jpg
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
public/holofoils/trainerbg.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/holofoils/vmaxbg.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
public/holofoils/wave.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
@@ -1,104 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
import { client } from '../src/db/typesense.ts';
|
||||
import type { DBInstance } from '../src/db/index.ts';
|
||||
|
||||
const DollarToInt = (dollar: any) => {
|
||||
if (dollar === null) return null;
|
||||
return Math.round(dollar * 100);
|
||||
}
|
||||
|
||||
|
||||
// Delete and recreate the 'cards' index
|
||||
export const createCardCollection = async () => {
|
||||
try {
|
||||
await client.collections('cards').delete();
|
||||
} catch (error) {
|
||||
// Ignore error, just means collection doesn't exist
|
||||
}
|
||||
await client.collections().create({
|
||||
name: 'cards',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string' },
|
||||
{ name: 'cardId', type: 'int32' },
|
||||
{ name: 'productId', type: 'int32' },
|
||||
{ name: 'variant', type: 'string', facet: true },
|
||||
{ name: 'productName', type: 'string' },
|
||||
{ name: 'productLineName', type: 'string', facet: true },
|
||||
{ name: 'rarityName', type: 'string', facet: true },
|
||||
{ name: 'setName', type: 'string', facet: true },
|
||||
{ name: 'cardType', type: 'string', facet: true },
|
||||
{ name: 'energyType', type: 'string', facet: true },
|
||||
{ name: 'number', type: 'string', sort: true },
|
||||
{ name: 'Artist', type: 'string' },
|
||||
{ name: 'sealed', type: 'bool' },
|
||||
{ name: 'releaseDate', type: 'int32' },
|
||||
{ name: 'marketPrice', type: 'int32', optional: true, sort: true },
|
||||
{ name: 'content', type: 'string', token_separators: ['/'] },
|
||||
{ name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
|
||||
],
|
||||
});
|
||||
console.log(chalk.green('Collection "cards" created successfully.'));
|
||||
}
|
||||
|
||||
// Delete and recreate the 'skus' index
|
||||
export const createSkuCollection = async () => {
|
||||
try {
|
||||
await client.collections('skus').delete();
|
||||
} catch (error) {
|
||||
// Ignore error, just means collection doesn't exist
|
||||
}
|
||||
await client.collections().create({
|
||||
name: 'skus',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string' },
|
||||
{ name: 'condition', type: 'string' },
|
||||
{ name: 'highestPrice', type: 'int32', optional: true },
|
||||
{ name: 'lowestPrice', type: 'int32', optional: true },
|
||||
{ name: 'marketPrice', type: 'int32', optional: true },
|
||||
]
|
||||
});
|
||||
console.log(chalk.green('Collection "skus" created successfully.'));
|
||||
}
|
||||
|
||||
|
||||
export const upsertCardCollection = async (db:DBInstance) => {
|
||||
const pokemon = await db.query.cards.findMany({
|
||||
with: { set: true, tcgdata: true, prices: true },
|
||||
});
|
||||
await client.collections('cards').documents().import(pokemon.map(card => {
|
||||
const marketPrice = card.tcgdata?.marketPrice ? DollarToInt(card.tcgdata.marketPrice) : null;
|
||||
|
||||
return {
|
||||
id: card.cardId.toString(),
|
||||
cardId: card.cardId,
|
||||
productId: card.productId,
|
||||
variant: card.variant,
|
||||
productName: card.productName,
|
||||
productLineName: card.productLineName,
|
||||
rarityName: card.rarityName,
|
||||
setName: card.set?.setName || "",
|
||||
cardType: card.cardType || "",
|
||||
energyType: card.energyType || "",
|
||||
number: card.number,
|
||||
Artist: card.artist || "",
|
||||
sealed: card.sealed,
|
||||
content: [card.productName, card.productLineName, card.set?.setName || "", card.number, card.rarityName, card.artist || ""].join(' '),
|
||||
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
|
||||
...(marketPrice !== null && { marketPrice }),
|
||||
sku_id: card.prices.map(price => price.skuId.toString())
|
||||
};
|
||||
}), { action: 'upsert' });
|
||||
console.log(chalk.green('Collection "cards" indexed successfully.'));
|
||||
}
|
||||
|
||||
export const upsertSkuCollection = async (db:DBInstance) => {
|
||||
const skus = await db.query.skus.findMany();
|
||||
await client.collections('skus').documents().import(skus.map(sku => ({
|
||||
id: sku.skuId.toString(),
|
||||
condition: sku.condition,
|
||||
highestPrice: DollarToInt(sku.highestPrice),
|
||||
lowestPrice: DollarToInt(sku.lowestPrice),
|
||||
marketPrice: DollarToInt(sku.marketPrice),
|
||||
})), { action: 'upsert' });
|
||||
console.log(chalk.green('Collection "skus" indexed successfully.'));
|
||||
}
|
||||
207
scripts/pokemon-helper.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import chalk from 'chalk';
|
||||
import { client } from '../src/db/typesense.ts';
|
||||
import type { DBInstance } from '../src/db/index.ts';
|
||||
import fs from "node:fs/promises";
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
|
||||
const DollarToInt = (dollar: any) => {
|
||||
if (dollar === null) return null;
|
||||
return Math.round(dollar * 100);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const Sleep = (ms: number) => {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
|
||||
export const FileExists = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
await fs.access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const GetNumberOrNull = (value: any): number | null => {
|
||||
const number = Number(value); // Attempt to convert the value to a number
|
||||
if (Number.isNaN(number)) {
|
||||
return null; // Return null if the result is NaN
|
||||
}
|
||||
return number; // Otherwise, return the number
|
||||
}
|
||||
|
||||
|
||||
// Delete and recreate the 'cards' index
|
||||
export const createCardCollection = async () => {
|
||||
try {
|
||||
await client.collections('cards').delete();
|
||||
} catch (error) {
|
||||
// Ignore error, just means collection doesn't exist
|
||||
}
|
||||
await client.collections().create({
|
||||
name: 'cards',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string' },
|
||||
{ name: 'cardId', type: 'int32' },
|
||||
{ name: 'productId', type: 'int32' },
|
||||
{ name: 'variant', type: 'string', facet: true },
|
||||
{ name: 'productName', type: 'string' },
|
||||
{ name: 'productLineName', type: 'string', facet: true },
|
||||
{ name: 'rarityName', type: 'string', facet: true },
|
||||
{ name: 'setName', type: 'string', facet: true },
|
||||
{ name: 'cardType', type: 'string', facet: true },
|
||||
{ name: 'energyType', type: 'string', facet: true },
|
||||
{ name: 'number', type: 'string', sort: true },
|
||||
{ name: 'Artist', type: 'string' },
|
||||
{ name: 'sealed', type: 'bool' },
|
||||
{ name: 'releaseDate', type: 'int32' },
|
||||
{ name: 'marketPrice', type: 'int32', optional: true, sort: true },
|
||||
{ name: 'content', type: 'string', token_separators: ['/'] },
|
||||
{ name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
|
||||
],
|
||||
});
|
||||
console.log(chalk.green('Collection "cards" created successfully.'));
|
||||
}
|
||||
|
||||
// Delete and recreate the 'skus' index
|
||||
export const createSkuCollection = async () => {
|
||||
try {
|
||||
await client.collections('skus').delete();
|
||||
} catch (error) {
|
||||
// Ignore error, just means collection doesn't exist
|
||||
}
|
||||
await client.collections().create({
|
||||
name: 'skus',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string' },
|
||||
{ name: 'condition', type: 'string' },
|
||||
{ name: 'highestPrice', type: 'int32', optional: true },
|
||||
{ name: 'lowestPrice', type: 'int32', optional: true },
|
||||
{ name: 'marketPrice', type: 'int32', optional: true },
|
||||
]
|
||||
});
|
||||
console.log(chalk.green('Collection "skus" created successfully.'));
|
||||
}
|
||||
|
||||
// Delete and recreate the 'inventory' index
|
||||
export const createInventoryCollection = async () => {
|
||||
try {
|
||||
await client.collections('inventories').delete();
|
||||
} catch (error) {
|
||||
// Ignore error, just means collection doesn't exist
|
||||
}
|
||||
await client.collections().create({
|
||||
name: 'inventories',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string' },
|
||||
{ name: 'userId', type: 'string' },
|
||||
{ name: 'catalogName', type: 'string' },
|
||||
{ name: 'card_id', type: 'string', reference: 'cards.id' },
|
||||
]
|
||||
});
|
||||
console.log(chalk.green('Collection "inventories" created successfully.'));
|
||||
}
|
||||
|
||||
|
||||
export const upsertCardCollection = async (db:DBInstance) => {
|
||||
const pokemon = await db.query.cards.findMany({
|
||||
with: { set: true, tcgdata: true, prices: true },
|
||||
});
|
||||
await client.collections('cards').documents().import(pokemon.map(card => {
|
||||
const marketPrice = card.tcgdata?.marketPrice ? DollarToInt(card.tcgdata.marketPrice) : null;
|
||||
|
||||
return {
|
||||
id: card.cardId.toString(),
|
||||
cardId: card.cardId,
|
||||
productId: card.productId,
|
||||
variant: card.variant,
|
||||
productName: card.productName,
|
||||
productLineName: card.productLineName,
|
||||
rarityName: card.rarityName,
|
||||
setName: card.set?.setName || "",
|
||||
cardType: card.cardType || "",
|
||||
energyType: card.energyType || "",
|
||||
number: card.number,
|
||||
Artist: card.artist || "",
|
||||
sealed: card.sealed,
|
||||
content: [card.productName, card.productLineName, card.set?.setName || "", card.number, card.rarityName, card.artist || ""].join(' '),
|
||||
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
|
||||
...(marketPrice !== null && { marketPrice }),
|
||||
sku_id: card.prices.map(price => price.skuId.toString())
|
||||
};
|
||||
}), { action: 'upsert' });
|
||||
console.log(chalk.green('Collection "cards" indexed successfully.'));
|
||||
}
|
||||
|
||||
export const upsertSkuCollection = async (db:DBInstance) => {
|
||||
const skus = await db.query.skus.findMany();
|
||||
await client.collections('skus').documents().import(skus.map(sku => ({
|
||||
id: sku.skuId.toString(),
|
||||
condition: sku.condition,
|
||||
highestPrice: DollarToInt(sku.highestPrice),
|
||||
lowestPrice: DollarToInt(sku.lowestPrice),
|
||||
marketPrice: DollarToInt(sku.marketPrice),
|
||||
})), { action: 'upsert' });
|
||||
console.log(chalk.green('Collection "skus" indexed successfully.'));
|
||||
}
|
||||
|
||||
export const upsertInventoryCollection = async (db:DBInstance) => {
|
||||
const inv = await db.query.inventory.findMany();
|
||||
await client.collections('inventories').documents().import(inv.map(i => ({
|
||||
id: i.inventoryId,
|
||||
userId: i.userId,
|
||||
catalogName: i.catalogName,
|
||||
card_id: i.cardId.toString(),
|
||||
})), { action: 'upsert' });
|
||||
console.log(chalk.green('Collection "inventories" indexed successfully.'));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const UpdateVariants = async (db:DBInstance) => {
|
||||
const updates = await db.execute(sql`update cards as c
|
||||
set
|
||||
product_name = a.product_name, product_line_name = a.product_line_name, product_url_name = a.product_url_name, rarity_name = a.rarity_name,
|
||||
sealed = a.sealed, set_id = a.set_id, card_type = a.card_type, energy_type = a.energy_type, number = a.number, artist = a.artist
|
||||
from (
|
||||
select t.product_id, b.variant,
|
||||
coalesce(o.product_name, regexp_replace(regexp_replace(coalesce(nullif(t.product_name, ''), t.product_url_name),' \\\\(.*\\\\)',''),' - .*$','')) as product_name,
|
||||
coalesce(o.product_line_name, t.product_line_name) as product_line_name, coalesce(o.product_url_name, t.product_url_name) as product_url_name,
|
||||
coalesce(o.rarity_name, t.rarity_name) as rarity_name, coalesce(o.sealed, t.sealed) as sealed, coalesce(o.set_id, t.set_id) as set_id,
|
||||
coalesce(o.card_type, t.card_type) as card_type, coalesce(o.energy_type, t.energy_type) as energy_type,
|
||||
coalesce(o.number, t.number) as number, coalesce(o.artist, t.artist) as artist
|
||||
from tcg_cards t
|
||||
join (select distinct product_id, variant from skus) b on t.product_id = b.product_id
|
||||
left join tcg_overrides o on t.product_id = o.product_id
|
||||
) a
|
||||
where c.product_id = a.product_id and c.variant = a.variant and
|
||||
(
|
||||
c.product_name is distinct from a.product_name or c.product_line_name is distinct from a.product_line_name or
|
||||
c.product_url_name is distinct from a.product_url_name or c.rarity_name is distinct from a.rarity_name or
|
||||
c.sealed is distinct from a.sealed or c.set_id is distinct from a.set_id or c.card_type is distinct from a.card_type or
|
||||
c.energy_type is distinct from a.energy_type or c."number" is distinct from a."number" or c.artist is distinct from a.artist
|
||||
)
|
||||
`);
|
||||
console.log(`Updated ${updates.rowCount} rows in cards table`);
|
||||
|
||||
const inserts = await db.execute(sql`insert into cards (product_id, variant, product_name, product_line_name, product_url_name, rarity_name, sealed, set_id, card_type, energy_type, "number", artist)
|
||||
select t.product_id, b.variant,
|
||||
coalesce(o.product_name, regexp_replace(regexp_replace(coalesce(nullif(t.product_name, ''), t.product_url_name),' \\\\(.*\\\\)',''),' - .*$','')) as product_name,
|
||||
coalesce(o.product_line_name, t.product_line_name) as product_line_name, coalesce(o.product_url_name, t.product_url_name) as product_url_name, coalesce(o.rarity_name, t.rarity_name) as rarity_name,
|
||||
coalesce(o.sealed, t.sealed) as sealed, coalesce(o.set_id, t.set_id) as set_id, coalesce(o.card_type, t.card_type) as card_type,
|
||||
coalesce(o.energy_type, t.energy_type) as energy_type, coalesce(o.number, t.number) as number, coalesce(o.artist, t.artist) as artist
|
||||
from tcg_cards t
|
||||
join (select distinct product_id, variant from skus) b on t.product_id = b.product_id
|
||||
left join tcg_overrides o on t.product_id = o.product_id
|
||||
where not exists (select 1 from cards where product_id=t.product_id and variant=b.variant)
|
||||
`);
|
||||
console.log(`Inserted ${inserts.rowCount} rows into cards table`);
|
||||
|
||||
}
|
||||
@@ -5,10 +5,11 @@ import { db, ClosePool } from '../src/db/index.ts';
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import chalk from 'chalk';
|
||||
import * as helper from './pokemon-helper.ts';
|
||||
//import util from 'util';
|
||||
|
||||
|
||||
async function syncTcgplayer() {
|
||||
async function syncTcgplayer(cardSets:string[] = []) {
|
||||
|
||||
const productLines = [ "pokemon", "pokemon-japan" ];
|
||||
|
||||
@@ -29,36 +30,21 @@ async function syncTcgplayer() {
|
||||
|
||||
const setNames = data.results[0].aggregations.setName;
|
||||
for (const setName of setNames) {
|
||||
console.log(chalk.blue(`Syncing product line "${productLine}" with setName "${setName.urlValue}"...`));
|
||||
await syncProductLine(productLine, "setName", setName.urlValue);
|
||||
let processSet = true;
|
||||
if (cardSets.length > 0) {
|
||||
processSet = cardSets.some(set => setName.value.toLowerCase().includes(set.toLowerCase()));
|
||||
}
|
||||
if (processSet) {
|
||||
console.log(chalk.blue(`Syncing product line "${productLine}" with setName "${setName.urlValue}"...`));
|
||||
await syncProductLine(productLine, "setName", setName.urlValue);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
console.log(chalk.green('✓ All TCGPlayer data synchronized successfully!'));
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getNumberOrNull(value: any): number | null {
|
||||
const number = Number(value); // Attempt to convert the value to a number
|
||||
if (Number.isNaN(number)) {
|
||||
return null; // Return null if the result is NaN
|
||||
}
|
||||
return number; // Otherwise, return the number
|
||||
}
|
||||
|
||||
async function syncProductLine(productLine: string, field: string, fieldValue: string) {
|
||||
let start = 0;
|
||||
@@ -123,7 +109,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
for (const item of data.results[0].results) {
|
||||
|
||||
// Check if productId already exists and skip if it does (to avoid hitting the API too much)
|
||||
if (allProductIds.has(item.productId)) {
|
||||
if (allProductIds.size > 0 && allProductIds.has(item.productId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -163,7 +149,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
cardTypeB: item.customAttributes.cardTypeB || null,
|
||||
energyType: detailData.customAttributes.energyType?.[0] || null,
|
||||
flavorText: detailData.customAttributes.flavorText || null,
|
||||
hp: getNumberOrNull(item.customAttributes.hp),
|
||||
hp: helper.GetNumberOrNull(item.customAttributes.hp),
|
||||
number: detailData.customAttributes.number || '',
|
||||
releaseDate: detailData.customAttributes.releaseDate ? new Date(detailData.customAttributes.releaseDate) : null,
|
||||
resistance: item.customAttributes.resistance || null,
|
||||
@@ -201,7 +187,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
cardTypeB: item.customAttributes.cardTypeB || null,
|
||||
energyType: detailData.customAttributes.energyType?.[0] || null,
|
||||
flavorText: detailData.customAttributes.flavorText || null,
|
||||
hp: getNumberOrNull(item.customAttributes.hp),
|
||||
hp: helper.GetNumberOrNull(item.customAttributes.hp),
|
||||
number: detailData.customAttributes.number || '',
|
||||
releaseDate: detailData.customAttributes.releaseDate ? new Date(detailData.customAttributes.releaseDate) : null,
|
||||
resistance: item.customAttributes.resistance || null,
|
||||
@@ -218,7 +204,9 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
console.log(`item: ${item.setId}\tdetail: ${detailData.setId}`);
|
||||
console.log(`item: ${item.setCode}\tdetail: ${detailData.setCode}`);
|
||||
console.log(`item: ${item.setName}\tdetail: ${detailData.setName}`);
|
||||
// set is...
|
||||
await db.insert(schema.sets).values({
|
||||
setId: detailData.setId,
|
||||
@@ -255,7 +243,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
|
||||
// get image if it doesn't already exist
|
||||
const imagePath = path.join(process.cwd(), 'public', 'cards', `${item.productId}.jpg`);
|
||||
if (!await fileExists(imagePath)) {
|
||||
if (!await helper.FileExists(imagePath)) {
|
||||
const imageResponse = await fetch(`https://tcgplayer-cdn.tcgplayer.com/product/${item.productId}_in_1000x1000.jpg`);
|
||||
if (imageResponse.ok) {
|
||||
const buffer = await imageResponse.arrayBuffer();
|
||||
@@ -267,7 +255,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
}
|
||||
|
||||
// be nice to the API and not send too many requests in a short time
|
||||
await sleep(300);
|
||||
await helper.Sleep(300);
|
||||
|
||||
}
|
||||
|
||||
@@ -277,8 +265,21 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
||||
|
||||
// clear the log file
|
||||
await fs.rm('missing_images.log', { force: true });
|
||||
let allProductIds = new Set();
|
||||
|
||||
const allProductIds = new Set(await db.select({ productId: schema.cards.productId }).from(schema.cards).then(rows => rows.map(row => row.productId)));
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0) {
|
||||
allProductIds = new Set(await db.select({ productId: schema.cards.productId }).from(schema.cards).then(rows => rows.map(row => row.productId)));
|
||||
await syncTcgplayer();
|
||||
}
|
||||
else {
|
||||
await syncTcgplayer(args);
|
||||
}
|
||||
|
||||
// update the card table with new/updated variants
|
||||
await helper.UpdateVariants(db);
|
||||
|
||||
// index the card updates
|
||||
await helper.upsertCardCollection(db);
|
||||
|
||||
await syncTcgplayer();
|
||||
await ClosePool();
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import chalk from 'chalk';
|
||||
import { db, ClosePool } from '../src/db/index.ts';
|
||||
import * as Indexing from './indexing.ts';
|
||||
import * as Indexing from './pokemon-helper.ts';
|
||||
|
||||
|
||||
await Indexing.createCardCollection();
|
||||
await Indexing.createSkuCollection();
|
||||
await Indexing.upsertCardCollection(db);
|
||||
await Indexing.upsertSkuCollection(db);
|
||||
//await Indexing.createCardCollection();
|
||||
//await Indexing.createSkuCollection();
|
||||
await Indexing.createInventoryCollection();
|
||||
|
||||
//await Indexing.upsertCardCollection(db);
|
||||
//await Indexing.upsertSkuCollection(db);
|
||||
await Indexing.upsertInventoryCollection(db);
|
||||
await ClosePool();
|
||||
console.log(chalk.green('Pokémon reindex complete.'));
|
||||
|
||||
@@ -3,15 +3,11 @@ import 'dotenv/config';
|
||||
import chalk from 'chalk';
|
||||
import { db, ClosePool } from '../src/db/index.ts';
|
||||
import { sql, inArray, eq } from 'drizzle-orm';
|
||||
import { skus, processingSkus, priceHistory } from '../src/db/schema.ts';
|
||||
import { skus, processingSkus, priceHistory, salesHistory } from '../src/db/schema.ts';
|
||||
import { toSnakeCase } from 'drizzle-orm/casing';
|
||||
import * as Indexing from './indexing.ts';
|
||||
import * as helper from './pokemon-helper.ts';
|
||||
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function resetProcessingTable() {
|
||||
// Use sql.raw to execute the TRUNCATE TABLE statement
|
||||
await db.execute(sql.raw('TRUNCATE TABLE pokemon.processing_skus;'));
|
||||
@@ -21,6 +17,7 @@ async function resetProcessingTable() {
|
||||
async function syncPrices() {
|
||||
const batchSize = 1000;
|
||||
// const skuIndex = client.collections('skus');
|
||||
const updatedCards = new Set<number>();
|
||||
|
||||
await resetProcessingTable();
|
||||
console.log(chalk.green('Processing table reset and populated with current SKUs.'));
|
||||
@@ -60,7 +57,7 @@ async function syncPrices() {
|
||||
// remove skus from the 'working' processingSkus table
|
||||
await db.delete(processingSkus).where(inArray(processingSkus.skuId, skuIds));
|
||||
// be nice to the API and not send too many requests in a short time
|
||||
await sleep(200);
|
||||
await helper.Sleep(200);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -103,21 +100,63 @@ async function syncPrices() {
|
||||
});
|
||||
console.log(chalk.cyan(`${skuRows.length} history rows added.`));
|
||||
}
|
||||
for (const productId of skuRows.filter(row => row.calculatedAt != null).map(row => row.productId)) {
|
||||
updatedCards.add(productId);
|
||||
}
|
||||
}
|
||||
|
||||
// remove skus from the 'working' processingSkus table
|
||||
await db.delete(processingSkus).where(inArray(processingSkus.skuId, skuIds));
|
||||
|
||||
// be nice to the API and not send too many requests in a short time
|
||||
await sleep(200);
|
||||
await helper.Sleep(200);
|
||||
}
|
||||
|
||||
return updatedCards;
|
||||
}
|
||||
|
||||
const updateLatestSales = async (updatedCards: Set<number>) => {
|
||||
for (const productId of updatedCards.values()) {
|
||||
console.log(`Getting sale history for ${productId}`)
|
||||
const salesResponse = await fetch(`https://mpapi.tcgplayer.com/v2/product/${productId}/latestsales`,{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36'
|
||||
},
|
||||
body: JSON.stringify({ conditions:[], languages:[1], limit:25, listType:"All", variants:[] }),
|
||||
});
|
||||
if (!salesResponse.ok) {
|
||||
console.error('Error fetching sale history:', salesResponse.statusText);
|
||||
process.exit(1);
|
||||
}
|
||||
const salesData = await salesResponse.json();
|
||||
for (const sale of salesData.data) {
|
||||
const skuData = await db.query.skus.findFirst({ where: { productId: productId, variant: sale.variant, condition: sale.condition } });
|
||||
if (skuData) {
|
||||
await db.insert(salesHistory).values({
|
||||
skuId: skuData.skuId,
|
||||
orderDate: new Date(sale.orderDate),
|
||||
title: sale.title,
|
||||
customListingId: sale.customListingId,
|
||||
language: sale.language,
|
||||
listingType: sale.listingType,
|
||||
purchasePrice: sale.purchasePrice,
|
||||
quantity: sale.quantity,
|
||||
shippingPrice: sale.shippingPrice
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
await helper.Sleep(500);
|
||||
}
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
await syncPrices();
|
||||
await Indexing.upsertSkuCollection(db);
|
||||
const updatedCards = await syncPrices();
|
||||
await helper.upsertSkuCollection(db);
|
||||
//console.log(updatedCards);
|
||||
//console.log(updatedCards.size);
|
||||
//await updateLatestSales(updatedCards);
|
||||
await ClosePool();
|
||||
const end = Date.now();
|
||||
const duration = (end - start) / 1000;
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import 'dotenv/config';
|
||||
import { db, ClosePool } from '../src/db/index.ts';
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
async function syncVariants() {
|
||||
const updates = await db.execute(sql`update cards as c
|
||||
set
|
||||
product_name = a.product_name, product_line_name = a.product_line_name, product_url_name = a.product_url_name, rarity_name = a.rarity_name,
|
||||
sealed = a.sealed, set_id = a.set_id, card_type = a.card_type, energy_type = a.energy_type, number = a.number, artist = a.artist
|
||||
from (
|
||||
select t.product_id, b.variant,
|
||||
coalesce(o.product_name, regexp_replace(regexp_replace(coalesce(nullif(t.product_name, ''), t.product_url_name),' \\\\(.*\\\\)',''),' - .*$','')) as product_name,
|
||||
coalesce(o.product_line_name, t.product_line_name) as product_line_name, coalesce(o.product_url_name, t.product_url_name) as product_url_name,
|
||||
coalesce(o.rarity_name, t.rarity_name) as rarity_name, coalesce(o.sealed, t.sealed) as sealed, coalesce(o.set_id, t.set_id) as set_id,
|
||||
coalesce(o.card_type, t.card_type) as card_type, coalesce(o.energy_type, t.energy_type) as energy_type,
|
||||
coalesce(o.number, t.number) as number, coalesce(o.artist, t.artist) as artist
|
||||
from tcg_cards t
|
||||
join (select distinct product_id, variant from skus) b on t.product_id = b.product_id
|
||||
left join tcg_overrides o on t.product_id = o.product_id
|
||||
) a
|
||||
where c.product_id = a.product_id and c.variant = a.variant and
|
||||
(
|
||||
c.product_name is distinct from a.product_name or c.product_line_name is distinct from a.product_line_name or
|
||||
c.product_url_name is distinct from a.product_url_name or c.rarity_name is distinct from a.rarity_name or
|
||||
c.sealed is distinct from a.sealed or c.set_id is distinct from a.set_id or c.card_type is distinct from a.card_type or
|
||||
c.energy_type is distinct from a.energy_type or c."number" is distinct from a."number" or c.artist is distinct from a.artist
|
||||
)
|
||||
`);
|
||||
console.log(`Updated ${updates.rowCount} rows in cards table`);
|
||||
|
||||
const inserts = await db.execute(sql`insert into cards (product_id, variant, product_name, product_line_name, product_url_name, rarity_name, sealed, set_id, card_type, energy_type, "number", artist)
|
||||
select t.product_id, b.variant,
|
||||
coalesce(o.product_name, regexp_replace(regexp_replace(coalesce(nullif(t.product_name, ''), t.product_url_name),' \\\\(.*\\\\)',''),' - .*$','')) as product_name,
|
||||
coalesce(o.product_line_name, t.product_line_name) as product_line_name, coalesce(o.product_url_name, t.product_url_name) as product_url_name, coalesce(o.rarity_name, t.rarity_name) as rarity_name,
|
||||
coalesce(o.sealed, t.sealed) as sealed, coalesce(o.set_id, t.set_id) as set_id, coalesce(o.card_type, t.card_type) as card_type,
|
||||
coalesce(o.energy_type, t.energy_type) as energy_type, coalesce(o.number, t.number) as number, coalesce(o.artist, t.artist) as artist
|
||||
from tcg_cards t
|
||||
join (select distinct product_id, variant from skus) b on t.product_id = b.product_id
|
||||
left join tcg_overrides o on t.product_id = o.product_id
|
||||
where not exists (select 1 from cards where product_id=t.product_id and variant=b.variant)
|
||||
`);
|
||||
console.log(`Inserted ${inserts.rowCount} rows into cards table`);
|
||||
|
||||
}
|
||||
|
||||
await syncVariants();
|
||||
await ClosePool();
|
||||
@@ -22,7 +22,7 @@
|
||||
@import 'bootstrap/scss/alert';
|
||||
@import 'bootstrap/scss/badge';
|
||||
// @import 'bootstrap/scss/breadcrumb';
|
||||
// @import 'bootstrap/scss/button-group';
|
||||
@import 'bootstrap/scss/button-group';
|
||||
@import 'bootstrap/scss/buttons';
|
||||
@import 'bootstrap/scss/card';
|
||||
// @import 'bootstrap/scss/carousel';
|
||||
|
||||
2115
src/assets/css/_card.scss
Normal file
349
src/assets/css/_holofoil-integration.scss
Normal file
@@ -0,0 +1,349 @@
|
||||
// =============================================================================
|
||||
// HOLOFOIL INTEGRATION
|
||||
// _holofoil-integration.scss
|
||||
// =============================================================================
|
||||
|
||||
@import "card";
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 1. WRAPPER NORMALISATION
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
%holofoil-wrapper-base {
|
||||
--card-aspect: 0.718;
|
||||
--card-radius: 4.55% / 3.5%;
|
||||
|
||||
--pointer-x: 50%;
|
||||
--pointer-y: 50%;
|
||||
--background-x: 50%;
|
||||
--background-y: 50%;
|
||||
--pointer-from-center: 0;
|
||||
--pointer-from-top: 0.5;
|
||||
--pointer-from-left: 0.5;
|
||||
--card-scale: 1;
|
||||
--card-opacity: 0;
|
||||
|
||||
--grain: url('/public/holofoils/grain.webp');
|
||||
--glitter: url('/public/holofoils/glitter.png');
|
||||
--glittersize: 25%;
|
||||
--space: 5%;
|
||||
--angle: 133deg;
|
||||
--imgsize: cover;
|
||||
|
||||
--red: #f80e35;
|
||||
--yellow: #eedf10;
|
||||
--green: #21e985;
|
||||
--blue: #0dbde9;
|
||||
--violet: #c929f1;
|
||||
|
||||
--clip: inset(9.85% 8% 52.85% 8%);
|
||||
--clip-invert: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 47.15%, 91.5% 47.15%, 91.5% 9.85%, 8% 9.85%, 8% 47.15%, 0 50%);
|
||||
--clip-stage: polygon(91.5% 9.85%, 57% 9.85%, 54% 12%, 17% 12%, 16% 14%, 12% 16%, 8% 16%, 8% 47.15%, 92% 47.15%);
|
||||
--clip-stage-invert: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 47.15%, 91.5% 47.15%, 91.5% 9.85%, 57% 9.85%, 54% 12%, 17% 12%, 16% 14%, 12% 16%, 8% 16%, 8% 47.15%, 0 50%);
|
||||
--clip-trainer: inset(14.5% 8.5% 48.2% 8.5%);
|
||||
--clip-borders: inset(2.8% 4% round 2.55% / 1.5%);
|
||||
|
||||
--sunpillar-clr-1: var(--sunpillar-1);
|
||||
--sunpillar-clr-2: var(--sunpillar-2);
|
||||
--sunpillar-clr-3: var(--sunpillar-3);
|
||||
--sunpillar-clr-4: var(--sunpillar-4);
|
||||
--sunpillar-clr-5: var(--sunpillar-5);
|
||||
--sunpillar-clr-6: var(--sunpillar-6);
|
||||
|
||||
// NOTE: no overflow:hidden here -- that would clip the lift/scale transform
|
||||
// on .image-grow. Overflow is handled by the child .holo-shine/.holo-glare.
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
border-radius: var(--card-radius);
|
||||
}
|
||||
|
||||
%holofoil-energy-glows {
|
||||
&[data-energy="Water"] { --card-glow: hsl(192, 97%, 60%); }
|
||||
&[data-energy="Fire"] { --card-glow: hsl(9, 81%, 59%); }
|
||||
&[data-energy="Grass"] { --card-glow: hsl(96, 81%, 65%); }
|
||||
&[data-energy="Lightning"] { --card-glow: hsl(54, 87%, 63%); }
|
||||
&[data-energy="Psychic"] { --card-glow: hsl(281, 62%, 58%); }
|
||||
&[data-energy="Fighting"] { --card-glow: rgb(145, 90, 39); }
|
||||
&[data-energy="Darkness"] { --card-glow: hsl(189, 77%, 27%); }
|
||||
&[data-energy="Metal"] { --card-glow: hsl(184, 20%, 70%); }
|
||||
&[data-energy="Dragon"] { --card-glow: hsl(51, 60%, 35%); }
|
||||
&[data-energy="Fairy"] { --card-glow: hsl(323, 100%, 89%); }
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 2. SHINE + GLARE CHILD DIVS
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
%shine-base {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--card-radius);
|
||||
overflow: hidden; // clipping lives here, not on the parent
|
||||
z-index: 3;
|
||||
will-change: transform, opacity, background-image, background-size,
|
||||
background-position, background-blend-mode, filter;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--card-radius);
|
||||
}
|
||||
}
|
||||
|
||||
%glare-base {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--card-radius);
|
||||
z-index: 4;
|
||||
transform: translateZ(0);
|
||||
overflow: hidden;
|
||||
will-change: transform, opacity, background-image, background-size,
|
||||
background-position, background-blend-mode, filter;
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 3. MODES
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// -- 3a. GRID -----------------------------------------------------------------
|
||||
// No idle animation. Effect is invisible until hover.
|
||||
|
||||
.image-grow,
|
||||
.card-image-wrap {
|
||||
@extend %holofoil-wrapper-base;
|
||||
@extend %holofoil-energy-glows;
|
||||
|
||||
// No effect if the image fell back to default.jpg
|
||||
&[data-default="true"] {
|
||||
.holo-shine,
|
||||
.holo-glare { display: none !important; }
|
||||
}
|
||||
|
||||
.holo-shine { @extend %shine-base; }
|
||||
.holo-glare { @extend %glare-base; }
|
||||
}
|
||||
|
||||
|
||||
// -- 3b. GRID HOVER -----------------------------------------------------------
|
||||
// The existing main.scss .image-grow:hover handles lift + scale.
|
||||
// We layer the holo effect on top without overriding transform or transition.
|
||||
|
||||
.image-grow:hover,
|
||||
.image-grow[data-holo-active] {
|
||||
--card-opacity: 0.45;
|
||||
}
|
||||
|
||||
|
||||
// -- 3c. MODAL ----------------------------------------------------------------
|
||||
// Sweeps once per minute. Peaks at 0.35.
|
||||
// Pointer tracking bumps opacity to 0.45 while hovering.
|
||||
|
||||
@keyframes holo-modal-pulse {
|
||||
0% {
|
||||
--card-opacity: 0;
|
||||
--pointer-x: 50%; --pointer-y: 50%;
|
||||
--background-x: 50%; --background-y: 50%;
|
||||
--pointer-from-center: 0; --pointer-from-left: 0.5; --pointer-from-top: 0.5;
|
||||
}
|
||||
4% { --card-opacity: 0; }
|
||||
8% {
|
||||
--card-opacity: 0.35;
|
||||
--pointer-x: 25%; --pointer-y: 15%;
|
||||
--background-x: 38%; --background-y: 28%;
|
||||
--pointer-from-center: 0.85; --pointer-from-left: 0.25; --pointer-from-top: 0.15;
|
||||
}
|
||||
25% {
|
||||
--pointer-x: 70%; --pointer-y: 30%;
|
||||
--background-x: 64%; --background-y: 34%;
|
||||
--pointer-from-center: 0.9; --pointer-from-left: 0.70; --pointer-from-top: 0.30;
|
||||
}
|
||||
45% {
|
||||
--pointer-x: 80%; --pointer-y: 70%;
|
||||
--background-x: 74%; --background-y: 68%;
|
||||
--pointer-from-center: 0.88; --pointer-from-left: 0.80; --pointer-from-top: 0.70;
|
||||
}
|
||||
65% {
|
||||
--pointer-x: 35%; --pointer-y: 80%;
|
||||
--background-x: 38%; --background-y: 76%;
|
||||
--pointer-from-center: 0.8; --pointer-from-left: 0.35; --pointer-from-top: 0.80;
|
||||
}
|
||||
85% {
|
||||
--card-opacity: 0.35;
|
||||
--pointer-x: 25%; --pointer-y: 15%;
|
||||
--background-x: 38%; --background-y: 28%;
|
||||
--pointer-from-center: 0.85;
|
||||
}
|
||||
90% { --card-opacity: 0; }
|
||||
100% {
|
||||
--card-opacity: 0;
|
||||
--pointer-x: 50%; --pointer-y: 50%;
|
||||
--background-x: 50%; --background-y: 50%;
|
||||
--pointer-from-center: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image-wrap.holo-modal-mode {
|
||||
--card-opacity: 0;
|
||||
|
||||
.holo-shine,
|
||||
.holo-glare {
|
||||
animation: holo-modal-pulse 60s ease-in-out infinite;
|
||||
animation-delay: var(--shimmer-delay, -2s);
|
||||
}
|
||||
|
||||
&[data-holo-active] {
|
||||
--card-opacity: 0.45;
|
||||
.holo-shine,
|
||||
.holo-glare { animation-play-state: paused; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 4. RARITY -> CLIP-PATH BRIDGE
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
.image-grow,
|
||||
.card-image-wrap {
|
||||
|
||||
// No effect on common/uncommon or unrecognised wrapper
|
||||
&[data-rarity="common"],
|
||||
&[data-rarity="uncommon"],
|
||||
&:not([data-rarity]) {
|
||||
.holo-shine,
|
||||
.holo-glare { display: none; }
|
||||
}
|
||||
|
||||
// Standard holo — artwork area only
|
||||
&[data-rarity="rare holo"] {
|
||||
.holo-shine { clip-path: var(--clip); }
|
||||
&[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); }
|
||||
&[data-subtypes^="supporter"] .holo-shine,
|
||||
&[data-subtypes^="item"] .holo-shine { clip-path: var(--clip-trainer); }
|
||||
}
|
||||
|
||||
// Cosmos holo
|
||||
&[data-rarity="rare holo cosmos"] {
|
||||
.holo-shine { clip-path: var(--clip); }
|
||||
&[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); }
|
||||
&[data-subtypes^="supporter"] .holo-shine { clip-path: var(--clip-trainer); }
|
||||
}
|
||||
|
||||
&[data-rarity="radiant rare"] { .holo-shine { clip-path: var(--clip-borders); } }
|
||||
&[data-rarity="amazing rare"] { .holo-shine { clip-path: var(--clip); } }
|
||||
|
||||
&[data-rarity="trainer gallery rare holo"],
|
||||
&[data-rarity="rare holo"][data-trainer-gallery="true"] {
|
||||
.holo-shine { clip-path: var(--clip-borders); }
|
||||
}
|
||||
|
||||
&[data-rarity="rare shiny"] {
|
||||
.holo-shine { clip-path: var(--clip); }
|
||||
&[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); }
|
||||
}
|
||||
|
||||
// Reverse holo by rarity — borders only
|
||||
&[data-rarity$="reverse holo"] { .holo-shine { clip-path: var(--clip-invert); } }
|
||||
// Reverse Holofoil variant — borders only
|
||||
&[data-variant="Reverse Holofoil"] { .holo-shine { clip-path: var(--clip-invert); } }
|
||||
|
||||
// True holofoil variants + full-bleed rarities — no clip
|
||||
&[data-variant="Holofoil"],
|
||||
&[data-variant="1st Edition Holofoil"],
|
||||
&[data-variant="Unlimited Holofoil"],
|
||||
&[data-rarity="rare ultra"],
|
||||
&[data-rarity="rare holo v"],
|
||||
&[data-rarity="rare holo vmax"],
|
||||
&[data-rarity="rare holo vstar"],
|
||||
&[data-rarity="rare shiny v"],
|
||||
&[data-rarity="rare shiny vmax"],
|
||||
&[data-rarity="rare rainbow"],
|
||||
&[data-rarity="rare rainbow alt"],
|
||||
&[data-rarity="rare secret"] {
|
||||
.holo-shine { clip-path: none; }
|
||||
}
|
||||
|
||||
// Foil variant shine/glare — clip handled above per variant type
|
||||
&[data-variant="Holofoil"],
|
||||
&[data-variant="Reverse Holofoil"],
|
||||
&[data-variant="1st Edition Holofoil"],
|
||||
&[data-variant="Unlimited Holofoil"] {
|
||||
.holo-shine {
|
||||
background-image:
|
||||
radial-gradient(
|
||||
circle at var(--pointer-x) var(--pointer-y),
|
||||
#fff 5%, #000 50%, #fff 80%
|
||||
),
|
||||
linear-gradient(
|
||||
var(--foil-angle, -45deg),
|
||||
#000 15%, #fff, #000 85%
|
||||
);
|
||||
background-blend-mode: soft-light, difference;
|
||||
background-size: 120% 120%, 200% 200%;
|
||||
background-position:
|
||||
center center,
|
||||
calc(100% * var(--pointer-from-left)) calc(100% * var(--pointer-from-top));
|
||||
filter: brightness(var(--foil-brightness, 0.4)) contrast(1.3) saturate(var(--foil-saturation, 0.5));
|
||||
mix-blend-mode: color-dodge;
|
||||
opacity: calc((var(--card-opacity) * 0.9) - (var(--pointer-from-center) * 0.1));
|
||||
}
|
||||
|
||||
.holo-glare {
|
||||
opacity: calc(var(--card-opacity) * 0.5);
|
||||
background-image: radial-gradient(
|
||||
farthest-corner circle at var(--pointer-x) var(--pointer-y),
|
||||
hsla(0, 0%, 100%, 0.5) 10%,
|
||||
hsla(0, 0%, 100%, 0.25) 30%,
|
||||
hsla(0, 0%, 0%, 0.4) 90%
|
||||
);
|
||||
filter: brightness(0.7) contrast(1.2);
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 5. DEFAULT HOLO SHINE / GLARE
|
||||
// Fallback for rarities not explicitly handled above.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
.image-grow,
|
||||
.card-image-wrap {
|
||||
&[data-rarity]:not([data-rarity="common"]):not([data-rarity="uncommon"]) {
|
||||
|
||||
.holo-shine {
|
||||
background-image:
|
||||
repeating-linear-gradient(110deg,
|
||||
var(--violet), var(--blue), var(--green), var(--yellow), var(--red),
|
||||
var(--violet), var(--blue), var(--green), var(--yellow), var(--red)
|
||||
);
|
||||
background-position:
|
||||
calc(((50% - var(--background-x)) * 2.6) + 50%)
|
||||
calc(((50% - var(--background-y)) * 3.5) + 50%);
|
||||
background-size: 400% 400%;
|
||||
filter: brightness(0.7) contrast(0.9) saturate(0.8);
|
||||
mix-blend-mode: color-dodge;
|
||||
opacity: calc(var(--card-opacity) * 0.6);
|
||||
}
|
||||
|
||||
.holo-glare {
|
||||
background-image: radial-gradient(
|
||||
farthest-corner circle at var(--pointer-x) var(--pointer-y),
|
||||
hsla(0, 0%, 100%, 0.35) 10%,
|
||||
hsla(0, 0%, 100%, 0.15) 30%,
|
||||
hsla(0, 0%, 0%, 0.35) 90%
|
||||
);
|
||||
opacity: calc(var(--card-opacity) * 0.4);
|
||||
mix-blend-mode: overlay;
|
||||
filter: brightness(0.7) contrast(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ $container-max-widths: (
|
||||
|
||||
@import "_bootstrap";
|
||||
|
||||
// ── Holofoil ──────────────────────────────────────────────────────────────
|
||||
//@import "_holofoil-integration"; // also pulls in _card.scss
|
||||
|
||||
/* --------------------------------------------------
|
||||
Root Variables
|
||||
-------------------------------------------------- */
|
||||
@@ -169,6 +172,16 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
.col:has(.image-grow:hover) .inventory-button {
|
||||
opacity: 0.20;
|
||||
transition: opacity 350ms ease;
|
||||
}
|
||||
|
||||
.inventory-button {
|
||||
// add transition to existing rule
|
||||
transition: opacity 350ms ease;
|
||||
}
|
||||
|
||||
.card-modal {
|
||||
background-color: rgba(1, 11, 18, 0.8);
|
||||
cursor: default;
|
||||
@@ -275,6 +288,40 @@ $tiers: (
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inventory form condition buttons ──────────────────────────────────────
|
||||
// Reuses $tiers map so colors stay in sync with nav tabs and price-row
|
||||
|
||||
$cond-text: (
|
||||
nm: rgba(156, 204, 102, 1),
|
||||
lp: rgba(211, 225, 86, 1),
|
||||
mp: rgba(255, 238, 87, 1),
|
||||
hp: rgba(255, 201, 41, 1),
|
||||
dmg: rgba(255, 167, 36, 1),
|
||||
);
|
||||
|
||||
@each $name, $color in $tiers {
|
||||
@if map-has-key($cond-text, $name) {
|
||||
.btn-check:checked + .btn-cond-#{$name} {
|
||||
background-color: $color;
|
||||
border-color: $color;
|
||||
color: rgba(0, 0, 0, 0.94);
|
||||
}
|
||||
|
||||
.btn-cond-#{$name} {
|
||||
border-color: rgba($color, 0.4);
|
||||
color: var(--bs-body-color);
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.btn-check:not(:checked) + .btn-cond-#{$name}:hover {
|
||||
background-color: rgba($color, 0.67);
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------
|
||||
Misc UI
|
||||
-------------------------------------------------- */
|
||||
@@ -292,7 +339,7 @@ $tiers: (
|
||||
.card-image {
|
||||
aspect-ratio: 23 / 32;
|
||||
object-fit: cover;
|
||||
z-index: 998;
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -360,6 +407,7 @@ $tiers: (
|
||||
bottom: 5vh;
|
||||
right: 5vw;
|
||||
display: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.top-icon svg {
|
||||
@@ -374,6 +422,30 @@ $tiers: (
|
||||
stroke: var(--bs-info-border-subtle);
|
||||
}
|
||||
|
||||
.delete-svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
fill: var(--bs-danger);
|
||||
stroke: var(--bs-danger);
|
||||
}
|
||||
|
||||
.edit-svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
fill: var(--bs-warning);
|
||||
stroke: var(--bs-warning);
|
||||
}
|
||||
|
||||
.btn:hover .delete-svg {
|
||||
fill: var(--bs-danger-border-subtle);
|
||||
stroke: var(--bs-danger-border-subtle);
|
||||
}
|
||||
|
||||
.btn:hover .edit-svg {
|
||||
fill: var(--bs-warning-border-subtle);
|
||||
stroke: var(--bs-warning-border-subtle);
|
||||
}
|
||||
|
||||
.shadow-filter {
|
||||
filter:
|
||||
drop-shadow(0 5px 5px rgba(0, 0, 0, 0.3))
|
||||
@@ -400,6 +472,7 @@ $tiers: (
|
||||
|
||||
.price-row {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: -1.25rem;
|
||||
border-radius: 0.33rem;
|
||||
background: linear-gradient(
|
||||
@@ -412,21 +485,20 @@ $tiers: (
|
||||
);
|
||||
}
|
||||
|
||||
.inventory-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: -2rem;
|
||||
margin-right: -0.25rem;
|
||||
border-radius: 0.33rem;
|
||||
.inventory-button, .btn-vendor {
|
||||
background-color: hsl(262, 47%, 55%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.inventory-label {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
.inventory-button {
|
||||
margin-bottom: -2.25rem;
|
||||
margin-right: -0.25rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.inventory-button:hover, .btn-vendor:hover {
|
||||
background-color: hsl(262, 39%, 40%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fs-7 {
|
||||
|
||||
280
src/assets/js/holofoil-init.js
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* holofoil-init.js
|
||||
* -----------------------------------------------------------------------------
|
||||
* Instruments .image-grow and .card-image-wrap with the holofoil effect system.
|
||||
*
|
||||
* GRID (.image-grow)
|
||||
* Effect is invisible at rest. On hover, pointer tracking drives the shine
|
||||
* and glare layers. The card lift/scale comes from main.scss as before.
|
||||
*
|
||||
* MODAL (.card-image-wrap)
|
||||
* Effect sweeps autonomously once per minute via CSS animation.
|
||||
* Pointer tracking takes over while the user hovers the image.
|
||||
*
|
||||
* DEFAULT FALLBACK
|
||||
* If data-default="true" is set (onerror in the Astro markup), no effect
|
||||
* is applied -- even if the attribute appears after stamp() has run.
|
||||
* -----------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
(function HolofoilSystem() {
|
||||
|
||||
'use strict';
|
||||
|
||||
// -- Constants --------------------------------------------------------------
|
||||
|
||||
const SHIMMER_SEL = [
|
||||
'.image-grow[data-rarity]',
|
||||
'.image-grow[data-variant="Holofoil"]',
|
||||
'.image-grow[data-variant="1st Edition Holofoil"]',
|
||||
'.image-grow[data-variant="Unlimited Holofoil"]',
|
||||
'.image-grow[data-variant="Reverse Holofoil"]',
|
||||
'.card-image-wrap[data-rarity]',
|
||||
'.card-image-wrap[data-variant="Holofoil"]',
|
||||
'.card-image-wrap[data-variant="1st Edition Holofoil"]',
|
||||
'.card-image-wrap[data-variant="Unlimited Holofoil"]',
|
||||
'.card-image-wrap[data-variant="Reverse Holofoil"]',
|
||||
].join(',');
|
||||
|
||||
const ALL_WRAPPERS_SEL = '.image-grow, .card-image-wrap';
|
||||
|
||||
// Foil variant visual randomisation
|
||||
const FOIL_ANGLE_MIN = -65, FOIL_ANGLE_MAX = -25;
|
||||
const FOIL_BRITE_MIN = 0.18, FOIL_BRITE_MAX = 0.32;
|
||||
const FOIL_SAT_MIN = 0.40, FOIL_SAT_MAX = 0.75;
|
||||
|
||||
const SKIP_RARITIES = new Set(['common', 'uncommon', '']);
|
||||
|
||||
|
||||
// -- Helpers ----------------------------------------------------------------
|
||||
|
||||
const rand = (min, max) => parseFloat((Math.random() * (max - min) + min).toFixed(2));
|
||||
const clamp01 = n => Math.max(0, Math.min(1, n));
|
||||
|
||||
function pointerVars(x, y, rect) {
|
||||
const fromLeft = clamp01((x - rect.left) / rect.width);
|
||||
const fromTop = clamp01((y - rect.top) / rect.height);
|
||||
const fromCenter = clamp01(Math.sqrt((fromLeft - 0.5) ** 2 + (fromTop - 0.5) ** 2) * 2);
|
||||
return {
|
||||
px: fromLeft * 100,
|
||||
py: fromTop * 100,
|
||||
fromLeft,
|
||||
fromTop,
|
||||
fromCenter,
|
||||
bgX: 50 + (fromLeft - 0.5) * 30,
|
||||
bgY: 50 + (fromTop - 0.5) * 30,
|
||||
};
|
||||
}
|
||||
|
||||
function applyPointerVars(el, v) {
|
||||
el.style.setProperty('--pointer-x', v.px.toFixed(1) + '%');
|
||||
el.style.setProperty('--pointer-y', v.py.toFixed(1) + '%');
|
||||
el.style.setProperty('--pointer-from-left', v.fromLeft.toFixed(3));
|
||||
el.style.setProperty('--pointer-from-top', v.fromTop.toFixed(3));
|
||||
el.style.setProperty('--pointer-from-center', v.fromCenter.toFixed(3));
|
||||
el.style.setProperty('--background-x', v.bgX.toFixed(1) + '%');
|
||||
el.style.setProperty('--background-y', v.bgY.toFixed(1) + '%');
|
||||
}
|
||||
|
||||
const isHoloVariant = v => ['Holofoil', 'Reverse Holofoil', '1st Edition Holofoil', 'Unlimited Holofoil'].includes(v);
|
||||
const isModalWrapper = el => el.classList.contains('card-image-wrap');
|
||||
const isDefault = el => el.dataset.default === 'true';
|
||||
|
||||
|
||||
// -- Child injection --------------------------------------------------------
|
||||
|
||||
function injectChildren(el) {
|
||||
if (el.querySelector('.holo-shine')) return;
|
||||
const shine = document.createElement('div');
|
||||
shine.className = 'holo-shine';
|
||||
const glare = document.createElement('div');
|
||||
glare.className = 'holo-glare';
|
||||
el.appendChild(shine);
|
||||
el.appendChild(glare);
|
||||
}
|
||||
|
||||
|
||||
// -- Default image guard ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Watch for the onerror handler in the Astro markup setting data-default="true"
|
||||
* after stamp() has already run. Hide the effect children immediately when seen.
|
||||
*/
|
||||
function watchForDefault(el) {
|
||||
if (isDefault(el)) return;
|
||||
|
||||
var observer = new MutationObserver(function() {
|
||||
if (isDefault(el)) {
|
||||
var shine = el.querySelector('.holo-shine');
|
||||
var glare = el.querySelector('.holo-glare');
|
||||
if (shine) shine.style.display = 'none';
|
||||
if (glare) glare.style.display = 'none';
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(el, { attributes: true, attributeFilter: ['data-default'] });
|
||||
}
|
||||
|
||||
|
||||
// -- Stamp ------------------------------------------------------------------
|
||||
|
||||
function stamp(el) {
|
||||
if (el.dataset.holoInit) return;
|
||||
|
||||
// Skip if already a default fallback image
|
||||
if (isDefault(el)) {
|
||||
el.dataset.holoInit = 'skip';
|
||||
return;
|
||||
}
|
||||
|
||||
const rarity = (el.dataset.rarity || '').toLowerCase();
|
||||
const variant = el.dataset.variant || '';
|
||||
|
||||
const hasHoloRarity = rarity && !SKIP_RARITIES.has(rarity);
|
||||
const hasHoloVariant = isHoloVariant(variant);
|
||||
|
||||
if (!hasHoloRarity && !hasHoloVariant) {
|
||||
el.dataset.holoInit = 'skip';
|
||||
return;
|
||||
}
|
||||
|
||||
injectChildren(el);
|
||||
|
||||
// Per-card foil visual randomisation (angle/brightness/saturation)
|
||||
if (hasHoloVariant) {
|
||||
el.style.setProperty('--foil-angle', Math.round(rand(FOIL_ANGLE_MIN, FOIL_ANGLE_MAX)) + 'deg');
|
||||
el.style.setProperty('--foil-brightness', rand(FOIL_BRITE_MIN, FOIL_BRITE_MAX).toFixed(2));
|
||||
el.style.setProperty('--foil-saturation', rand(FOIL_SAT_MIN, FOIL_SAT_MAX ).toFixed(2));
|
||||
}
|
||||
|
||||
// Modal-only: set a stable delay offset for the autonomous CSS animation
|
||||
if (isModalWrapper(el)) {
|
||||
el.classList.add('holo-modal-mode');
|
||||
el.style.setProperty('--shimmer-delay', rand(-8, 0) + 's');
|
||||
}
|
||||
|
||||
watchForDefault(el);
|
||||
|
||||
el.dataset.holoInit = '1';
|
||||
}
|
||||
|
||||
function stampAll(root) {
|
||||
(root || document).querySelectorAll(ALL_WRAPPERS_SEL).forEach(stamp);
|
||||
}
|
||||
|
||||
|
||||
// -- Pointer tracking -------------------------------------------------------
|
||||
|
||||
const pointerState = new WeakMap();
|
||||
|
||||
function onPointerEnter(e) {
|
||||
const el = e.currentTarget;
|
||||
if (el.dataset.holoInit !== '1' || isDefault(el)) return;
|
||||
|
||||
el.dataset.holoActive = '1';
|
||||
if (!pointerState.has(el)) pointerState.set(el, { rafId: null });
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
const el = e.currentTarget;
|
||||
if (el.dataset.holoInit !== '1') return;
|
||||
|
||||
const state = pointerState.get(el);
|
||||
if (!state) return;
|
||||
|
||||
if (state.rafId) cancelAnimationFrame(state.rafId);
|
||||
state.rafId = requestAnimationFrame(function() {
|
||||
const rect = el.getBoundingClientRect();
|
||||
applyPointerVars(el, pointerVars(e.clientX, e.clientY, rect));
|
||||
state.rafId = null;
|
||||
});
|
||||
}
|
||||
|
||||
function onPointerLeave(e) {
|
||||
const el = e.currentTarget;
|
||||
if (el.dataset.holoInit !== '1') return;
|
||||
|
||||
const state = pointerState.get(el);
|
||||
if (state && state.rafId) { cancelAnimationFrame(state.rafId); state.rafId = null; }
|
||||
|
||||
delete el.dataset.holoActive;
|
||||
|
||||
if (isModalWrapper(el)) {
|
||||
// Let the CSS animation resume driving --card-opacity
|
||||
el.style.removeProperty('--card-opacity');
|
||||
}
|
||||
}
|
||||
|
||||
function attachListeners(el) {
|
||||
if (el.dataset.holoListeners) return;
|
||||
el.addEventListener('pointerenter', onPointerEnter, { passive: true });
|
||||
el.addEventListener('pointermove', onPointerMove, { passive: true });
|
||||
el.addEventListener('pointerleave', onPointerLeave, { passive: true });
|
||||
el.dataset.holoListeners = '1';
|
||||
}
|
||||
|
||||
function attachAllListeners(root) {
|
||||
(root || document).querySelectorAll(SHIMMER_SEL).forEach(function(el) {
|
||||
stamp(el);
|
||||
if (el.dataset.holoInit === '1') attachListeners(el);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// -- MutationObserver: react to HTMX / infinite scroll ----------------------
|
||||
|
||||
function observeGrid() {
|
||||
var grid = document.getElementById('cardGrid');
|
||||
if (!grid) return;
|
||||
|
||||
new MutationObserver(function(mutations) {
|
||||
for (var i = 0; i < mutations.length; i++) {
|
||||
var nodes = mutations[i].addedNodes;
|
||||
for (var j = 0; j < nodes.length; j++) {
|
||||
var node = nodes[j];
|
||||
if (node.nodeType !== 1) continue;
|
||||
if (node.matches && node.matches(ALL_WRAPPERS_SEL)) {
|
||||
stamp(node);
|
||||
if (node.dataset.holoInit === '1') attachListeners(node);
|
||||
}
|
||||
if (node.querySelectorAll) {
|
||||
node.querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) {
|
||||
stamp(el);
|
||||
if (el.dataset.holoInit === '1') attachListeners(el);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}).observe(grid, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
function observeModal() {
|
||||
var modal = document.getElementById('cardModal');
|
||||
if (!modal) return;
|
||||
|
||||
new MutationObserver(function() {
|
||||
modal.querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) {
|
||||
stamp(el);
|
||||
if (el.dataset.holoInit === '1') attachListeners(el);
|
||||
});
|
||||
}).observe(modal, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
|
||||
// -- Bootstrap --------------------------------------------------------------
|
||||
|
||||
function init() {
|
||||
stampAll();
|
||||
attachAllListeners();
|
||||
observeGrid();
|
||||
observeModal();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -1,52 +1,97 @@
|
||||
import * as bootstrap from 'bootstrap';
|
||||
window.bootstrap = bootstrap;
|
||||
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
||||
|
||||
// trap browser back and close the modal if open
|
||||
const cardModal = document.getElementById('cardModal');
|
||||
const loadingMsg = cardModal.innerHTML;
|
||||
// Push a new history state when the modal is shown
|
||||
cardModal.addEventListener('shown.bs.modal', () => {
|
||||
history.pushState({ modalOpen: true }, null, '#cardModal');
|
||||
});
|
||||
// Listen for the browser's back button (popstate event)
|
||||
window.addEventListener('popstate', (e) => {
|
||||
if (cardModal.classList.contains('show')) {
|
||||
const modalInstance = bootstrap.Modal.getInstance(cardModal);
|
||||
if (modalInstance) {
|
||||
modalInstance.hide();
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Initialize all Bootstrap modals
|
||||
document.querySelectorAll('.modal').forEach(modalEl => {
|
||||
bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||
});
|
||||
|
||||
// Initialize tooltips
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||
if (!el._tooltipInstance) {
|
||||
el._tooltipInstance = new bootstrap.Tooltip(el, { container: 'body' });
|
||||
}
|
||||
}
|
||||
});
|
||||
// Trigger a back navigation when the modal is closed via its native controls (X, backdrop click)
|
||||
cardModal.addEventListener('hide.bs.modal', () => {
|
||||
cardModal.innerHTML = loadingMsg;
|
||||
if (history.state && history.state.modalOpen) {
|
||||
history.back();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------- DASHBOARD LOGIC ----------------
|
||||
const toggleBtn = document.getElementById("toggleViewBtn");
|
||||
const gridView = document.getElementById("gridView");
|
||||
const tableView = document.getElementById("tableView");
|
||||
const searchInput = document.getElementById("inventorySearch");
|
||||
const tbody = document.getElementById("inventoryRows");
|
||||
|
||||
import { Tooltip } from "bootstrap";
|
||||
|
||||
// Initialize all tooltips globally
|
||||
const initTooltips = () => {
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||
if (!el._tooltipInstance) {
|
||||
el._tooltipInstance = new Tooltip(el, {
|
||||
container: 'body', // ensures tooltip is appended to body, important for modals
|
||||
});
|
||||
if(toggleBtn && gridView && tableView && tbody) {
|
||||
// TOGGLE GRID/TABLE
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
if(gridView.style.display !== "none") {
|
||||
gridView.style.display = "none";
|
||||
tableView.style.display = "block";
|
||||
toggleBtn.textContent = "Switch to Grid View";
|
||||
} else {
|
||||
gridView.style.display = "block";
|
||||
tableView.style.display = "none";
|
||||
toggleBtn.textContent = "Switch to Table View";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Run on page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initTooltips);
|
||||
} else {
|
||||
initTooltips();
|
||||
// SEARCH FILTER
|
||||
if(searchInput) {
|
||||
searchInput.addEventListener("input", e => {
|
||||
const term = e.target.value.toLowerCase();
|
||||
[...tbody.querySelectorAll("tr")].forEach(row => {
|
||||
row.style.display = row.textContent.toLowerCase().includes(term) ? "" : "none";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// SORTING
|
||||
document.querySelectorAll("th[data-key]").forEach(th => {
|
||||
let sortAsc = true;
|
||||
th.addEventListener("click", () => {
|
||||
const key = th.dataset.key;
|
||||
const indexMap = {name:0,set:1,condition:2,qty:3,price:4,market:5,gain:6};
|
||||
const idx = indexMap[key];
|
||||
const rows = [...tbody.querySelectorAll("tr")];
|
||||
|
||||
rows.sort((a,b) => {
|
||||
let aText = a.children[idx].textContent.replace(/\$|,/g,'').toLowerCase();
|
||||
let bText = b.children[idx].textContent.replace(/\$|,/g,'').toLowerCase();
|
||||
if(!isNaN(aText) && !isNaN(bText)) return sortAsc ? aText-bText : bText-aText;
|
||||
return sortAsc ? aText.localeCompare(bText) : bText.localeCompare(aText);
|
||||
});
|
||||
|
||||
sortAsc = !sortAsc;
|
||||
tbody.innerHTML="";
|
||||
rows.forEach(r => tbody.appendChild(r));
|
||||
});
|
||||
});
|
||||
|
||||
// INLINE EDITING + GAIN/LOSS UPDATE
|
||||
tbody.addEventListener("input", e => {
|
||||
const row = e.target.closest("tr");
|
||||
if(!row) return;
|
||||
|
||||
const priceCell = row.querySelector(".editable-price");
|
||||
const qtyCell = row.querySelector(".editable-qty");
|
||||
const marketCell = row.children[5];
|
||||
const gainCell = row.querySelector(".gain");
|
||||
|
||||
if(e.target.classList.contains("editable-price")) {
|
||||
e.target.textContent = e.target.textContent.replace(/[^\d.]/g,"");
|
||||
}
|
||||
if(e.target.classList.contains("editable-qty")) {
|
||||
e.target.textContent = e.target.textContent.replace(/\D/g,"");
|
||||
}
|
||||
|
||||
const price = parseFloat(priceCell.textContent) || 0;
|
||||
const qty = parseInt(qtyCell.textContent) || 0;
|
||||
const market = parseFloat(marketCell.textContent) || 0;
|
||||
const gain = market - price;
|
||||
|
||||
gainCell.textContent = (gain>=0 ? "+" : "-") + Math.abs(gain);
|
||||
gainCell.className = gain>=0 ? "gain text-success" : "gain text-danger";
|
||||
});
|
||||
}
|
||||
|
||||
// Optional: observe DOM changes for dynamically added tooltips (e.g., modals loaded later)
|
||||
const observer = new MutationObserver(() => initTooltips());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
@@ -32,6 +32,12 @@ function setEmptyState(isEmpty) {
|
||||
canvasWrapper.classList.toggle('d-none', isEmpty);
|
||||
}
|
||||
|
||||
function setChartVisible(visible) {
|
||||
const modal = document.getElementById('cardModal');
|
||||
const chartWrapper = modal?.querySelector('#priceHistoryChart')?.closest('.alert');
|
||||
if (chartWrapper) chartWrapper.classList.toggle('d-none', !visible);
|
||||
}
|
||||
|
||||
function buildChartData(history, rangeKey) {
|
||||
const cutoff = RANGE_DAYS[rangeKey] === Infinity
|
||||
? new Date(0)
|
||||
@@ -39,10 +45,21 @@ function buildChartData(history, rangeKey) {
|
||||
|
||||
const filtered = history.filter(r => new Date(r.calculatedAt) >= cutoff);
|
||||
|
||||
const allDates = [...new Set(filtered.map(r => r.calculatedAt))]
|
||||
.sort((a, b) => new Date(a) - new Date(b));
|
||||
const dataDateSet = new Set(filtered.map(r => r.calculatedAt));
|
||||
const allDates = [...dataDateSet].sort((a, b) => new Date(a) - new Date(b));
|
||||
|
||||
const labels = allDates.map(formatDate);
|
||||
let axisLabels = allDates;
|
||||
if (allDates.length > 0 && RANGE_DAYS[rangeKey] !== Infinity) {
|
||||
const start = new Date(cutoff);
|
||||
const end = new Date();
|
||||
const expanded = [];
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
expanded.push(d.toISOString().split('T')[0]);
|
||||
}
|
||||
axisLabels = expanded;
|
||||
}
|
||||
|
||||
const labels = axisLabels.map(formatDate);
|
||||
|
||||
const lookup = {};
|
||||
for (const row of filtered) {
|
||||
@@ -50,16 +67,14 @@ function buildChartData(history, rangeKey) {
|
||||
lookup[row.condition][row.calculatedAt] = Number(row.marketPrice);
|
||||
}
|
||||
|
||||
// Check specifically whether the active condition has any data points
|
||||
const activeConditionDates = allDates.filter(
|
||||
const activeConditionHasData = allDates.some(
|
||||
date => lookup[activeCondition]?.[date] != null
|
||||
);
|
||||
const activeConditionHasData = activeConditionDates.length > 0;
|
||||
|
||||
const datasets = CONDITIONS.map(condition => {
|
||||
const isActive = condition === activeCondition;
|
||||
const colors = CONDITION_COLORS[condition];
|
||||
const data = allDates.map(date => lookup[condition]?.[date] ?? null);
|
||||
const data = axisLabels.map(date => lookup[condition]?.[date] ?? null);
|
||||
return {
|
||||
label: condition,
|
||||
data,
|
||||
@@ -75,23 +90,21 @@ function buildChartData(history, rangeKey) {
|
||||
};
|
||||
});
|
||||
|
||||
return { labels, datasets, hasData: allDates.length > 0, activeConditionHasData };
|
||||
return {
|
||||
labels,
|
||||
datasets,
|
||||
hasData: allDates.length > 0,
|
||||
activeConditionHasData,
|
||||
};
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (!chartInstance) return;
|
||||
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
|
||||
|
||||
// Show empty state if no data at all, or if the active condition specifically has no data
|
||||
if (!hasData || !activeConditionHasData) {
|
||||
setEmptyState(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setEmptyState(false);
|
||||
chartInstance.data.labels = labels;
|
||||
chartInstance.data.labels = labels;
|
||||
chartInstance.data.datasets = datasets;
|
||||
chartInstance.update('none');
|
||||
setEmptyState(!hasData || !activeConditionHasData);
|
||||
}
|
||||
|
||||
function initPriceChart(canvas) {
|
||||
@@ -114,12 +127,7 @@ function initPriceChart(canvas) {
|
||||
|
||||
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
|
||||
|
||||
if (!hasData || !activeConditionHasData) {
|
||||
setEmptyState(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setEmptyState(false);
|
||||
setEmptyState(!hasData || !activeConditionHasData);
|
||||
|
||||
chartInstance = new Chart(canvas.getContext('2d'), {
|
||||
type: 'line',
|
||||
@@ -185,9 +193,16 @@ function initFromCanvas(canvas) {
|
||||
activeCondition = "Near Mint";
|
||||
activeRange = '1m';
|
||||
const modal = document.getElementById('cardModal');
|
||||
|
||||
modal?.querySelectorAll('.price-range-btn').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.range === '1m');
|
||||
});
|
||||
|
||||
// Hide chart if the vendor tab is already active when the modal opens
|
||||
// (e.g. opened via the inventory button)
|
||||
const activeTab = modal?.querySelector('.nav-link.active')?.getAttribute('data-bs-target');
|
||||
setChartVisible(activeTab !== '#nav-vendor');
|
||||
|
||||
initPriceChart(canvas);
|
||||
}
|
||||
|
||||
@@ -208,6 +223,10 @@ function setup() {
|
||||
document.addEventListener('shown.bs.tab', (e) => {
|
||||
if (!modal.contains(e.target)) return;
|
||||
const target = e.target?.getAttribute('data-bs-target');
|
||||
|
||||
// Hide the chart when the vendor tab is active, show it for all others
|
||||
setChartVisible(target !== '#nav-vendor');
|
||||
|
||||
const conditionMap = {
|
||||
'#nav-nm': 'Near Mint',
|
||||
'#nav-lp': 'Lightly Played',
|
||||
|
||||
@@ -15,7 +15,7 @@ import BackToTop from "./BackToTop.astro"
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-10 mt-0">
|
||||
<div class="d-flex flex-row align-items-center mb-2">
|
||||
<div class="d-flex flex-column gap-1 flex-md-row align-items-center mb-3 w-100 justify-content-start">
|
||||
<div id="sortBy"></div>
|
||||
<div id="totalResults"></div>
|
||||
<div id="activeFilters"></div>
|
||||
@@ -47,13 +47,144 @@ import BackToTop from "./BackToTop.astro"
|
||||
<script is:inline>
|
||||
(function () {
|
||||
|
||||
// ── Sort dropdown ─────────────────────────────────────────────────────────
|
||||
// Plain JS toggle — no dependency on Bootstrap's Dropdown JS initialising.
|
||||
// Uses event delegation so it works after OOB swaps repopulate #sortBy.
|
||||
document.addEventListener('click', (e) => {
|
||||
const sortBy = document.getElementById('sortBy');
|
||||
// ── Price mode helpers ────────────────────────────────────────────────────
|
||||
// marketPriceByCondition is injected into the modal HTML via a data attribute
|
||||
// on #inventoryEntryList: data-market-prices='{"Near Mint":6.00,...}'
|
||||
// See card-modal.astro for where this is set.
|
||||
|
||||
// Toggle the menu when the button is clicked
|
||||
function getMarketPrices(form) {
|
||||
const listEl = form.closest('.tab-pane')?.querySelector('#inventoryEntryList')
|
||||
?? document.getElementById('inventoryEntryList');
|
||||
try {
|
||||
return JSON.parse(listEl?.dataset.marketPrices || '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function applyPriceModeUI(form, mode) {
|
||||
const priceInput = form.querySelector('#purchasePrice');
|
||||
const pricePrefix = form.querySelector('#pricePrefix');
|
||||
const priceSuffix = form.querySelector('#priceSuffix');
|
||||
const priceHint = form.querySelector('#priceHint');
|
||||
if (!priceInput) return;
|
||||
|
||||
const isPct = mode === 'percent';
|
||||
pricePrefix?.classList.toggle('d-none', isPct);
|
||||
priceSuffix?.classList.toggle('d-none', !isPct);
|
||||
priceInput.step = isPct ? '1' : '0.01';
|
||||
priceInput.max = isPct ? '100' : '';
|
||||
priceInput.placeholder = isPct ? '0' : '0.00';
|
||||
priceInput.classList.toggle('rounded-end', !isPct);
|
||||
priceInput.classList.toggle('rounded-start', isPct);
|
||||
|
||||
if (priceHint && !isPct) priceHint.textContent = 'Enter the purchase price.';
|
||||
}
|
||||
|
||||
function updatePriceHint(form) {
|
||||
const priceInput = form.querySelector('#purchasePrice');
|
||||
const priceHint = form.querySelector('#priceHint');
|
||||
if (!priceInput || !priceHint) return;
|
||||
|
||||
const mode = form.querySelector('input[name="priceMode"]:checked')?.value ?? 'dollar';
|
||||
if (mode !== 'percent') { priceHint.textContent = 'Enter the purchase price.'; return; }
|
||||
|
||||
const condition = form.querySelector('input[name="condition"]:checked')?.value ?? 'Near Mint';
|
||||
const prices = getMarketPrices(form);
|
||||
const marketPrice = prices[condition] ?? 0;
|
||||
const pct = parseFloat(priceInput.value) || 0;
|
||||
const resolved = ((pct / 100) * marketPrice).toFixed(2);
|
||||
priceHint.textContent = marketPrice
|
||||
? `= $${resolved} (${pct}% of $${marketPrice.toFixed(2)} market)`
|
||||
: 'No market price available for this condition.';
|
||||
}
|
||||
|
||||
function resolveFormPrice(form) {
|
||||
// Returns a FormData ready to POST; % is converted to $ in-place.
|
||||
const data = new FormData(form);
|
||||
const mode = data.get('priceMode');
|
||||
if (mode === 'percent') {
|
||||
const condition = data.get('condition');
|
||||
const prices = getMarketPrices(form);
|
||||
const marketPrice = prices[condition] ?? 0;
|
||||
const pct = parseFloat(data.get('purchasePrice')) || 0;
|
||||
data.set('purchasePrice', ((pct / 100) * marketPrice).toFixed(2));
|
||||
}
|
||||
data.delete('priceMode'); // UI-only field
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Empty state helper ────────────────────────────────────────────────────
|
||||
function syncEmptyState(invList) {
|
||||
const emptyState = document.getElementById('inventoryEmptyState');
|
||||
if (!emptyState) return;
|
||||
const hasEntries = invList.querySelector('[data-inventory-id]') !== null;
|
||||
emptyState.classList.toggle('d-none', hasEntries);
|
||||
}
|
||||
|
||||
// ── Inventory form init (binding price-mode UI events) ───────────────────
|
||||
function initInventoryForms(root = document) {
|
||||
// Fetch inventory entries for this card
|
||||
const invList = root.querySelector('#inventoryEntryList') || document.getElementById('inventoryEntryList');
|
||||
if (invList && !invList.dataset.inventoryFetched) {
|
||||
invList.dataset.inventoryFetched = 'true';
|
||||
const cardId = invList.dataset.cardId;
|
||||
if (cardId) {
|
||||
const body = new FormData();
|
||||
body.append('cardId', cardId);
|
||||
fetch('/api/inventory', { method: 'POST', body })
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
invList.innerHTML = html || '';
|
||||
syncEmptyState(invList);
|
||||
})
|
||||
.catch(() => { invList.innerHTML = '<span class="text-danger">Failed to load inventory</span>'; });
|
||||
}
|
||||
}
|
||||
|
||||
const forms = root.querySelectorAll('[data-inventory-form]');
|
||||
|
||||
forms.forEach((form) => {
|
||||
if (form.dataset.inventoryBound === 'true') return;
|
||||
form.dataset.inventoryBound = 'true';
|
||||
|
||||
const priceInput = form.querySelector('#purchasePrice');
|
||||
const modeInputs = form.querySelectorAll('input[name="priceMode"]');
|
||||
const condInputs = form.querySelectorAll('input[name="condition"]');
|
||||
|
||||
// Set initial UI state
|
||||
const checkedMode = form.querySelector('input[name="priceMode"]:checked')?.value ?? 'dollar';
|
||||
applyPriceModeUI(form, checkedMode);
|
||||
|
||||
// Mode toggle
|
||||
modeInputs.forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
if (priceInput) priceInput.value = ''; // clear stale value on mode switch
|
||||
applyPriceModeUI(form, input.value);
|
||||
updatePriceHint(form);
|
||||
});
|
||||
});
|
||||
|
||||
// Condition change updates the hint when in % mode
|
||||
condInputs.forEach((input) => {
|
||||
input.addEventListener('change', () => updatePriceHint(form));
|
||||
});
|
||||
|
||||
// Live hint as user types
|
||||
priceInput?.addEventListener('input', () => updatePriceHint(form));
|
||||
|
||||
// Reset — restore to $ mode
|
||||
form.addEventListener('reset', () => {
|
||||
setTimeout(() => {
|
||||
applyPriceModeUI(form, 'dollar');
|
||||
updatePriceHint(form);
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sort dropdown ─────────────────────────────────────────────────────────
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('#sortBy [data-bs-toggle="dropdown"]');
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
@@ -64,7 +195,6 @@ import BackToTop from "./BackToTop.astro"
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle sort option selection
|
||||
const opt = e.target.closest('#sortBy .sort-option');
|
||||
if (opt) {
|
||||
e.preventDefault();
|
||||
@@ -87,7 +217,6 @@ import BackToTop from "./BackToTop.astro"
|
||||
return;
|
||||
}
|
||||
|
||||
// Click outside — close any open sort menu
|
||||
const menu = document.querySelector('#sortBy .dropdown-menu.show');
|
||||
if (menu) {
|
||||
menu.classList.remove('show');
|
||||
@@ -96,6 +225,23 @@ import BackToTop from "./BackToTop.astro"
|
||||
}
|
||||
});
|
||||
|
||||
// ── Language toggle ───────────────────────────────────────────────────────
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.language-btn');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
|
||||
const input = document.getElementById('languageInput');
|
||||
if (input) input.value = btn.dataset.lang;
|
||||
|
||||
const start = document.getElementById('start');
|
||||
if (start) start.value = '0';
|
||||
|
||||
document.getElementById('searchform').dispatchEvent(
|
||||
new Event('submit', { bubbles: true, cancelable: true })
|
||||
);
|
||||
});
|
||||
|
||||
// ── Global helpers ────────────────────────────────────────────────────────
|
||||
window.copyImage = async function(img) {
|
||||
try {
|
||||
@@ -103,7 +249,14 @@ import BackToTop from "./BackToTop.astro"
|
||||
const ctx = canvas.getContext("2d");
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const clean = new Image();
|
||||
clean.crossOrigin = 'anonymous';
|
||||
clean.onload = () => { ctx.drawImage(clean, 0, 0); resolve(); };
|
||||
clean.onerror = () => { ctx.drawImage(img, 0, 0); resolve(); };
|
||||
clean.src = img.src;
|
||||
});
|
||||
|
||||
if (navigator.clipboard && navigator.clipboard.write) {
|
||||
const blob = await new Promise((resolve, reject) => {
|
||||
@@ -150,6 +303,20 @@ import BackToTop from "./BackToTop.astro"
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// ── Tab switching helper ──────────────────────────────────────────────────
|
||||
function switchToRequestedTab() {
|
||||
const tab = sessionStorage.getItem('openModalTab');
|
||||
if (!tab) return;
|
||||
sessionStorage.removeItem('openModalTab');
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
const tabEl = document.querySelector(`#cardModal [data-bs-target="#${tab}"]`);
|
||||
if (tabEl) bootstrap.Tab.getOrCreateInstance(tabEl).show();
|
||||
} catch (e) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
const cardIndex = [];
|
||||
let currentCardId = null;
|
||||
@@ -201,7 +368,6 @@ import BackToTop from "./BackToTop.astro"
|
||||
nextBtn.classList.toggle('d-none', next === null);
|
||||
}
|
||||
|
||||
// ── Trigger infinite scroll sentinel ─────────────────────────────────────
|
||||
function tryTriggerSentinel() {
|
||||
const sentinel = cardGrid.querySelector('[hx-trigger="revealed"]');
|
||||
if (!sentinel) return;
|
||||
@@ -212,7 +378,6 @@ import BackToTop from "./BackToTop.astro"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fire card-modal:swapped so the partial's script can init the chart ────
|
||||
function initChartAfterSwap(modal) {
|
||||
const canvas = modal.querySelector('#priceHistoryChart');
|
||||
if (!canvas) return;
|
||||
@@ -239,10 +404,17 @@ import BackToTop from "./BackToTop.astro"
|
||||
|
||||
if (modal._reconnectChartObserver) modal._reconnectChartObserver();
|
||||
|
||||
modal.querySelectorAll('[data-bs-toggle="tab"]').forEach(el => {
|
||||
bootstrap.Tab.getInstance(el)?.dispose();
|
||||
});
|
||||
|
||||
modal.innerHTML = html;
|
||||
|
||||
if (typeof htmx !== 'undefined') htmx.process(modal);
|
||||
initInventoryForms(modal);
|
||||
updateNavButtons(modal);
|
||||
initChartAfterSwap(modal);
|
||||
switchToRequestedTab();
|
||||
};
|
||||
|
||||
if (document.startViewTransition && direction) {
|
||||
@@ -269,11 +441,9 @@ import BackToTop from "./BackToTop.astro"
|
||||
if (next) loadCard(next, 'next');
|
||||
}
|
||||
|
||||
// ── Nav button clicks ─────────────────────────────────────────────────────
|
||||
document.getElementById('modalPrevBtn').addEventListener('click', navigatePrev);
|
||||
document.getElementById('modalNextBtn').addEventListener('click', navigateNext);
|
||||
|
||||
// ── Keyboard ──────────────────────────────────────────────────────────────
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const modal = document.getElementById('cardModal');
|
||||
if (!modal.classList.contains('show')) return;
|
||||
@@ -281,7 +451,6 @@ import BackToTop from "./BackToTop.astro"
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); navigateNext(); }
|
||||
});
|
||||
|
||||
// ── Touch / swipe ─────────────────────────────────────────────────────────
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
const SWIPE_THRESHOLD = 50;
|
||||
@@ -299,7 +468,6 @@ import BackToTop from "./BackToTop.astro"
|
||||
else navigatePrev();
|
||||
}, { passive: true });
|
||||
|
||||
// ── HTMX card-modal opens ─────────────────────────────────────────────────
|
||||
document.body.addEventListener('htmx:beforeRequest', async (e) => {
|
||||
if (e.detail.elt.getAttribute('hx-target') !== '#cardModal') return;
|
||||
|
||||
@@ -331,8 +499,14 @@ import BackToTop from "./BackToTop.astro"
|
||||
|
||||
if (target._reconnectChartObserver) target._reconnectChartObserver();
|
||||
|
||||
target.querySelectorAll('[data-bs-toggle="tab"]').forEach(el => {
|
||||
bootstrap.Tab.getInstance(el)?.dispose();
|
||||
});
|
||||
|
||||
target.innerHTML = html;
|
||||
|
||||
if (typeof htmx !== 'undefined') htmx.process(target);
|
||||
initInventoryForms(target);
|
||||
|
||||
const destImg = target.querySelector('img.card-image');
|
||||
if (destImg) {
|
||||
@@ -349,6 +523,7 @@ import BackToTop from "./BackToTop.astro"
|
||||
await transition.finished;
|
||||
updateNavButtons(target);
|
||||
initChartAfterSwap(target);
|
||||
switchToRequestedTab();
|
||||
|
||||
} catch (err) {
|
||||
console.error('[card-modal] transition failed:', err);
|
||||
@@ -363,16 +538,115 @@ import BackToTop from "./BackToTop.astro"
|
||||
}
|
||||
});
|
||||
|
||||
// ── Bootstrap modal events ────────────────────────────────────────────────
|
||||
const cardModal = document.getElementById('cardModal');
|
||||
|
||||
// ── Delegated submit handler for inventory form ──────────────────────────
|
||||
cardModal.addEventListener('submit', async (e) => {
|
||||
const form = e.target.closest('[data-inventory-form]');
|
||||
if (!form) return;
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.checkValidity()) { form.classList.add('was-validated'); return; }
|
||||
|
||||
const cardId = form.closest('[data-card-id]')?.dataset.cardId;
|
||||
if (!cardId) return;
|
||||
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Saving…'; }
|
||||
|
||||
// resolveFormPrice converts % → $ and strips priceMode before POSTing
|
||||
const body = resolveFormPrice(form);
|
||||
body.append('action', 'add');
|
||||
body.append('cardId', cardId);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/inventory', { method: 'POST', body });
|
||||
const html = await res.text();
|
||||
const invList = document.getElementById('inventoryEntryList');
|
||||
if (invList) {
|
||||
invList.innerHTML = html || '';
|
||||
syncEmptyState(invList);
|
||||
}
|
||||
form.reset();
|
||||
form.classList.remove('was-validated');
|
||||
// reset fires our listener which restores $ mode UI
|
||||
} catch {
|
||||
// keep current inventory list state
|
||||
} finally {
|
||||
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Save to inventory'; }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Delegated click handler for inventory entry buttons ─────────────────
|
||||
cardModal.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-inv-action]');
|
||||
if (!btn) return;
|
||||
|
||||
const article = btn.closest('[data-inventory-id]');
|
||||
if (!article) return;
|
||||
|
||||
const action = btn.dataset.invAction;
|
||||
const inventoryId = article.dataset.inventoryId;
|
||||
const cardId = article.dataset.cardId;
|
||||
const qtyEl = article.querySelector('[data-inv-qty]');
|
||||
let qty = Number(qtyEl?.textContent) || 1;
|
||||
|
||||
if (action === 'increment') {
|
||||
qtyEl.textContent = ++qty;
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'decrement') {
|
||||
if (qty > 1) qtyEl.textContent = --qty;
|
||||
return;
|
||||
}
|
||||
|
||||
// update or remove — POST to API and reload inventory list
|
||||
btn.disabled = true;
|
||||
const body = new FormData();
|
||||
body.append('cardId', cardId);
|
||||
|
||||
if (action === 'update') {
|
||||
body.append('action', 'update');
|
||||
body.append('inventoryId', inventoryId);
|
||||
body.append('quantity', String(qty));
|
||||
body.append('purchasePrice', article.dataset.purchasePrice);
|
||||
body.append('note', article.dataset.note || '');
|
||||
} else if (action === 'remove') {
|
||||
body.append('action', 'remove');
|
||||
body.append('inventoryId', inventoryId);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/inventory', { method: 'POST', body });
|
||||
const html = await res.text();
|
||||
const invList = document.getElementById('inventoryEntryList');
|
||||
if (invList) {
|
||||
invList.innerHTML = html || '';
|
||||
syncEmptyState(invList);
|
||||
}
|
||||
} catch {
|
||||
// keep current state
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
cardModal.addEventListener('shown.bs.modal', () => {
|
||||
updateNavButtons(cardModal);
|
||||
initChartAfterSwap(cardModal);
|
||||
initInventoryForms(cardModal);
|
||||
switchToRequestedTab();
|
||||
});
|
||||
|
||||
cardModal.addEventListener('hidden.bs.modal', () => {
|
||||
currentCardId = null;
|
||||
updateNavButtons(null);
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initInventoryForms();
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
37
src/components/InventoryTable.astro
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
const mockInventory = [
|
||||
{ name: "Charizard", set: "Base Set", condition: "NM", qty: 2, price: 350, market: 400, gain: 50 },
|
||||
{ name: "Pikachu", set: "Shining Legends", condition: "LP", qty: 5, price: 15, market: 20, gain: 5 },
|
||||
];
|
||||
---
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Card</th>
|
||||
<th>Set</th>
|
||||
<th>Condition</th>
|
||||
<th>Qty</th>
|
||||
<th>Price</th>
|
||||
<th>Market</th>
|
||||
<th>Gain/Loss</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockInventory.map(card => (
|
||||
<tr>
|
||||
<td>{card.name}</td>
|
||||
<td>{card.set}</td>
|
||||
<td>{card.condition}</td>
|
||||
<td>{card.qty}</td>
|
||||
<td>${card.price}</td>
|
||||
<td>${card.market}</td>
|
||||
<td class={card.gain >= 0 ? "text-success" : "text-danger"}>
|
||||
{card.gain >= 0 ? "+" : "-"}${Math.abs(card.gain)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -26,15 +26,21 @@ import { Show } from '@clerk/astro/components'
|
||||
</script>
|
||||
|
||||
<Show when="signed-in">
|
||||
<form class="d-flex ms-2" role="search" id="searchform" hx-post="/partials/cards" hx-target="#cardGrid" hx-trigger="load, submit" hx-vals='{"start":"0"}' hx-on--after-request="afterUpdate()" hx-on--before-request="beforeSearch()">
|
||||
<a class="btn btn-secondary btn-lg me-2" data-bs-toggle="offcanvas" href="#filterBar" role="button" aria-controls="filterBar" aria-label="filter"><span class="d-block d-md-none filter-icon mt-1"><svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg></span><span class="d-none d-md-block">Filters</span></a>
|
||||
<form class="d-flex ms-2 align-items-center gap-2" role="search" id="searchform" hx-post="/partials/cards" hx-target="#cardGrid" hx-trigger="load, submit" hx-vals='{"start":"0"}' hx-on--after-request="afterUpdate()" hx-on--before-request="beforeSearch()">
|
||||
<a class="btn btn-secondary btn-lg" data-bs-toggle="offcanvas" href="#filterBar" role="button" aria-controls="filterBar" aria-label="filter">
|
||||
<span class="d-block d-md-none filter-icon py-2">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg>
|
||||
</span>
|
||||
<span class="d-none d-md-block">Filters</span>
|
||||
</a>
|
||||
|
||||
<div class="input-group">
|
||||
<input type="hidden" name="start" id="start" value="0" />
|
||||
<input type="hidden" name="sort" id="sortInput" value="" />
|
||||
<input type="search" name="q" class="form-control form-control-lg" placeholder="Search cards..." />
|
||||
<button type="submit" class="btn btn-danger btn-lg border border-danger-subtle border-start-0" aria-label="search" value="" onclick="const q = this.closest('form').querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });">
|
||||
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M432 272C432 183.6 360.4 112 272 112C183.6 112 112 183.6 112 272C112 360.4 183.6 432 272 432C360.4 432 432 360.4 432 272zM401.1 435.1C365.7 463.2 320.8 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272C480 320.8 463.2 365.7 435.1 401.1L569 535C578.4 544.4 578.4 558.1 569 567.5C559.6 575.9 544.4 575.9 536 567.5L401.1 435.1z"/></svg>
|
||||
</button>
|
||||
<input type="hidden" name="start" id="start" value="0" />
|
||||
<input type="hidden" name="sort" id="sortInput" value="" />
|
||||
<input type="hidden" name="language" id="languageInput" value="all" />
|
||||
<input type="search" name="q" class="form-control form-control-lg" placeholder="Search cards..." />
|
||||
<button type="submit" class="btn btn-danger btn-lg border border-danger-subtle border-start-0" aria-label="search" value="" onclick="const q = this.closest('form').querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });">
|
||||
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M432 272C432 183.6 360.4 112 272 112C183.6 112 112 183.6 112 272C112 360.4 183.6 432 272 432C360.4 432 432 360.4 432 272zM401.1 435.1C365.7 463.2 320.8 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272C480 320.8 463.2 365.7 435.1 401.1L569 535C578.4 544.4 578.4 558.1 569 567.5C559.6 575.9 544.4 575.9 536 567.5L401.1 435.1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Show>
|
||||
</form>
|
||||
@@ -124,6 +124,7 @@ import phantasmal_flames from "/src/svg/set/phantasmal_flames.svg?raw";
|
||||
import destined_rivals from "/src/svg/set/destined_rivals.svg?raw";
|
||||
import surging_sparks from "/src/svg/set/surging_sparks.svg?raw";
|
||||
import team_rocket from "/src/svg/set/team_rocket.svg?raw";
|
||||
import perfect_order from "/src/svg/set/perfect_order.svg?raw";
|
||||
|
||||
const { set } = Astro.props;
|
||||
|
||||
@@ -252,6 +253,7 @@ const setMap = {
|
||||
"ASC": ascended_heroes,
|
||||
"DRI": destined_rivals,
|
||||
"SSP": surging_sparks,
|
||||
"ME03": perfect_order,
|
||||
};
|
||||
|
||||
const svg = setMap[set as keyof typeof setMap] ?? "";
|
||||
|
||||
@@ -21,9 +21,21 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
}),
|
||||
history: r.many.priceHistory(),
|
||||
latestSales: r.many.salesHistory(),
|
||||
inventories: r.many.inventory(),
|
||||
},
|
||||
inventory: {
|
||||
card: r.one.cards({
|
||||
from: r.inventory.cardId,
|
||||
to: r.cards.cardId,
|
||||
}),
|
||||
sku: r.one.skus({
|
||||
from: [r.inventory.cardId, r.inventory.condition],
|
||||
to: [r.skus.cardId, r.skus.condition],
|
||||
}),
|
||||
},
|
||||
cards: {
|
||||
prices: r.many.skus(),
|
||||
inventories: r.many.inventory(),
|
||||
set: r.one.sets({
|
||||
from: r.cards.setId,
|
||||
to: r.sets.setId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//import { mysqlTable, int, varchar, boolean, decimal, datetime, index } from "drizzle-orm/mysql-core"
|
||||
import { integer, varchar, boolean, decimal, timestamp, index, pgSchema, uniqueIndex, primaryKey } from "drizzle-orm/pg-core";
|
||||
import { integer, varchar, boolean, decimal, timestamp, index, pgSchema, uuid, primaryKey } from "drizzle-orm/pg-core";
|
||||
|
||||
export const pokeSchema = pgSchema("pokemon");
|
||||
|
||||
@@ -97,7 +97,8 @@ export const skus = pokeSchema.table('skus', {
|
||||
priceCount: integer(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_product_id_condition').on(table.productId, table.variant),
|
||||
index('idx_product_id_condition').on(table.productId, table.variant, table.condition),
|
||||
index('idx_card_id_condition').on(table.cardId, table.condition),
|
||||
]);
|
||||
|
||||
export const priceHistory = pokeSchema.table('price_history', {
|
||||
@@ -124,6 +125,22 @@ export const salesHistory = pokeSchema.table('sales_history',{
|
||||
primaryKey({ name: 'pk_sales_history', columns: [table.skuId, table.orderDate] })
|
||||
]);
|
||||
|
||||
export const inventory = pokeSchema.table('inventory',{
|
||||
inventoryId: uuid().primaryKey().notNull().defaultRandom(),
|
||||
userId: varchar({ length: 100 }).notNull(),
|
||||
catalogName: varchar({ length: 100 }),
|
||||
cardId: integer().notNull(),
|
||||
condition: varchar({ length: 255 }).notNull(),
|
||||
variant: varchar({ length: 100 }).default('Normal'),
|
||||
quantity: integer(),
|
||||
purchasePrice: decimal({ precision: 10, scale: 2 }),
|
||||
note: varchar({ length:255 }),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_userid_cardid').on(table.userId, table.cardId)
|
||||
]);
|
||||
|
||||
export const processingSkus = pokeSchema.table('processing_skus', {
|
||||
skuId: integer().primaryKey(),
|
||||
});
|
||||
|
||||
182
src/pages/api/inventory.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../db/index';
|
||||
import { inventory, skus, cards } from '../../db/schema';
|
||||
import { client } from '../../db/typesense';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
|
||||
const GainLoss = (purchasePrice: any, marketPrice: any) => {
|
||||
if (!purchasePrice || !marketPrice) return '<div class="fs-5 fw-semibold">N/A</div>';
|
||||
const pp = Number(purchasePrice);
|
||||
const mp = Number(marketPrice);
|
||||
if (pp === mp) return '<div class="fs-5 fw-semibold text-warning">-</div>';
|
||||
if (pp > mp) return `<div class="fs-5 fw-semibold text-critical">-$${(pp - mp).toFixed(2)}</div>`;
|
||||
return `<div class="fs-6 fw-semibold text-success">+$${(mp - pp).toFixed(2)}</div>`;
|
||||
}
|
||||
|
||||
const getInventory = async (userId: string, cardId: number) => {
|
||||
|
||||
const inventories = await db
|
||||
.select({
|
||||
inventoryId: inventory.inventoryId,
|
||||
cardId: inventory.cardId,
|
||||
condition: inventory.condition,
|
||||
variant: inventory.variant,
|
||||
quantity: inventory.quantity,
|
||||
purchasePrice: inventory.purchasePrice,
|
||||
note: inventory.note,
|
||||
marketPrice: skus.marketPrice,
|
||||
createdAt: inventory.createdAt,
|
||||
})
|
||||
.from(inventory)
|
||||
.leftJoin(
|
||||
cards,
|
||||
eq(inventory.cardId, cards.cardId)
|
||||
)
|
||||
.leftJoin(
|
||||
skus,
|
||||
and(
|
||||
eq(cards.productId, skus.productId),
|
||||
eq(inventory.condition, skus.condition),
|
||||
eq(
|
||||
sql`COALESCE(${inventory.variant}, 'Normal')`,
|
||||
skus.variant
|
||||
)
|
||||
)
|
||||
)
|
||||
.where(and(
|
||||
eq(inventory.userId, userId),
|
||||
eq(inventory.cardId, cardId)
|
||||
));
|
||||
|
||||
const invHtml = inventories.map(inv => {
|
||||
const marketPrice = inv.marketPrice ? Number(inv.marketPrice).toFixed(2) : null;
|
||||
const marketPriceDisplay = marketPrice ? `$${marketPrice}` : '—';
|
||||
const purchasePriceDisplay = inv.purchasePrice ? `$${Number(inv.purchasePrice).toFixed(2)}` : '—';
|
||||
|
||||
return `
|
||||
<article class="border rounded-4 p-2 inventory-entry-card"
|
||||
data-inventory-id="${inv.inventoryId}"
|
||||
data-card-id="${inv.cardId}"
|
||||
data-purchase-price="${inv.purchasePrice}"
|
||||
data-note="${(inv.note || '').replace(/"/g, '"')}">
|
||||
<div class="d-flex flex-column">
|
||||
<!-- Top row -->
|
||||
<div class="d-flex justify-content-between gap-3">
|
||||
<div class="min-w-0 flex-grow-1">
|
||||
<div class="fw-semibold fs-6 text-body mb-1">${inv.condition}</div>
|
||||
</div>
|
||||
<div class="fs-7 text-secondary">Added: ${inv.createdAt ? new Date(inv.createdAt).toLocaleDateString() : '—'}</div>
|
||||
</div>
|
||||
<!-- Middle row -->
|
||||
<div class="row g-2">
|
||||
<div class="col-4">
|
||||
<div class="small text-secondary">Purchase price</div>
|
||||
<div class="fs-6 fw-semibold">${purchasePriceDisplay}</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="small text-secondary">Market price</div>
|
||||
<div class="fs-6 text-success">${marketPriceDisplay}</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="small text-secondary">Gain / loss</div>
|
||||
${GainLoss(inv.purchasePrice, marketPrice)}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom row -->
|
||||
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap mt-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="small text-secondary">Qty</span>
|
||||
<div class="btn-group" role="group" aria-label="Quantity controls">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-inv-action="decrement">−</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" tabindex="-1" data-inv-qty>${inv.quantity}</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-inv-action="increment">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-inv-action="update">Edit</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" data-inv-action="remove" onclick="if(!confirm('Are you sure you want to remove this card from your inventory?')) event.stopImmediatePropagation();">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>`;
|
||||
});
|
||||
|
||||
return new Response(
|
||||
invHtml.join(''),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const addToInventory = async (userId: string, cardId: number, condition: string, variant: string, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
|
||||
const inv = await db.insert(inventory).values({
|
||||
userId: userId,
|
||||
cardId: cardId,
|
||||
catalogName: catalogName,
|
||||
condition: condition,
|
||||
variant: variant,
|
||||
purchasePrice: purchasePrice,
|
||||
quantity: quantity,
|
||||
note: note,
|
||||
}).returning();
|
||||
await client.collections('inventories').documents().import(inv.map(i => ({
|
||||
id: i.inventoryId,
|
||||
userId: i.userId,
|
||||
catalogName: i.catalogName,
|
||||
card_id: i.cardId.toString(),
|
||||
})));
|
||||
}
|
||||
|
||||
const removeFromInventory = async (inventoryId: string) => {
|
||||
await db.delete(inventory).where(eq(inventory.inventoryId, inventoryId));
|
||||
await client.collections('inventories').documents(inventoryId).delete();
|
||||
}
|
||||
|
||||
const updateInventory = async (inventoryId: string, quantity: number, purchasePrice: number, note: string) => {
|
||||
await db.update(inventory).set({
|
||||
quantity: quantity,
|
||||
purchasePrice: purchasePrice,
|
||||
note: note,
|
||||
}).where(eq(inventory.inventoryId, inventoryId));
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action');
|
||||
const cardId = Number(formData.get('cardId')) || 0;
|
||||
const { userId } = locals.auth();
|
||||
|
||||
switch (action) {
|
||||
|
||||
case 'add':
|
||||
const condition = formData.get('condition')?.toString() || 'Unknown';
|
||||
const variant = formData.get('variant')?.toString() || 'Normal';
|
||||
const purchasePrice = Number(formData.get('purchasePrice')) || 0;
|
||||
const quantity = Number(formData.get('quantity')) || 1;
|
||||
const note = formData.get('note')?.toString() || '';
|
||||
const catalogName = formData.get('catalogName')?.toString() || 'Default';
|
||||
await addToInventory(userId!, cardId, condition, variant, purchasePrice, quantity, note, catalogName);
|
||||
break;
|
||||
|
||||
case 'remove':
|
||||
const inventoryId = formData.get('inventoryId')?.toString() || '';
|
||||
await removeFromInventory(inventoryId);
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
const invId = formData.get('inventoryId')?.toString() || '';
|
||||
const qty = Number(formData.get('quantity')) || 1;
|
||||
const price = Number(formData.get('purchasePrice')) || 0;
|
||||
const invNote = formData.get('note')?.toString() || '';
|
||||
await updateInventory(invId, qty, price, invNote);
|
||||
break;
|
||||
|
||||
default:
|
||||
return getInventory(userId!, cardId);
|
||||
}
|
||||
|
||||
return getInventory(userId!, cardId);
|
||||
};
|
||||
95
src/pages/api/upload.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// src/pages/api/upload.ts
|
||||
import type { APIRoute } from 'astro';
|
||||
import { parse, stringify, transform } from 'csv';
|
||||
import { Readable } from 'stream';
|
||||
import { client } from '../../db/typesense';
|
||||
import chalk from 'chalk';
|
||||
import { db, ClosePool } from '../../db/index';
|
||||
|
||||
// Define the transformation logic
|
||||
const transformer = transform({ parallel: 1 }, async function(this: any, row: any, callback: any) {
|
||||
try {
|
||||
// Specific query bsaed on tcgcollector CSV
|
||||
const query = String(Object.values(row)[1]);
|
||||
const setname = String(Object.values(row)[4]).replace(/Wizards of the coast promos/ig,'WoTC Promo');
|
||||
const cardNumber = String(Object.values(row)[7]);
|
||||
console.log(`${query} ${cardNumber} : ${setname}`);
|
||||
|
||||
// Use Typesense to find the card because we can easily use the combined fields
|
||||
let cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `setName:\`${setname}\` && number:${cardNumber}` });
|
||||
if (cards.hits?.length === 0) {
|
||||
// Try without card number
|
||||
cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `setName:\`${setname}\`` });
|
||||
}
|
||||
if (cards.hits?.length === 0) {
|
||||
// Try without set name
|
||||
cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `number:${cardNumber}` });
|
||||
}
|
||||
if (cards.hits?.length === 0) {
|
||||
// I give up, just output the values from the csv
|
||||
console.log(chalk.red(' - not found'));
|
||||
const newRow = { ...row };
|
||||
newRow.Variant = '';
|
||||
newRow.marketPrice = '';
|
||||
this.push(newRow);
|
||||
}
|
||||
else {
|
||||
for (const card of cards.hits?.map((hit: any) => hit.document) ?? []) {
|
||||
console.log(chalk.blue(` - ${card.cardId} : ${card.productName} : ${card.number}`), chalk.yellow(`${card.setName}`), chalk.green(`${card.variant}`));
|
||||
const variant = await db.query.cards.findFirst({
|
||||
with: { prices: true, tcgdata: true },
|
||||
where: { cardId: card.cardId }
|
||||
});
|
||||
const newRow = { ...row };
|
||||
newRow.Variant = variant?.variant;
|
||||
newRow.marketPrice = variant?.prices.find(p => p.condition === 'Near Mint')?.marketPrice;
|
||||
this.push(newRow);
|
||||
}
|
||||
}
|
||||
|
||||
callback();
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
const inputStream = Readable.from(file.stream());
|
||||
|
||||
if (!file) {
|
||||
return new Response('No file uploaded', { status: 400 });
|
||||
}
|
||||
|
||||
// Pipe the streams: Read -> Parse -> Transform -> Stringify -> Write
|
||||
const outputStream = inputStream
|
||||
.on('error', (error) => console.error('Input stream error:', error))
|
||||
.pipe(parse({ columns: true, trim: true }))
|
||||
.on('error', (error) => console.error('Parse error:', error))
|
||||
.pipe(transformer)
|
||||
.on('error', (error) => console.error('Transform error:', error))
|
||||
.pipe(stringify({ header: true }))
|
||||
.on('error', (error) => console.error('Stringify error:', error));
|
||||
|
||||
// outputStream.on('finish', () => {
|
||||
// ClosePool();
|
||||
// }).on('error', (error) => {
|
||||
// ClosePool();
|
||||
// });
|
||||
|
||||
|
||||
return new Response(outputStream as any, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-Disposition': 'attachment; filename=transformed.csv',
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing CSV stream:', error);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
};
|
||||
1048
src/pages/dashboard.astro
Normal file
26
src/pages/myprices.astro
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import Layout from '../layouts/Main.astro';
|
||||
import NavItems from '../components/NavItems.astro';
|
||||
import NavBar from '../components/NavBar.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
|
||||
---
|
||||
<Layout title="Rigid's App Thing">
|
||||
<NavBar slot="navbar">
|
||||
<NavItems slot="navItems" />
|
||||
</NavBar>
|
||||
<div class="row mb-4" slot="page">
|
||||
<div class="col-12">
|
||||
<h1>Rigid's App Thing</h1>
|
||||
<p class="text-secondary">(working title)</p>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<!-- src/components/FileUploader.astro -->
|
||||
<form action="/api/upload" method="post" enctype="multipart/form-data">
|
||||
<input type="file" name="file" accept=".csv" required />
|
||||
<button type="submit">Upload CSV</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<Footer slot="footer" />
|
||||
</Layout>
|
||||
@@ -10,6 +10,18 @@ import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
|
||||
|
||||
import { Tooltip } from "bootstrap";
|
||||
|
||||
import { clerkClient } from '@clerk/astro/server';
|
||||
|
||||
const { userId, has } = Astro.locals.auth();
|
||||
const TARGET_ORG_ID = 'org_3Baav9czkRLLlC7g89oJWqRRulK';
|
||||
|
||||
let hasAccess = has({ feature: 'inventory_add' });
|
||||
|
||||
if (!hasAccess && userId) {
|
||||
const memberships = await clerkClient(Astro).users.getOrganizationMembershipList({ userId });
|
||||
hasAccess = memberships.data.some(m => m.organization.id === TARGET_ORG_ID);
|
||||
}
|
||||
|
||||
export const partial = true;
|
||||
export const prerender = false;
|
||||
|
||||
@@ -46,7 +58,7 @@ const calculatedAt = (() => {
|
||||
const dates = card.prices
|
||||
.map(p => p.calculatedAt)
|
||||
.filter(d => d)
|
||||
.map(d => new Date(d));
|
||||
.map(d => new Date(d!));
|
||||
if (!dates.length) return null;
|
||||
return new Date(Math.max(...dates.map(d => d.getTime())));
|
||||
})();
|
||||
@@ -161,6 +173,17 @@ const conditionAttributes = (price: any) => {
|
||||
}[condition];
|
||||
};
|
||||
|
||||
// ── Build a market price lookup keyed by condition for use in JS ──────────
|
||||
const marketPriceByCondition: Record<string, number> = {};
|
||||
for (const price of card?.prices ?? []) {
|
||||
if (price.condition && price.marketPrice != null) {
|
||||
marketPriceByCondition[price.condition] = Number(price.marketPrice);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Derive distinct variants available for this card ─────────────────────
|
||||
const availableVariants = [...new Set(cardSkus.map(s => s.variant))].sort();
|
||||
|
||||
const ebaySearchUrl = (card: any) => {
|
||||
return `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&LH_Sold=1&Graded=No&_dcat=183454`;
|
||||
};
|
||||
@@ -168,8 +191,8 @@ const ebaySearchUrl = (card: any) => {
|
||||
const altSearchUrl = (card: any) => {
|
||||
return `https://alt.xyz/browse?query=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&sortBy=newest_first`;
|
||||
};
|
||||
---
|
||||
|
||||
---
|
||||
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
|
||||
<div class="modal-content" data-card-id={card?.cardId}>
|
||||
<div class="modal-header border-0">
|
||||
@@ -190,13 +213,26 @@ const altSearchUrl = (card: any) => {
|
||||
<!-- Card image column -->
|
||||
<div class="col-sm-12 col-md-3">
|
||||
<div class="position-relative mt-1">
|
||||
<img
|
||||
src={`/cards/${card?.productId}.jpg`}
|
||||
class="card-image w-100 img-fluid rounded-4"
|
||||
alt={card?.productName}
|
||||
onerror="this.onerror=null;this.src='/cards/default.jpg'"
|
||||
onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});"
|
||||
/>
|
||||
|
||||
<!-- card-image-wrap gives the modal image shimmer effects
|
||||
without the hover lift/scale that image-grow has in main.scss -->
|
||||
<div
|
||||
class="card-image-wrap rounded-4"
|
||||
data-energy={card?.energyType}
|
||||
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>
|
||||
@@ -213,34 +249,36 @@ const altSearchUrl = (card: any) => {
|
||||
<ul class="nav nav-tabs nav-fill border-0 me-3 mb-2" id="myTab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link nm active" id="nm-tab" data-bs-toggle="tab" data-bs-target="#nav-nm" type="button" role="tab" aria-controls="nav-nm" aria-selected="true">
|
||||
<span class="d-none d-xxl-inline">Near Mint</span><span class="d-xxl-none">NM</span>
|
||||
<span class="d-none">Near Mint</span><span class="d-inline">NM</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link lp" id="lp-tab" data-bs-toggle="tab" data-bs-target="#nav-lp" type="button" role="tab" aria-controls="nav-lp" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Lightly Played</span><span class="d-xxl-none">LP</span>
|
||||
<span class="d-none">Lightly Played</span><span class="d-inline">LP</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link mp" id="mp-tab" data-bs-toggle="tab" data-bs-target="#nav-mp" type="button" role="tab" aria-controls="nav-mp" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Moderately Played</span><span class="d-xxl-none">MP</span>
|
||||
<span class="d-none">Moderately Played</span><span class="d-inline">MP</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link hp" id="hp-tab" data-bs-toggle="tab" data-bs-target="#nav-hp" type="button" role="tab" aria-controls="nav-hp" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Heavily Played</span><span class="d-xxl-none">HP</span>
|
||||
<span class="d-none">Heavily Played</span><span class="d-inline">HP</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link dmg" id="dmg-tab" data-bs-toggle="tab" data-bs-target="#nav-dmg" type="button" role="tab" aria-controls="nav-dmg" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Damaged</span><span class="d-xxl-none">DMG</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link vendor d-none" id="vendor-tab" data-bs-toggle="tab" data-bs-target="#nav-vendor" type="button" role="tab" aria-controls="nav-vendor" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Inventory</span><span class="d-xxl-none">+/-</span>
|
||||
<span class="d-none">Damaged</span><span class="d-inline">DMG</span>
|
||||
</button>
|
||||
</li>
|
||||
{hasAccess && (
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link vendor" id="vendor-tab" data-bs-toggle="tab" data-bs-target="#nav-vendor" type="button" role="tab" aria-controls="nav-vendor" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Inventory</span><span class="d-xxl-none">+/-</span>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="myTabContent">
|
||||
@@ -266,9 +304,9 @@ const altSearchUrl = (card: any) => {
|
||||
</div>
|
||||
<div class={`alert rounded p-2 flex-fill d-flex flex-column mb-0 ${attributes?.volatilityClass}`}>
|
||||
<h6 class="mb-auto d-flex justify-content-between align-items-start">
|
||||
<span>Volatility</span>
|
||||
<span class="me-1">Volatility</span>
|
||||
<span
|
||||
class="volatility-info"
|
||||
class="volatility-info float-end mt-0"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
data-bs-container="body"
|
||||
@@ -297,7 +335,7 @@ const altSearchUrl = (card: any) => {
|
||||
|
||||
<!-- Table only — chart is outside the tab panes -->
|
||||
<div class="w-100">
|
||||
<div class="alert alert-dark rounded p-2 mb-0 table-responsive">
|
||||
<div class="alert alert-dark rounded p-2 mb-0 table-responsive d-none">
|
||||
<h6>Latest Verified Sales</h6>
|
||||
<table class="table table-sm mb-0">
|
||||
<caption class="small">Filtered to remove mismatched language variants</caption>
|
||||
@@ -323,12 +361,204 @@ const altSearchUrl = (card: any) => {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasAccess && (
|
||||
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<h6 class="mt-1 mb-2">Add {card?.productName} to inventory</h6>
|
||||
|
||||
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0"></div>
|
||||
<form id="inventoryForm" data-inventory-form novalidate>
|
||||
<div class="row gx-3 gy-1">
|
||||
<div class="col-3">
|
||||
<label for="quantity" class="form-label">Quantity</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control mt-1"
|
||||
id="quantity"
|
||||
name="quantity"
|
||||
min="1"
|
||||
step="1"
|
||||
value="1"
|
||||
required
|
||||
/>
|
||||
<div class="invalid-feedback">Required.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-9">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2 flex-wrap">
|
||||
<label for="purchasePrice" class="form-label">
|
||||
Purchase price
|
||||
</label>
|
||||
|
||||
<div class="btn-group btn-group-sm price-toggle" role="group" aria-label="Price mode">
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="priceMode"
|
||||
id="mode-dollar"
|
||||
value="dollar"
|
||||
autocomplete="off"
|
||||
checked
|
||||
/>
|
||||
<label class="btn btn-outline-secondary" for="mode-dollar">$</label>
|
||||
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="priceMode"
|
||||
id="mode-percent"
|
||||
value="percent"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<label class="btn btn-outline-secondary" for="mode-percent">%</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<span class="input-group-text mt-1" id="pricePrefix">$</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control mt-1 rounded-end"
|
||||
id="purchasePrice"
|
||||
name="purchasePrice"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
aria-describedby="pricePrefix priceSuffix priceHint"
|
||||
required
|
||||
/>
|
||||
<span class="input-group-text d-none mt-1" id="priceSuffix">%</span>
|
||||
</div>
|
||||
|
||||
<div class="form-text" id="priceHint">Enter the purchase price.</div>
|
||||
<div class="invalid-feedback">Enter a purchase price.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Condition</label>
|
||||
<div class="btn-group condition-input w-100" 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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<label class="btn btn-cond-dmg" for="cond-dmg">DMG</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="variant" value={card?.variant} />
|
||||
|
||||
<div class="col-12">
|
||||
<label for="catalogName" class="form-label">
|
||||
Catalog
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="catalogName"
|
||||
name="catalogName"
|
||||
list="catalogSuggestions"
|
||||
placeholder="Default"
|
||||
autocomplete="off"
|
||||
maxlength="100"
|
||||
/>
|
||||
<datalist id="catalogSuggestions">
|
||||
|
||||
</datalist>
|
||||
<div class="form-text">
|
||||
Type a name or pick an existing catalog.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="note" class="form-label">
|
||||
Note
|
||||
</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="note"
|
||||
name="note"
|
||||
rows="2"
|
||||
maxlength="255"
|
||||
placeholder="e.g. bought at local shop, gift, graded copy…"
|
||||
></textarea>
|
||||
<div class="form-text text-end" id="noteCount">0 / 255</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex gap-3 pt-2">
|
||||
<button type="reset" class="btn btn-outline-danger flex-fill">Reset</button>
|
||||
<button type="submit" class="btn btn-success flex-fill">Save to inventory</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<h6 class="mt-1 mb-2">Inventory entries for {card?.productName}</h6>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div class="alert alert-dark border-0 rounded-4 d-none" id="inventoryEmptyState">
|
||||
<div class="fw-medium mb-1">No inventory entries yet</div>
|
||||
<div class="text-secondary small">
|
||||
Once you add copies of this card, they'll show up here.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inventory list -->
|
||||
<div class="d-flex flex-column gap-3" id="inventoryEntryList" data-card-id={cardId} data-market-prices={JSON.stringify(marketPriceByCondition)}>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Chart lives permanently outside tab-content so Bootstrap never hides it. -->
|
||||
<div class="d-block d-lg-flex gap-1 mt-1">
|
||||
<div class="d-block d-lg-flex gap-1 mt-1 price-chart-container">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-dark rounded p-2 mb-0">
|
||||
<h6>Market Price History</h6>
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
---
|
||||
import { client } from '../../db/typesense';
|
||||
import { clerkClient } from '@clerk/astro/server';
|
||||
|
||||
const { userId, has } = Astro.locals.auth();
|
||||
const TARGET_ORG_ID = 'org_3Baav9czkRLLlC7g89oJWqRRulK';
|
||||
|
||||
let hasAccess = has({ feature: 'inventory_add' });
|
||||
|
||||
if (!hasAccess && userId) {
|
||||
const memberships = await clerkClient(Astro).users.getOrganizationMembershipList({ userId });
|
||||
hasAccess = memberships.data.some(m => m.organization.id === TARGET_ORG_ID);
|
||||
}
|
||||
|
||||
import RarityIcon from '../../components/RarityIcon.astro';
|
||||
import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
|
||||
export const prerender = false;
|
||||
@@ -9,7 +21,7 @@ import * as util from 'util';
|
||||
|
||||
// all the facet fields we want to use for filtering
|
||||
const facetFields:any = {
|
||||
"productLineName": "Product Line",
|
||||
//"productLineName": "Product Line",
|
||||
"setName": "Set",
|
||||
"variant": "Variant",
|
||||
"rarityName": "Rarity",
|
||||
@@ -18,11 +30,6 @@ const facetFields:any = {
|
||||
}
|
||||
|
||||
// ── Allowed sort values ───────────────────────────────────────────────────
|
||||
// Maps the client-supplied key to the actual Typesense sort_by string.
|
||||
// Never pass raw user input directly to sort_by.
|
||||
// Note: price sorting uses nmMarketPrice — a field you need to denormalize
|
||||
// onto your card document in your Typesense indexing step (NM market price
|
||||
// as an integer in cents, e.g. nmMarketPrice: 499 = $4.99).
|
||||
const sortMap: Record<string, string> = {
|
||||
'releaseDate:desc,number:asc': '_text_match:asc,releaseDate:desc,number:asc',
|
||||
'releaseDate:asc,number:asc': '_text_match:asc,releaseDate:asc,number:asc',
|
||||
@@ -40,8 +47,32 @@ const start = Number(formData.get('start')?.toString() || '0');
|
||||
const sortKey = formData.get('sort')?.toString() || '';
|
||||
const resolvedSort = sortMap[sortKey] ?? DEFAULT_SORT;
|
||||
|
||||
// ── Language filter ───────────────────────────────────────────────────────
|
||||
// Expects a `language` field on your card documents in Typesense.
|
||||
// Valid values: 'en', 'jp' — anything else (or 'all') means no filter.
|
||||
const language = formData.get('language')?.toString() || 'all';
|
||||
const languageFilter = language === 'en' ? " && productLineName:=`Pokemon`"
|
||||
: language === 'jp' ? " && productLineName:=`Pokemon Japan`"
|
||||
: '';
|
||||
|
||||
// ── Query alias expansion ─────────────────────────────────────────────────
|
||||
// Intercepts known shorthand queries that can't be handled by Typesense
|
||||
// synonyms alone (e.g. terms that need to match across multiple set names)
|
||||
// and rewrites them into a direct filter, clearing the query so it doesn't
|
||||
// also try to text-match against card names.
|
||||
const EREADER_SETS = ['Expedition Base Set', 'Aquapolis', 'Skyridge', 'Battle-e'];
|
||||
const EREADER_RE = /^(e-?reader|e reader)$/i;
|
||||
|
||||
let resolvedQuery = query;
|
||||
let queryFilter = '';
|
||||
|
||||
if (EREADER_RE.test(query.trim())) {
|
||||
resolvedQuery = '';
|
||||
queryFilter = `setName:=[${EREADER_SETS.map(s => '`' + s + '`').join(',')}]`;
|
||||
}
|
||||
|
||||
const filters = Array.from(formData.entries())
|
||||
.filter(([key, value]) => key !== 'q' && key !== 'start' && key !== 'sort')
|
||||
.filter(([key]) => key !== 'q' && key !== 'start' && key !== 'sort' && key !== 'language')
|
||||
.reduce((acc, [key, value]) => {
|
||||
if (!acc[key]) {
|
||||
acc[key] = [];
|
||||
@@ -63,14 +94,15 @@ const facetFilter = (facet:string) => {
|
||||
.filter(([field]) => field !== facet)
|
||||
.map(([field, values]) => `${field}:=[${values.map(v => '`'+v+'`').join(',')}]`)
|
||||
.join(' && ');
|
||||
return `sealed:false${otherFilters ? ` && ${otherFilters}` : ''}`;
|
||||
// Language filter is always included so facet counts stay accurate
|
||||
return `sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${otherFilters ? ` && ${otherFilters}` : ''}`;
|
||||
};
|
||||
|
||||
|
||||
// primary search values (for cards)
|
||||
let searchArray = [{
|
||||
collection: 'cards',
|
||||
filter_by: `sealed:false${filterBy ? ` && ${filterBy}` : ''}`,
|
||||
filter_by: `sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${filterBy ? ` && ${filterBy}` : ''}`,
|
||||
per_page: 20,
|
||||
facet_by: '',
|
||||
max_facet_values: 0,
|
||||
@@ -97,8 +129,8 @@ if (start === 0) {
|
||||
|
||||
const searchRequests = { searches: searchArray };
|
||||
const commonSearchParams = {
|
||||
q: query,
|
||||
query_by: 'content'
|
||||
q: resolvedQuery,
|
||||
query_by: 'content,setName,productLineName,rarityName,energyType,cardType'
|
||||
};
|
||||
|
||||
// use typesense to search for cards matching the query and return the productIds of the results
|
||||
@@ -135,8 +167,8 @@ const facetNames = (name:string) => {
|
||||
}
|
||||
|
||||
const facets = searchResults.results.slice(1).map((result: any) => {
|
||||
const facet = result.facet_counts[0];
|
||||
if (!facet) return facet;
|
||||
const facet = result.facet_counts?.[0];
|
||||
if (!facet) return null;
|
||||
|
||||
// Sort: checked items first, then alphabetically
|
||||
facet.counts = facet.counts.sort((a: any, b: any) => {
|
||||
@@ -148,7 +180,7 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
||||
});
|
||||
|
||||
return facet;
|
||||
});
|
||||
}).filter(Boolean);
|
||||
|
||||
---
|
||||
|
||||
@@ -178,39 +210,42 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div id="sortBy" class="mb-2 d-flex align-items-center justify-content-start small" hx-swap-oob="true">
|
||||
<div id="sortBy" class="d-flex flex-fill align-items-center me-auto gap-2" hx-swap-oob="true">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-dark dropdown-toggle small" data-bs-toggle="dropdown" aria-expanded="false">Sort by</button>
|
||||
<button class="btn btn-sm btn-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Sort by</button>
|
||||
<ul class="dropdown-menu dropdown-menu-dark">
|
||||
<li><a class="dropdown-item sort-option small" href="#" data-sort="releaseDate:desc,number:asc" data-label="Set: Newest to Oldest">Set: Newest to Oldest</a></li>
|
||||
<li><a class="dropdown-item sort-option small" href="#" data-sort="releaseDate:asc,number:asc" data-label="Set: Oldest to Newest">Set: Oldest to Newest</a></li>
|
||||
<li><a class="dropdown-item sort-option small" href="#" data-sort="marketPrice:desc" data-label="Price: High to Low">Price: High to Low</a></li>
|
||||
<li><a class="dropdown-item sort-option small" href="#" data-sort="marketPrice:asc" data-label="Price: Low to High">Price: Low to High</a></li>
|
||||
<li><a class="dropdown-item sort-option small" href="#" data-sort="number:asc" data-label="Card Number: Ascending">Card Number: Ascending</a></li>
|
||||
<li><a class="dropdown-item sort-option small" href="#" data-sort="number:desc" data-label="Card Number: Descending">Card Number: Descending</a></li>
|
||||
<li><a class="dropdown-item sort-option" href="#" data-sort="releaseDate:desc,number:asc" data-label="Set: Newest to Oldest">Set: Newest to Oldest</a></li>
|
||||
<li><a class="dropdown-item sort-option" href="#" data-sort="releaseDate:asc,number:asc" data-label="Set: Oldest to Newest">Set: Oldest to Newest</a></li>
|
||||
<li><a class="dropdown-item sort-option" href="#" data-sort="marketPrice:desc" data-label="Price: High to Low">Price: High to Low</a></li>
|
||||
<li><a class="dropdown-item sort-option" href="#" data-sort="marketPrice:asc" data-label="Price: Low to High">Price: Low to High</a></li>
|
||||
<li><a class="dropdown-item sort-option" href="#" data-sort="number:asc" data-label="Card Number: Ascending">Card Number: Ascending</a></li>
|
||||
<li><a class="dropdown-item sort-option" href="#" data-sort="number:desc" data-label="Card Number: Descending">Card Number: Descending</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<span id="sortLabel" class="ms-2 text-secondary">{sortKey ? ({"releaseDate:desc,number:asc":"Set: Newest to Oldest","releaseDate:asc,number:asc":"Set: Oldest to Newest","marketPrice:desc":"Price: High to Low","marketPrice:asc":"Price: Low to High","number:asc":"Card Number: Ascending","number:desc":"Card Number: Descending"}[sortKey] ?? '') : ''}</span>
|
||||
<span id="sortLabel" class="ms-1 text-secondary small">{sortKey ? ({"releaseDate:desc,number:asc":"Set: Newest to Oldest","releaseDate:asc,number:asc":"Set: Oldest to Newest","marketPrice:desc":"Price: High to Low","marketPrice:asc":"Price: Low to High","number:asc":"Card Number: Ascending","number:desc":"Card Number: Descending"}[sortKey] ?? '') : ''}</span>
|
||||
<div class="btn-group btn-group-sm ms-2 flex-shrink-0" role="group" aria-label="Language filter">
|
||||
<button type="button" class={`btn btn-dark language-btn${language === 'all' ? ' active' : ''}`} data-lang="all">All</button>
|
||||
<button type="button" class={`btn btn-dark language-btn${language === 'en' ? ' active' : ''}`} data-lang="en">EN</button>
|
||||
<button type="button" class={`btn btn-dark language-btn${language === 'jp' ? ' active' : ''}`} data-lang="jp">JP</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="totalResults" class="mb-2 ms-5 text-secondary small" hx-swap-oob="true">
|
||||
<div id="totalResults" class="d-flex text-secondary small d-none align-items-center" hx-swap-oob="true">
|
||||
{totalHits} {totalHits === 1 ? ' result' : ' results'}
|
||||
</div>
|
||||
<div id="activeFilters" class="mb-2 d-flex align-items-center small ms-auto" hx-swap-oob="true">
|
||||
<div id="activeFilters" class="d-flex small ms-auto align-items-center" hx-swap-oob="true">
|
||||
{(Object.entries(filters).length > 0) &&
|
||||
<span class="me-1 small">Filtered by:</span>
|
||||
<ul class="list-group list-group-horizontal">
|
||||
{Object.entries(filters).map(([filter, values]) => (
|
||||
values.map((value) => (
|
||||
<li data-facet={filter} data-value={value} class="list-group-item small remove-filter">{value}</li>
|
||||
<li data-facet={filter} data-value={value} class="list-group-item small p-2 remove-filter">{value}</li>
|
||||
))
|
||||
))}
|
||||
</ul>
|
||||
<span class="ms-2"><button type="button" class="btn-close" aria-label="Clear all filters" id="clear-all-filters"></button></span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<script define:vars={{ totalHits, filters, facets }} is:inline>
|
||||
|
||||
// Filter the facet values to make things like Set easier to find
|
||||
const facetfilters = document.querySelectorAll('.facet-filter');
|
||||
for (const facetfilter of facetfilters) {
|
||||
@@ -236,7 +271,8 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
||||
document.getElementById('searchform').dispatchEvent(new Event('submit', {bubbles:true, cancelable:true}));
|
||||
}
|
||||
document.getElementById('clear-filters').addEventListener('click', (e) => clearAllFilters(e));
|
||||
document.getElementById('clear-all-filters').addEventListener('click', (e) => clearAllFilters(e));
|
||||
const clearAllBtn = document.getElementById('clear-all-filters');
|
||||
if (clearAllBtn) clearAllBtn.addEventListener('click', (e) => clearAllFilters(e));
|
||||
|
||||
// Remove single facet value
|
||||
for (const li of document.querySelectorAll('.remove-filter')) {
|
||||
@@ -259,11 +295,16 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
||||
|
||||
{pokemon.map((card:any) => (
|
||||
<div class="col">
|
||||
<div class="inventory-button position-relative float-end shadow-filter text-center d-none">
|
||||
<div class="inventory-label pt-2">+/-</div>
|
||||
</div>
|
||||
{hasAccess && (
|
||||
<button type="button" class="btn btn-sm inventory-button position-relative float-end shadow-filter text-center p-2" 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');">
|
||||
<b>+/–</b>
|
||||
</button>
|
||||
)}
|
||||
<div class="card-trigger position-relative" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});">
|
||||
<div class="image-grow"><img src={`/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 card-image w-100" onerror="this.onerror=null;this.src='/cards/default.jpg'"/><span class="position-absolute top-50 start-0 d-inline medium-icon"><FirstEditionIcon edition={card?.variant} /></span></div>
|
||||
<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="holo-shine"></div>
|
||||
<div class="holo-glare"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-cols-5 gx-1 price-row mb-2">
|
||||
{conditionOrder.map((condition) => (
|
||||
|
||||
1
src/svg/set/perfect_order.svg
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"include": [".astro/types.d.ts", "src/**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
|
||||