84 Commits

Author SHA1 Message Date
29deb19b89 [feat] move inventory data to db call 2026-04-10 15:19:58 -04:00
Zach Harding
e7374ad182 dashboard/inventory styling, fixed contact layout with new branding, consolidated all inline styles to main.scss 2026-04-10 09:05:22 -04:00
37b9d5c954 [bugfix] sort by dropdown fixed 2026-04-09 14:58:15 -04:00
ed049da3d2 [feat] override inventory access in .env 2026-04-09 14:21:26 -04:00
9a8008fc92 [chore] package update 2026-04-09 14:20:54 -04:00
Zach Harding
7b1f470ee9 muted nm-dmg tier colors, updated navbar/search/menu to follow new "brand", modified auth for features, new homepage 2026-04-09 11:34:37 -04:00
Zach Harding
d5dbb7718d style updates and re-added auth with a new method (thad to confirm) 2026-04-08 16:03:29 -04:00
Zach Harding
d2ad949c2e visual tweaks to recalled inventory 2026-04-08 16:03:29 -04:00
6811c4c249 [bugfix] correct sku relation to cards 2026-04-08 15:38:56 -04:00
3b4f98e556 [feat] purchase price added to inventory collection 2026-04-08 08:13:52 -04:00
4648507371 [feat] pull dashboard summary from db 2026-04-08 07:57:03 -04:00
71c167308d [feat] dashboard shows inventory 2026-04-07 22:34:31 -04:00
cb829e1922 [chore] move inventory relationship to sku 2026-04-07 09:52:17 -04:00
5dc7ce2de7 [bugfix] correcting issues introduced by Claude 2026-04-06 14:31:39 -04:00
Zach Harding
29ec850eef commented out the auth related to inventory 2026-04-05 17:11:16 -04:00
Zach Harding
5d716a4d8e added a fix for variant selection (without it cards with multiple versions would add all at form submit) 2026-04-05 16:32:44 -04:00
Zach Harding
d06c6fb9bf removed unnecessary migration/json files from drizzle 2026-04-05 16:12:02 -04:00
Zach Harding
404355304c style tweaks to both form and existing inventory, added createdAt and modified purchasePrice (for % of market) 2026-04-05 16:09:52 -04:00
Zach Harding
87235ab37a working with inventory style changes - tbd 2026-04-05 11:20:26 -04:00
12a42b87b8 [feat] save/remove inventory working now 2026-04-03 22:50:54 -04:00
03394d81e8 [feat] loading inventory 2026-04-03 22:10:41 -04:00
86da8a91ad [bugfix] removed server import from client script 2026-04-03 21:53:45 -04:00
66290fcb97 [bugfix] duplicated bootstrap inclusion causing errors 2026-04-03 21:49:44 -04:00
7a1b7eb5fe [chore] claude init 2026-04-03 21:37:04 -04:00
3be17fe84c [wip] bugs to work out, but backend should support inventory 2026-04-02 19:24:51 -04:00
38f041d86f [chore] supress bootstrap warnings for now 2026-04-01 21:08:52 -04:00
Zach Harding
b65e2a2859 minor style updates 2026-03-25 10:23:17 -04:00
Zach Harding
91823174d2 mostly complete inventory dashboard/modal 2026-03-25 09:21:24 -04:00
Zach Harding
943bd33c9a Merge branch 'master' into feat/inventory 2026-03-25 08:43:18 -04:00
Zach Harding
9975db20cb inventory dashboard setup 2026-03-25 08:42:17 -04:00
Zach Harding
db12844dea setting up inventory dashboard 2026-03-25 08:41:21 -04:00
3f9b1accda [chore] employ static assets directory 2026-03-25 05:34:11 -04:00
03e606e152 [bugfix] missed async await in tcgplayer preload 2026-03-23 21:24:24 -04:00
b871385fba [bugfix] don't close db connection pool in upload api script 2026-03-21 20:52:39 -04:00
4c6922f76b [feat] testing tcgcollector upload 2026-03-21 16:40:04 -04:00
171ce294f4 [chore] refactor common functions into helper script 2026-03-19 22:18:24 -04:00
Zach Harding
023cd87319 fixed backToTop z-index when scrolling on mobile 2026-03-18 20:36:33 -04:00
Zach Harding
04ea65eeeb hotfix for image-grow class 2026-03-18 14:53:10 -04:00
Zach Harding
9d9524e654 Merge branch 'feat/csv-prices' of papi.tkpups.com:tmiller/pokemon 2026-03-18 13:45:57 -04:00
c0120e3e77 [feat] read tcgcollector csv 2026-03-18 13:39:39 -04:00
Zach Harding
bc99be51ea setup (but did not apply) holofoil styling and added new seticon for perfect order set 2026-03-18 13:31:56 -04:00
660da7cded read/write CSV, prices from db 2026-03-18 13:26:42 -04:00
2a17654c74 [chore] sales history schema 2026-03-18 11:14:19 -04:00
Zach Harding
b06e24d382 added data attributes to be used later for holofoil styling and added ability to search by e-reader 2026-03-17 17:27:39 -04:00
Zach Harding
7b4e06733f added a button group for quick filtering by productLine 2026-03-17 11:27:16 -04:00
Zach Harding
f72d479c1d rearranged sort and filter on mobile into a flex col instead of row 2026-03-17 10:35:02 -04:00
zach
ee9f7a2561 added the mechanism for sort by, added total results and made it all look nice in one row 2026-03-16 14:39:55 -04:00
zach
2f17912949 reqrote volatility with proper standard deviation and added tooltip 2026-03-16 14:07:37 -04:00
a86dc08b50 [bugfix] fixing schema messed up by something adding tabs 2026-03-16 13:54:50 -04:00
zach
c4ebbfb060 modified layout and made it so you can switch between card modals and keep the pricing chart 2026-03-16 11:05:10 -04:00
zach
9c81a13c69 created price-history.ts to get history data and added to modal via chart.js 2026-03-16 08:39:06 -04:00
3a6dbf2ed9 [chore] preload all price history 2026-03-14 23:50:14 -04:00
e1ab59a2eb [feat] price history 2026-03-12 22:31:29 -04:00
zach
a8df9c71ee Merge branch 'master' of papi.tkpups.com:tmiller/pokemon 2026-03-12 13:41:02 -04:00
zach
835a174da2 refactored 404 page, fixed copy image toast on mobile and filtered missing images to exclude sealed 2026-03-12 13:40:12 -04:00
485f26de7b [chore] refactor indexing scripts 2026-03-12 08:18:40 -04:00
c10e34cc34 [feat] move missing image script out of test scripts so it's picked up by git 2026-03-11 23:09:35 -04:00
d9995e5e10 [bugfix] escape facet filters so special characters like parentheses work 2026-03-11 20:33:43 -04:00
c622c8bd8f Merge branch 'feat/postgresql' 2026-03-11 19:26:52 -04:00
f03c909745 [chore] schema for price history 2026-03-11 19:19:47 -04:00
a68ed7f7b8 [feat] switched from mysql to postgresql 2026-03-11 19:18:45 -04:00
zach
3d46a48a7d sliding modals, view transitions, accessibility, etc, etc 2026-03-11 15:21:43 -04:00
1089bcdc20 [chore] schema for price history 2026-03-09 15:44:06 -04:00
zach
7482cb9e9c cleaned up css and made sorted active filters to the top 2026-03-09 14:25:18 -04:00
zach
68bed6ff8e new border pattern for each condition on modal, added icon display for 1st edition on both card/card modal 2026-03-09 12:00:29 -04:00
f5fcd7b3e7 [bugfix] clear the notfound message on new search 2026-03-08 00:03:25 -05:00
zach
4eed1869a6 hide modal nav buttons until working 2026-03-06 14:22:19 -05:00
zach
ce56d08efe scrolling within modals using keys/buttons/swipe (needs help with infinite scrolling) 2026-03-06 13:53:15 -05:00
zach
7fd8a21d1c clerk was missing /shared npm package 2026-03-05 22:59:16 -05:00
2fa0be9d23 [bugfix] combine all search terms to a single indexed field 2026-03-05 16:09:23 -05:00
zach
dedd7f8d87 changed the mechanism for auth on the index page (requires install of newer clerk components) 2026-03-05 15:24:08 -05:00
zach
091aa72f23 these damn price-labels 2026-03-05 13:48:00 -05:00
zach
692d06c35a removed the waitlist and added a link to the cards 2026-03-05 12:47:41 -05:00
zach
c28f9a5e84 Merge branch 'master' of papi.tkpups.com:tmiller/pokemon 2026-03-05 12:20:16 -05:00
zach
a535dffcbe added back padding on xs screens 2026-03-05 12:19:46 -05:00
58e0acf462 [bugfix] bootstrap overrides must come before loading bootstrap 2026-03-05 12:14:00 -05:00
4ec64dca18 [bugfix] make sure previous card is not shown in modal by defaulting to "loading..." 2026-03-05 12:11:19 -05:00
zach
f2b0309b6e further tweak price-label prices/padding for small screens 2026-03-05 12:06:19 -05:00
zach
8161940690 filterbar rename of offcanvas example 2026-03-05 08:21:06 -05:00
79b52db469 [chore] added small delay to sync-prices to not be too aggressive 2026-03-05 07:11:54 -05:00
9c13fd6e4c [bugfix] corrected path to scripts 2026-03-05 00:53:01 -05:00
zach
34f7c8ece9 replaced favicon files, added grid variables to main.scsss and renamed offcanvas 2026-03-04 17:02:17 -05:00
zach
16def05dd0 fixed svg overwriting st-0 class 2026-03-03 15:18:39 -05:00
zach
4aabbfde9e added custom variable for xxxl grid to main.scss 2026-03-03 14:01:26 -05:00
89 changed files with 9792 additions and 2276 deletions

7
.env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="astro/client" />
declare namespace App {
interface Locals {
canAddInventory: boolean;
}
}

3
.gitignore vendored
View File

@@ -26,6 +26,9 @@ pnpm-debug.log*
# imges from tcgplayer
public/cards/*
# static assets
/static/
# anything test
test.*

58
CLAUDE.md Normal file
View 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.

View File

@@ -11,9 +11,24 @@ export default defineConfig({
},
}),
],
server: {
allowedHosts: true,
},
adapter: node({ mode: "standalone", checkOrigin: false }),
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'],
},
},
},
},
});

View File

@@ -4,8 +4,12 @@ import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle', // Directory for migration files
schema: './src/db/schema.ts', // Path to your schema file
dialect: 'mysql', // Specify the database dialect
casing: 'snake_case', // camelCase JS objects become snake_case in the DB
dialect: 'postgresql', // Specify the database dialect
dbCredentials: {
url: process.env.DATABASE_URL!, // Use the URL from your .env file
},
schemaFilter: ['pokemon'],
verbose: true,
strict: true,
});

3053
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,26 +5,30 @@
"scripts": {
"dev": "astro dev --host 0.0.0.0",
"build": "astro build",
"preview": "astro preview",
"preview": "astro preview --host 0.0.0.0",
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^9.5.4",
"@clerk/astro": "^2.17.6",
"@clerk/astro": "^3.0.1",
"@clerk/shared": "^4.0.0",
"@clerk/themes": "^2.4.55",
"@popperjs/core": "^2.11.8",
"astro": "^5.17.1",
"bootstrap": "^5.3.8",
"chalk": "^5.6.2",
"chart.js": "^4.5.1",
"csv": "^6.4.1",
"dotenv": "^17.2.4",
"drizzle-orm": "^1.0.0-beta.15-859cf75",
"mysql2": "^3.16.3",
"pg": "^8.20.0",
"sass": "^1.97.3",
"typesense": "^3.0.1"
},
"devDependencies": {
"@types/bootstrap": "^5.2.10",
"@types/node": "^25.2.1",
"@types/pg": "^8.18.0",
"drizzle-kit": "^1.0.0-beta.15-859cf75",
"typescript": "^5.9.3"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 B

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
public/holofoils/cosmos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

BIN
public/holofoils/galaxy.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

BIN
public/holofoils/grain.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
public/holofoils/metal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/holofoils/vmaxbg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
public/holofoils/wave.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

87
scripts/csvprices.ts Normal file
View File

@@ -0,0 +1,87 @@
import 'dotenv/config';
import { db, ClosePool } from '../src/db/index.ts';
import chalk from 'chalk';
import fs from "fs";
//import path from "node:path";
import { parse, stringify, transform } from 'csv';
import { client } from '../src/db/typesense.ts';
async function PricesFromCSV() {
const inputFilePath = 'scripts/test.tcgcollector.csv';
const outputFilePath = 'scripts/output.csv';
// Create read and write streams
const inputStream = fs.createReadStream(inputFilePath, 'utf8');
const outputStream = fs.createWriteStream(outputFilePath);
// Define the transformation logic
const transformer = transform({ parallel: 1 }, async function(this: any, row: any, callback: any) {
try {
// Specific query bsaed on tcgcollector CSV
const query = String(Object.values(row)[1]);
const setname = String(Object.values(row)[4]).replace(/Wizards of the coast promos/ig,'WoTC Promo');
const cardNumber = String(Object.values(row)[7]);
console.log(`${query} ${cardNumber} : ${setname}`);
// Use Typesense to find the card because we can easily use the combined fields
let cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `setName:\`${setname}\` && number:${cardNumber}` });
if (cards.hits?.length === 0) {
// Try without card number
cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `setName:\`${setname}\`` });
}
if (cards.hits?.length === 0) {
// Try without set name
cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `number:${cardNumber}` });
}
if (cards.hits?.length === 0) {
// I give up, just output the values from the csv
console.log(chalk.red(' - not found'));
const newRow = { ...row };
newRow.Variant = '';
newRow.marketPrice = '';
this.push(newRow);
}
else {
for (const card of cards.hits?.map((hit: any) => hit.document) ?? []) {
console.log(chalk.blue(` - ${card.cardId} : ${card.productName} : ${card.number}`), chalk.yellow(`${card.setName}`), chalk.green(`${card.variant}`));
const variant = await db.query.cards.findFirst({
with: { prices: true, tcgdata: true },
where: { cardId: card.cardId }
});
const newRow = { ...row };
newRow.Variant = variant?.variant;
newRow.marketPrice = variant?.prices.find(p => p.condition === 'Near Mint')?.marketPrice;
this.push(newRow);
}
}
callback();
} catch (error) {
callback(error);
}
});
// Pipe the streams: Read -> Parse -> Transform -> Stringify -> Write
inputStream
.on('error', (error) => console.error('Input stream error:', error))
.pipe(parse({ columns: true, trim: true }))
.on('error', (error) => console.error('Parse error:', error))
.pipe(transformer)
.on('error', (error) => console.error('Transform error:', error))
.pipe(stringify({ header: true }))
.on('error', (error) => console.error('Stringify error:', error))
.pipe(outputStream);
outputStream.on('finish', () => {
console.log(`Successfully written to ${outputFilePath}`);
ClosePool();
});
outputStream.on('error', (error) => {
console.error('An error occurred in the process:', error);
ClosePool();
});
}
await PricesFromCSV();

33
scripts/diagnose-join.ts Normal file
View File

@@ -0,0 +1,33 @@
import 'dotenv/config';
import chalk from 'chalk';
import util from 'node:util';
import { client } from '../src/db/typesense.ts';
const variants = [
'$skus(*, $cards(*))',
'$skus(*,$cards(*))',
'$skus(*, card_id, $cards(*))',
'$skus(*, $cards(*, strategy:nest))',
'$skus(*, $cards(*, strategy:merge))',
];
const debug = await client.debug.retrieve();
console.log(chalk.cyan(`Typesense server version: ${debug.version}`));
console.log();
for (const include of variants) {
console.log(chalk.yellow(`include_fields: ${include}`));
try {
const res: any = await client.collections('inventories').documents().search({
q: '*',
query_by: 'content',
per_page: 1,
include_fields: include,
});
const doc = res.hits?.[0]?.document;
console.log(util.inspect(doc, { depth: null, colors: false }));
} catch (err: any) {
console.log(chalk.red(` ERROR: ${err.message ?? err}`));
}
console.log();
}

View File

@@ -0,0 +1,30 @@
import * as schema from '../src/db/schema.ts';
import { db, ClosePool } from '../src/db/index.ts';
import { sql } from "drizzle-orm";
import fs from "node:fs/promises";
import path from "node:path";
async function findMissingImages() {
const cards = await db
.select()
.from(schema.tcgcards)
.where(sql`${schema.tcgcards.sealed} = false`);
const missingImages: string[] = [];
for (const card of cards) {
const imagePath = path.join(process.cwd(), 'public', 'cards', `${card.productId}.jpg`);
try {
await fs.access(imagePath);
} catch (err) {
missingImages.push(`${card.productId}\t${card.setId}\t${card.productName}\t${card.number}`);
}
}
return missingImages;
}
const missingImages = await findMissingImages();
//console.log("Missing Images:", missingImages.join('\n'));
fs.writeFile(path.join(process.cwd(), 'missing-images.log'), missingImages.join('\n'));
await ClosePool();

240
scripts/pokemon-helper.ts Normal file
View File

@@ -0,0 +1,240 @@
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'
import * as util from 'util';
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 },
{ name: 'card_id', type: 'string', reference: 'cards.id', async_reference: 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', async_reference: true },
{ name: 'sku_id', type: 'string', reference: 'skus.id', async_reference: true },
{ name: 'purchasePrice', type: 'int32', optional: true },
// content,setName,productLineName,rarityName,energyType,cardType from cards for searching
{ name: 'content', type: 'string', token_separators: ['/'] },
{ name: 'setName', type: 'string' },
{ name: 'productLineName', type: 'string' },
{ name: 'rarityName', type: 'string' },
{ name: 'energyType', type: 'string' },
{ name: 'cardType', type: 'string' },
]
});
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),
card_id: sku.cardId.toString(),
})), { action: 'upsert' });
console.log(chalk.green('Collection "skus" indexed successfully.'));
}
export const upsertInventoryCollection = async (db:DBInstance) => {
const inv = await db.query.inventory.findMany({
with: { sku: { with: { card: { with: { set: true } } } } }
});
await client.collections('inventories').documents().import(inv.map(i => ({
id: i.inventoryId,
userId: i.userId,
catalogName: i.catalogName,
card_id: i.sku?.cardId.toString(),
sku_id: i.skuId.toString(),
purchasePrice: DollarToInt(i.purchasePrice),
productLineName: i.sku?.card?.productLineName,
rarityName: i.sku?.card?.rarityName,
setName: i.sku?.card?.set?.setName || "",
cardType: i.sku?.card?.cardType || "",
energyType: i.sku?.card?.energyType || "",
content: [
i.sku?.card?.productName,
i.sku?.card?.productLineName,
i.sku?.card?.set?.setName || "",
i.sku?.card?.number,
i.sku?.card?.rarityName,
i.sku?.card?.artist || ""
].join(' '),
})), { 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`);
const skuUpdates = await db.execute(sql`update skus s set card_id = c.card_id from cards c where s.product_id = c.product_id and s.variant = c.variant and s.card_id is distinct from c.card_id`);
console.log(`Updated ${skuUpdates.rowCount} rows in skus table`);
}

View File

@@ -0,0 +1,147 @@
import chalk from 'chalk';
import { db, ClosePool } from '../src/db/index.ts';
import { sql } from 'drizzle-orm';
import { skus, priceHistory } from '../src/db/schema.ts';
import { toSnakeCase } from 'drizzle-orm/casing';
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const productPath = path.join(__dirname, 'products.log');
const sleep = (ms: number) => {
return new Promise(resolve => setTimeout(resolve, ms));
}
const headers = {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36'
}
const GetHistory = async (productId:number) => {
let monthData;
let quarterData;
let annualData;
let retries = 10;
while (retries > 0) {
try {
const monthResponse = await fetch(`https://infinite-api.tcgplayer.com/price/history/${productId}/detailed?range=month`, { headers: headers });
if (!monthResponse.ok) {
throw new Error(`Error fetching month data: ${monthResponse.statusText}`);
}
monthData = await monthResponse.json();
const quarterResponse = await fetch(`https://infinite-api.tcgplayer.com/price/history/${productId}/detailed?range=quarter`, { headers: headers });
if (!quarterResponse.ok) {
throw new Error(`Error fetching quarter data: ${quarterResponse.statusText}`);
}
quarterData = await quarterResponse.json();
const annualResponse = await fetch(`https://infinite-api.tcgplayer.com/price/history/${productId}/detailed?range=annual`, { headers: headers });
if (!annualResponse.ok) {
throw new Error(`Error fetching annual data: ${annualResponse.statusText}`);
}
annualData = await annualResponse.json();
retries = 0;
}
catch (error) {
retries--;
const err = error as Error;
console.error(err);
if (err.message.startsWith('Error fetching ')) await sleep(7500);
await sleep(2500);
}
}
if (annualData.result === null) {
console.error(chalk.red(`\tNo results found for productId: ${productId}`));
fs.appendFile(productPath, `${productId}\n`);
return null;
}
let skuCount = 0;
let priceCount = 0;
for (const annual of annualData.result) {
const quarter = quarterData.result?.find((r:any) => r.skuId == annual.skuId);
const month = monthData.result?.find((r:any) => r.skuId == annual.skuId);
const allPrices = [
...annual?.buckets?.map((r:any) => { return { skuId:Number(annual.skuId), calculatedAt:r.bucketStartDate, marketPrice:Number(r.marketPrice) }; }) || [],
...quarter?.buckets?.map((r:any) => { return { skuId:Number(annual.skuId), calculatedAt:r.bucketStartDate, marketPrice:Number(r.marketPrice) }; }) || [],
...month?.buckets?.map((r:any) => { return { skuId:Number(annual.skuId), calculatedAt:r.bucketStartDate, marketPrice:Number(r.marketPrice) }; }) || []
].sort((a:any,b:any) => { if(a.calculatedAt<b.calculatedAt) return -1; if(a.calculatedAt>b.calculatedAt) return 1; return 0; });;
const priceUpdates = allPrices.reduce((accumulator:any[],currentItem:any) => {
if (accumulator.length === 0 || (accumulator[accumulator.length-1].marketPrice !== currentItem.marketPrice && accumulator[accumulator.length-1].calculatedAt != currentItem.calculatedAt)) {
accumulator.push(currentItem);
}
return accumulator;
},[]);
skuCount++;
priceCount += priceUpdates.length;
console.log(chalk.gray(`\tSkuId: ${annual.skuId} with ${priceUpdates.length} updates`));
await db.insert(priceHistory).values(priceUpdates).onConflictDoUpdate({
target: [priceHistory.skuId, priceHistory.calculatedAt ],
set: {
marketPrice: sql.raw(`excluded.${toSnakeCase(skus.marketPrice.name)}`),
},
}).returning();
}
fs.appendFile(productPath, `${productId}\n`);
return { skuCount:skuCount, priceCount:priceCount };
}
const start = Date.now();
let productSet;
try {
const data = await fs.readFile(productPath, 'utf8');
const lines = data.split(/\r?\n/);
productSet = new Set(lines.map(line => line.trim()));
} catch (err) {
productSet = new Set();
}
// problem with this product
productSet.add('632947');
productSet.add('635161');
productSet.add('642504');
productSet.add('654346');
let count = productSet.size;
console.log(chalk.green(`${count} products already done.`));
const productIds = await db.query.tcgcards.findMany({ columns: { productId: true }});
const total = productIds.length;
for (const product of productIds) {
const productId = product.productId;
if (productSet.has(productId.toString().trim())) {
// console.log(chalk.blue(`ProductId: ${productId} (.../${total})`));
} else {
count++;
console.log(chalk.blue(`ProductId: ${productId} (${count}/${total})`));
await GetHistory(productId);
//await sleep(7000);
}
}
await ClosePool();
const end = Date.now();
const duration = (end - start) / 1000;
console.log(chalk.green(`Price history preloaded in ${duration.toFixed(2)} seconds.`));
export {};

View File

@@ -1,14 +1,15 @@
import 'dotenv/config';
import * as schema from '../src/db/schema.ts';
import { db, poolConnection } from '../src/db/index.ts';
import { db, ClosePool } from '../src/db/index.ts';
import fs from "node:fs/promises";
import 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,44 +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));
}
function cleanProductName(name: string): string {
// remove TCGPlayer crap
name = name.replace(/ - .*$/, '');
name = name.replace(/ \[.*\]/, '');
name = name.replace(/ \(.*\)/, '');
return name.trim();
}
async function fileExists(path: string): Promise<boolean> {
try {
await fs.access(path);
return true;
} catch {
return false;
}
}
function getNumberOrNull(value: any): number | null {
const number = Number(value); // Attempt to convert the value to a number
if (Number.isNaN(number)) {
return null; // Return null if the result is NaN
}
return number; // Otherwise, return the number
}
async function syncProductLine(productLine: string, field: string, fieldValue: string) {
let start = 0;
@@ -130,10 +108,10 @@ 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)) {
// continue;
// }
// Check if productId already exists and skip if it does (to avoid hitting the API too much)
if (allProductIds.size > 0 && allProductIds.has(item.productId)) {
continue;
}
console.log(chalk.blue(` - ${item.productName} (ID: ${item.productId})`));
@@ -171,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,
@@ -184,8 +162,9 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
maxFulfillableQuantity: detailData.maxFulfillableQuantity,
medianPrice: detailData.medianPrice,
totalListings: item.totalListings,
Artist: detailData.formattedAttributes.Artist || null,
}).onDuplicateKeyUpdate({
artist: detailData.formattedAttributes.Artist || null,
}).onConflictDoUpdate({
target: schema.tcgcards.productId,
set: {
productName: detailData.productName,
//productName: cleanProductName(item.productName),
@@ -208,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,
@@ -221,18 +200,21 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
maxFulfillableQuantity: detailData.maxFulfillableQuantity,
medianPrice: detailData.medianPrice,
totalListings: item.totalListings,
Artist: detailData.formattedAttributes.Artist || null,
artist: detailData.formattedAttributes.Artist || null,
},
});
// console.log(`item: ${item.setId}\tdetail: ${detailData.setId}`);
// console.log(`item: ${item.setCode}\tdetail: ${detailData.setCode}`);
// console.log(`item: ${item.setName}\tdetail: ${detailData.setName}`);
// set is...
await db.insert(schema.sets).values({
setId: detailData.setId,
setCode: detailData.setCode,
setName: detailData.setName,
setUrlName: detailData.setUrlName,
}).onDuplicateKeyUpdate({
}).onConflictDoUpdate({
target: schema.sets.setId,
set: {
setCode: detailData.setCode,
setName: detailData.setName,
@@ -249,7 +231,8 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
condition: skuItem.condition,
language: skuItem.language,
variant: skuItem.variant,
}).onDuplicateKeyUpdate({
}).onConflictDoUpdate({
target: schema.skus.skuId,
set: {
condition: skuItem.condition,
language: skuItem.language,
@@ -259,8 +242,8 @@ 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)) {
const imagePath = path.join(process.cwd(), 'static', 'cards', `${item.productId}.jpg`);
if (!await helper.FileExists(imagePath)) {
const imageResponse = await fetch(`https://tcgplayer-cdn.tcgplayer.com/product/${item.productId}_in_1000x1000.jpg`);
if (imageResponse.ok) {
const buffer = await imageResponse.arrayBuffer();
@@ -272,7 +255,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
}
// be nice to the API and not send too many requests in a short time
await sleep(300);
await helper.Sleep(300);
}
@@ -282,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);
}
await syncTcgplayer();
await poolConnection.end();
// update the card table with new/updated variants
await helper.UpdateVariants(db);
// index the card updates
await helper.upsertCardCollection(db);
await ClosePool();

View File

@@ -1,132 +1,14 @@
import { Client } from 'typesense';
import chalk from 'chalk';
import { db, poolConnection } from '../src/db/index.ts';
import { client } from '../src/db/typesense.ts';
import { release } from 'node:os';
const DollarToInt = (dollar: any) => {
if (dollar === null) return null;
return Math.round(dollar * 100);
}
async function createCollection(client: Client) {
// Delete the collection if it already exists to ensure a clean slate
try {
await client.collections('cards').delete();
await client.collections('skus').delete();
//console.log(`Collection "cards" deleted successfully:`, response);
} catch (error) {
//console.error(`Error deleting collection "cards":`, error);
}
// Create the collection with the specified schema
try {
await client.collections('cards').retrieve();
console.log(chalk.yellow('Collection "cards" already exists.'));
} catch (error) {
if (error instanceof Error && error.message.includes('404')) {
await client.collections().create({
name: 'cards',
fields: [
{ name: 'id', type: 'string' },
{ name: 'cardId', type: 'int32' },
{ name: 'productId', type: 'int32' },
{ name: 'variant', type: 'string', facet: true },
{ name: 'productName', type: 'string' },
{ name: 'productLineName', type: 'string', facet: true },
{ name: 'rarityName', type: 'string', facet: true },
{ name: 'setName', type: 'string', facet: true },
{ name: 'cardType', type: 'string', facet: true },
{ name: 'energyType', type: 'string', facet: true },
{ name: 'number', type: 'string', sort: true },
{ name: 'Artist', type: 'string' },
{ name: 'sealed', type: 'bool' },
{ name: 'releaseDate', type: 'int32'},
{ name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
],
//default_sorting_field: 'productId',
});
console.log(chalk.green('Collection "cards" created successfully.'));
} else {
console.error(chalk.red('Error checking/creating collection:'), error);
process.exit(1);
}
}
try {
await client.collections('skus').retrieve();
console.log(chalk.yellow('Collection "skus" already exists.'));
} catch(error) {
if (error instanceof Error && error.message.includes('404')) {
await client.collections().create({
name: 'skus',
fields: [
{ name: 'id', type: 'string' },
{ name: 'condition', type: 'string' },
{ name: 'highestPrice', type: 'int32', optional: true },
{ name: 'lowestPrice', type: 'int32', optional: true },
{ name: 'marketPrice', type: 'int32', optional: true },
//{ name: 'card_id', type: 'string', reference: 'cards.id' },
]
});
}
}
}
import { db, ClosePool } from '../src/db/index.ts';
import * as Indexing from './pokemon-helper.ts';
async function preloadSearchIndex() {
const pokemon = await db.query.cards.findMany({
with: { set: true, tcgdata: true, prices: true },
});
// await Indexing.createCardCollection();
await Indexing.createSkuCollection();
await Indexing.createInventoryCollection();
// Ensure the collection exists before importing documents
await createCollection(client);
await client.collections('cards').documents().import(pokemon.map(card => ({
id: card.cardId.toString(),
cardId: card.cardId,
productId: card.productId,
variant: card.variant,
productName: card.productName,
productLineName: card.productLineName,
rarityName: card.rarityName,
setName: card.set?.setName || "",
cardType: card.cardType || "",
energyType: card.energyType || "",
number: card.number,
Artist: card.Artist || "",
sealed: card.sealed,
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
sku_id: card.prices.map(price => price.skuId.toString())
})), { action: 'upsert' });
const skus = await db.query.skus.findMany({
with: { card: true }
});
await client.collections('skus').documents().import(skus.map(sku => ({
id: sku.skuId.toString(),
condition: sku.condition,
highestPrice: DollarToInt(sku.highestPrice),
lowestPrice: DollarToInt(sku.lowestPrice),
marketPrice: DollarToInt(sku.marketPrice),
//card_id: sku.card?.cardId.toString()
})));
console.log(chalk.green('Search index preloaded with Pokémon cards.'));
}
await preloadSearchIndex().catch((error) => {
console.error(chalk.red('Error preloading search index:'), error);
for (const e of error.importResults) {
if (!e.success) {
console.error(chalk.red(`Error importing document ${e.id}:`), e.error);
}
}
process.exit(1);
}).finally(() => {
poolConnection.end();
console.log(chalk.blue('Database connection closed.'));
process.exit(0);
});
// await Indexing.upsertCardCollection(db);
await Indexing.upsertSkuCollection(db);
await Indexing.upsertInventoryCollection(db);
await ClosePool();
console.log(chalk.green('Pokémon reindex complete.'));

View File

@@ -1,26 +1,23 @@
import 'dotenv/config';
import chalk from 'chalk';
import { db, poolConnection } from '../src/db/index.ts';
import { db, ClosePool } from '../src/db/index.ts';
import { sql, inArray, eq } from 'drizzle-orm';
import { skus, processingSkus } from '../src/db/schema.ts';
import { client } from '../src/db/typesense.ts';
import { skus, processingSkus, priceHistory, salesHistory } from '../src/db/schema.ts';
import { toSnakeCase } from 'drizzle-orm/casing';
import * as helper from './pokemon-helper.ts';
const DollarToInt = (dollar: any) => {
if (dollar === null) return null;
return Math.round(dollar * 100);
}
async function resetProcessingTable() {
// Use sql.raw to execute the TRUNCATE TABLE statement
await db.execute(sql.raw('TRUNCATE TABLE processingSkus;'));
await db.execute(sql.raw('TRUNCATE TABLE pokemon.processing_skus;'));
await db.insert(processingSkus).select(db.select({skuId: skus.skuId}).from(skus));
}
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.'));
@@ -55,6 +52,15 @@ async function syncPrices() {
console.error(chalk.yellow(`Expected ${batchSize} SKUs, got ${skuData.length}`));
}
if (skuData.length === 0) {
console.error(chalk.red('0 SKUs, skipping DB updates.'));
// remove skus from the 'working' processingSkus table
await db.delete(processingSkus).where(inArray(processingSkus.skuId, skuIds));
// be nice to the API and not send too many requests in a short time
await helper.Sleep(200);
continue;
}
const skuUpdates = skuData.map((sku: any) => { return {
skuId: sku.skuId,
cardId: 0,
@@ -68,39 +74,90 @@ async function syncPrices() {
marketPrice: sku.marketPrice,
priceCount: null,
}});
await db.insert(skus).values(skuUpdates).onDuplicateKeyUpdate({
const skuRows = await db.insert(skus).values(skuUpdates).onConflictDoUpdate({
target: skus.skuId,
set: {
calculatedAt: sql`values(${skus.calculatedAt})`,
highestPrice: sql`values(${skus.highestPrice})`,
lowestPrice: sql`values(${skus.lowestPrice})`,
marketPrice: sql`values(${skus.marketPrice})`,
calculatedAt: sql.raw(`excluded.${toSnakeCase(skus.calculatedAt.name)}`),
highestPrice: sql.raw(`excluded.${toSnakeCase(skus.highestPrice.name)}`),
lowestPrice: sql.raw(`excluded.${toSnakeCase(skus.lowestPrice.name)}`),
marketPrice: sql.raw(`excluded.${toSnakeCase(skus.marketPrice.name)}`),
},
setWhere: sql`skus.market_price is distinct from excluded.market_price`,
}).returning();
if (skuRows && skuRows.length > 0) {
const skuHistory = skuRows.filter(row => row.calculatedAt != null).map(row => { return {
skuId: row.skuId,
calculatedAt: new Date(row.calculatedAt?.toISOString().slice(0, 10)||0),
marketPrice: row.marketPrice,
}});
if (skuHistory && skuHistory.length > 0) {
await db.insert(priceHistory).values(skuHistory).onConflictDoUpdate({
target: [priceHistory.skuId,priceHistory.calculatedAt],
set: {
marketPrice: sql.raw(`excluded.${toSnakeCase(skus.marketPrice.name)}`),
}
});
console.log(chalk.cyan(`${skuRows.length} history rows added.`));
}
});
for (const productId of skuRows.filter(row => row.calculatedAt != null).map(row => row.productId)) {
updatedCards.add(productId);
}
}
// remove skus from the 'working' processingSkus table
await db.delete(processingSkus).where(inArray(processingSkus.skuId, skuIds));
// be nice to the API and not send too many requests in a short time
await helper.Sleep(200);
}
return updatedCards;
}
async function indexPrices() {
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' });
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 indexPrices();
await poolConnection.end();
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;
console.log(chalk.green(`Price sync completed in ${duration.toFixed(2)} seconds.`));

View File

@@ -1,32 +0,0 @@
import 'dotenv/config';
import { db, poolConnection } from '../src/db/index.ts';
import { sql } from 'drizzle-orm'
async function syncVariants() {
const updates = await db.execute(sql`update cards as c
join tcgcards t on c.productId = t.productId
join (select distinct productId, variant from skus) b on c.productId = b.productId and c.variant = b.variant
left join tcg_overrides o on c.productId = o.productId
set c.productName = coalesce(o.productName, regexp_replace(regexp_replace(coalesce(nullif(t.productName, ''), t.productUrlName),' \\\\(.*\\\\)',''),' - .*$','')),
c.productLineName = coalesce(o.productLineName, t.productLineName), c.productUrlName = coalesce(o.productUrlName, t.productUrlName), c.rarityName = coalesce(o.rarityName, t.rarityName),
c.sealed = coalesce(o.sealed, t.sealed), c.setId = coalesce(o.setId, t.setId), c.cardType = coalesce(o.cardType, t.cardType),
c.energyType = coalesce(o.energyType, t.energyType), c.number = coalesce(o.number, t.number), c.Artist = coalesce(o.Artist, t.Artist)`);
console.log(`Updated ${updates[0].affectedRows} rows in cards table`);
const inserts = await db.execute(sql`insert into cards (productId, variant, productName, productLineName, productUrlName, rarityName, sealed, setId, cardType, energyType, number, Artist)
select t.productId, b.variant,
coalesce(o.productName, regexp_replace(regexp_replace(coalesce(nullif(t.productName, ''), t.productUrlName),' \\\\(.*\\\\)',''),' - .*$','')) as productName,
coalesce(o.productLineName, t.productLineName) as productLineName, coalesce(o.productUrlName, t.productUrlName) as productUrlName, coalesce(o.rarityName, t.rarityName) as rarityName,
coalesce(o.sealed, t.sealed) as sealed, coalesce(o.setId, t.setId) as setId, coalesce(o.cardType, t.cardType) as cardType,
coalesce(o.energyType, t.energyType) as energyType, coalesce(o.number, t.number) as number, coalesce(o.Artist, t.Artist) as Artist
from tcgcards t
join (select distinct productId, variant from skus) b on t.productId = b.productId
left join tcg_overrides o on t.productId = o.productId
where not exists (select 1 from cards where productId=t.productId and variant=b.variant)
`);
console.log(`Inserted ${inserts[0].affectedRows} rows into cards table`);
}
await syncVariants();
await poolConnection.end();

View File

@@ -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';
@@ -41,7 +41,7 @@
// @import 'bootstrap/scss/spinners';
@import 'bootstrap/scss/tables';
@import 'bootstrap/scss/toasts';
// @import 'bootstrap/scss/tooltip';
@import 'bootstrap/scss/tooltip';
@import 'bootstrap/scss/transitions';
// Optional helpers

2115
src/assets/css/_card.scss Normal file

File diff suppressed because it is too large Load Diff

View 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);
}
}
}

File diff suppressed because it is too large Load Diff

View 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();
}
})();

View File

@@ -1,25 +1,97 @@
import * as bootstrap from 'bootstrap';
window.bootstrap = bootstrap;
document.addEventListener('DOMContentLoaded', () => {
// trap browser back and close the modal if open
const cardModal = document.getElementById('cardModal');
// 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();
// 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', () => {
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");
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";
}
});
// 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";
});
}
});

253
src/assets/js/priceChart.js Normal file
View File

@@ -0,0 +1,253 @@
import Chart from 'chart.js/auto';
const CONDITIONS = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
const CONDITION_COLORS = {
"Near Mint": { active: 'hsla(88, 50%, 67%, 1)', muted: 'hsla(88, 50%, 67%, 0.67)' },
"Lightly Played": { active: 'hsla(66, 70%, 68%, 1)', muted: 'hsla(66, 70%, 68%, 0.67)' },
"Moderately Played": { active: 'hsla(54, 100%, 73%, 1)', muted: 'hsla(54, 100%, 73%, 0.67)' },
"Heavily Played": { active: 'hsla(46, 100%, 65%, 1)', muted: 'hsla(46, 100%, 65%, 0.67)' },
"Damaged": { active: 'hsla(36, 100%, 65%, 1)', muted: 'hsla(36, 100%, 65%, 0.67)' },
};
const RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '1y': 365, 'all': Infinity };
let chartInstance = null;
let allHistory = [];
let activeCondition = "Near Mint";
let activeRange = '1m';
function formatDate(dateStr) {
const [year, month, day] = dateStr.split('-');
const d = new Date(Number(year), Number(month) - 1, Number(day));
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function setEmptyState(isEmpty) {
const modal = document.getElementById('cardModal');
const empty = modal?.querySelector('#priceHistoryEmpty');
const canvasWrapper = empty?.nextElementSibling;
if (!empty || !canvasWrapper) return;
empty.classList.toggle('d-none', !isEmpty);
canvasWrapper.classList.toggle('d-none', isEmpty);
}
function 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)
: new Date(Date.now() - RANGE_DAYS[rangeKey] * 86_400_000);
const filtered = history.filter(r => new Date(r.calculatedAt) >= cutoff);
const dataDateSet = new Set(filtered.map(r => r.calculatedAt));
const allDates = [...dataDateSet].sort((a, b) => new Date(a) - new Date(b));
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) {
if (!lookup[row.condition]) lookup[row.condition] = {};
lookup[row.condition][row.calculatedAt] = Number(row.marketPrice);
}
const activeConditionHasData = allDates.some(
date => lookup[activeCondition]?.[date] != null
);
const datasets = CONDITIONS.map(condition => {
const isActive = condition === activeCondition;
const colors = CONDITION_COLORS[condition];
const data = axisLabels.map(date => lookup[condition]?.[date] ?? null);
return {
label: condition,
data,
borderColor: isActive ? colors.active : colors.muted,
borderWidth: isActive ? 2 : 1,
pointRadius: isActive ? 2.5 : 0,
pointHoverRadius: isActive ? 5 : 3,
pointBackgroundColor: isActive ? colors.active : colors.muted,
tension: 0.3,
fill: false,
spanGaps: true,
order: isActive ? 0 : 1,
};
});
return {
labels,
datasets,
hasData: allDates.length > 0,
activeConditionHasData,
};
}
function updateChart() {
if (!chartInstance) return;
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
chartInstance.data.labels = labels;
chartInstance.data.datasets = datasets;
chartInstance.update('none');
setEmptyState(!hasData || !activeConditionHasData);
}
function initPriceChart(canvas) {
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
try {
allHistory = JSON.parse(canvas.dataset.history ?? '[]');
} catch (err) {
console.error('Failed to parse price history:', err);
return;
}
if (!allHistory.length) {
setEmptyState(true);
return;
}
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
setEmptyState(!hasData || !activeConditionHasData);
chartInstance = new Chart(canvas.getContext('2d'), {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.85)',
titleColor: 'rgba(255, 255, 255, 0.9)',
bodyColor: 'rgba(255, 255, 255, 0.75)',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
padding: 10,
callbacks: {
labelColor: (ctx) => {
const colors = CONDITION_COLORS[ctx.dataset.label];
return {
borderColor: colors.active,
backgroundColor: colors.active,
};
},
label: (ctx) => {
const isActive = ctx.dataset.label === activeCondition;
const price = ctx.parsed.y != null ? `$${ctx.parsed.y.toFixed(2)}` : '—';
return isActive
? ` ${ctx.dataset.label}: ${price}`
: ` ${ctx.dataset.label}: ${price}`;
}
}
}
},
scales: {
x: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: {
color: 'rgba(255, 255, 255, 0.4)',
maxTicksLimit: 6,
maxRotation: 0,
},
border: { color: 'rgba(255, 255, 255, 0.1)' },
},
y: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: {
color: 'rgba(255, 255, 255, 0.4)',
callback: (val) => `$${Number(val).toFixed(2)}`,
},
border: { color: 'rgba(255, 255, 255, 0.1)' },
}
}
}
});
}
function initFromCanvas(canvas) {
activeCondition = "Near Mint";
activeRange = '1m';
const modal = document.getElementById('cardModal');
modal?.querySelectorAll('.price-range-btn').forEach(b => {
b.classList.toggle('active', b.dataset.range === '1m');
});
// 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);
}
function setup() {
const modal = document.getElementById('cardModal');
if (!modal) return;
modal.addEventListener('card-modal:swapped', () => {
const canvas = modal.querySelector('#priceHistoryChart');
if (canvas) initFromCanvas(canvas);
});
modal.addEventListener('hidden.bs.modal', () => {
if (chartInstance) { chartInstance.destroy(); chartInstance = null; }
allHistory = [];
});
document.addEventListener('shown.bs.tab', (e) => {
if (!modal.contains(e.target)) return;
const target = e.target?.getAttribute('data-bs-target');
// 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',
'#nav-mp': 'Moderately Played',
'#nav-hp': 'Heavily Played',
'#nav-dmg': 'Damaged',
};
if (target && conditionMap[target]) {
activeCondition = conditionMap[target];
updateChart();
}
});
document.addEventListener('click', (e) => {
const btn = e.target?.closest('.price-range-btn');
if (!btn || !modal.contains(btn)) return;
modal.querySelectorAll('.price-range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeRange = btn.dataset.range ?? '1m';
updateChart();
});
}
setup();

View File

@@ -1,35 +1,43 @@
---
---
<button type="button" class="btn btn-info p-2 rounded-circle" aria-label="Back to Top" id="btn-back-to-top" onclick="dataLayer.push({'event': 'backToTop'});">
<span class="top-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M232 64C201.1 64 176 89.1 176 120L176 320.6C172.8 322.6 169.6 324.8 166.5 327.1L144 344C123.9 359.1 112 382.8 112 408L112 427.5C112 480.5 141.1 529.2 187.7 554.3L196 558.8C217 570.1 240.4 576 264.3 576L400 576C461.9 576 512 525.9 512 464L512 368C512 332.7 483.3 304 448 304C445.2 304 442.4 304.2 439.7 304.5C428.7 285.1 407.9 272 384 272C378.7 272 373.5 272.7 368.5 273.9C357.7 253.7 336.5 240 312 240C303.5 240 295.4 241.7 288 244.7L288 120C288 89.1 262.9 64 232 64zM208 120C208 106.7 218.7 96 232 96C245.3 96 256 106.7 256 120L256 277.5C256 284.6 260.6 290.8 267.4 292.8C274.2 294.8 281.4 292.2 285.4 286.3C291.2 277.6 301 272 312.1 272C327.7 272 340.7 283.2 343.5 297.9C344.5 303 347.9 307.4 352.7 309.5C357.5 311.6 363 311.3 367.5 308.6C372.3 305.7 378 304 384.1 304C398.8 304 411.3 314 415 327.6C416.2 332 419.2 335.7 423.3 337.7C427.4 339.7 432.1 339.9 436.4 338.3C440 336.9 444 336.1 448.2 336.1C465.9 336.1 480.2 350.4 480.2 368.1L480.2 464.1C480.2 508.3 444.4 544.1 400.2 544.1L264.5 544.1C246 544.1 227.7 539.5 211.4 530.7L211.4 530.7L203.1 526.2C166.6 506.6 144 468.7 144 427.5L144 408C144 392.9 151.1 378.7 163.2 369.6L176 360L176 408C176 416.8 183.2 424 192 424C200.8 424 208 416.8 208 408L208 120z"/></svg></span>
<button
type="button"
class="btn btn-light p-2 rounded-squircle"
aria-label="Back to Top"
aria-hidden="true"
id="btn-back-to-top"
style="display:none"
>
<span class="top-icon">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path d="M232 64C201.1 64 176 89.1 176 120L176 320.6C172.8 322.6 169.6 324.8 166.5 327.1L144 344C123.9 359.1 112 382.8 112 408L112 427.5C112 480.5 141.1 529.2 187.7 554.3L196 558.8C217 570.1 240.4 576 264.3 576L400 576C461.9 576 512 525.9 512 464L512 368C512 332.7 483.3 304 448 304C445.2 304 442.4 304.2 439.7 304.5C428.7 285.1 407.9 272 384 272C378.7 272 373.5 272.7 368.5 273.9C357.7 253.7 336.5 240 312 240C303.5 240 295.4 241.7 288 244.7L288 120C288 89.1 262.9 64 232 64zM208 120C208 106.7 218.7 96 232 96C245.3 96 256 106.7 256 120L256 277.5C256 284.6 260.6 290.8 267.4 292.8C274.2 294.8 281.4 292.2 285.4 286.3C291.2 277.6 301 272 312.1 272C327.7 272 340.7 283.2 343.5 297.9C344.5 303 347.9 307.4 352.7 309.5C357.5 311.6 363 311.3 367.5 308.6C372.3 305.7 378 304 384.1 304C398.8 304 411.3 314 415 327.6C416.2 332 419.2 335.7 423.3 337.7C427.4 339.7 432.1 339.9 436.4 338.3C440 336.9 444 336.1 448.2 336.1C465.9 336.1 480.2 350.4 480.2 368.1L480.2 464.1C480.2 508.3 444.4 544.1 400.2 544.1L264.5 544.1C246 544.1 227.7 539.5 211.4 530.7L211.4 530.7L203.1 526.2C166.6 506.6 144 468.7 144 427.5L144 408C144 392.9 151.1 378.7 163.2 369.6L176 360L176 408C176 416.8 183.2 424 192 424C200.8 424 208 416.8 208 408L208 120z"/>
</svg>
</span>
</button>
<script>
//Get the button
let mybutton = document.getElementById("btn-back-to-top");
const mybutton = document.getElementById("btn-back-to-top");
// When the user scrolls down 20px from the top of the document, show the button
window.onscroll = function () {
scrollFunction();
};
function scrollFunction() {
if (
document.body.scrollTop > 20 ||
document.documentElement.scrollTop > 20
) {
mybutton.style.display = "block";
} else {
mybutton.style.display = "none";
function setButtonVisibility(visible: boolean) {
if (!mybutton) return;
mybutton.style.display = visible ? "block" : "none";
mybutton.setAttribute("aria-hidden", visible ? "false" : "true");
}
}
// When the user clicks on the button, scroll to the top of the document
mybutton.addEventListener("click", backToTop);
function backToTop() {
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
}
function scrollFunction() {
const scrolled = document.body.scrollTop > 20 || document.documentElement.scrollTop > 20;
setButtonVisibility(scrolled);
}
function backToTop() {
dataLayer.push({ event: "backToTop" });
window.scrollTo({ top: 0, behavior: "smooth" });
}
if (mybutton) {
mybutton.addEventListener("click", backToTop);
}
window.addEventListener("scroll", scrollFunction);
</script>

View File

@@ -1,12 +1,13 @@
---
import BackToTop from "./BackToTop.astro"
---
<div class="container-fluid container-sm mt-3">
<div class="row mb-4">
<div class="col-md-3 display-sm-none">
<div class="col-md-2">
<div class="h5 d-none">Inventory management placeholder</div>
<div class="offcanvas offcanvas-start" data-bs-backdrop="static" tabindex="-1" id="offcanvasExample" aria-labelledby="offcanvasExampleLabel">
<div class="offcanvas offcanvas-start" data-bs-backdrop="static" tabindex="-1" id="filterBar" aria-labelledby="filterBarLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasExampleLabel">Filter by:</h5>
<h5 class="offcanvas-title" id="filterBarLabel">Filter by:</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body px-3 pt-0">
@@ -14,16 +15,649 @@ import BackToTop from "./BackToTop.astro"
</div>
</div>
</div>
<div class="col-sm-12 col-md-9 mt-0">
<div id="activeFilters" class="mb-2 d-flex align-items-center justify-content-end small"></div>
<div id="cardGrid" class="row g-xxl-3 g-2 row-cols-2 row-cols-lg-3 row-cols-xxl-4 row-cols-xxxl-5"></div>
<div id="notfound"></div>
</div>
</div>
<div class="modal fade card-modal" id="cardModal" tabindex="-1" aria-labelledby="cardModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
<div class="modal-content">
<div class="col-sm-12 col-md-10 mt-0">
<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>
</div>
<div id="cardGrid" aria-live="polite" class="row g-xxl-3 g-2 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5"></div>
<div id="notfound" aria-live="polite"></div>
</div>
</div>
<BackToTop>
<div class="modal card-modal" id="cardModal" tabindex="-1" aria-labelledby="cardModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
<div class="modal-content p-2">Loading...</div>
</div>
</div>
<button id="modalPrevBtn" class="modal-nav-btn modal-nav-prev d-none" aria-label="Previous card">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"/>
</svg>
</button>
<button id="modalNextBtn" class="modal-nav-btn modal-nav-next d-none" aria-label="Next card">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
<BackToTop />
</div>
<script is:inline>
(function () {
// ── 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.
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-toggle="sort-dropdown"]');
if (btn) {
e.preventDefault();
e.stopPropagation();
const menu = btn.nextElementSibling;
menu.classList.toggle('show');
btn.setAttribute('aria-expanded', menu.classList.contains('show'));
return;
}
const opt = e.target.closest('#sortBy .sort-option');
if (opt) {
e.preventDefault();
const menu = opt.closest('.dropdown-menu');
const btn2 = menu?.previousElementSibling;
menu?.classList.remove('show');
if (btn2) btn2.setAttribute('aria-expanded', 'false');
const sortInput = document.getElementById('sortInput');
if (sortInput) sortInput.value = opt.dataset.sort;
document.getElementById('sortLabel').textContent = opt.dataset.label;
document.querySelectorAll('.sort-option').forEach(o => o.classList.remove('active'));
opt.classList.add('active');
const start = document.getElementById('start');
if (start) start.value = '0';
document.getElementById('searchform').dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true })
);
return;
}
const menu = document.querySelector('#sortBy .dropdown-menu.show');
if (menu) {
menu.classList.remove('show');
const btn3 = menu.previousElementSibling;
if (btn3) btn3.setAttribute('aria-expanded', 'false');
}
});
// ── Language toggle ───────────────────────────────────────────────────────
document.addEventListener('click', (e) => {
const btn = e.target.closest('.language-btn');
if (!btn) return;
e.preventDefault();
const input = document.getElementById('languageInput');
if (input) input.value = btn.dataset.lang;
const start = document.getElementById('start');
if (start) start.value = '0';
document.getElementById('searchform').dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true })
);
});
// ── Global helpers ────────────────────────────────────────────────────────
window.copyImage = async function(img) {
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
await new Promise((resolve) => {
const clean = new Image();
clean.crossOrigin = 'anonymous';
clean.onload = () => { ctx.drawImage(clean, 0, 0); resolve(); };
clean.onerror = () => { ctx.drawImage(img, 0, 0); resolve(); };
clean.src = img.src;
});
if (navigator.clipboard && navigator.clipboard.write) {
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
});
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
showCopyToast('📋 Image copied!', '#198754');
} else {
const url = img.src;
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(url);
showCopyToast('📋 Image URL copied!', '#198754');
} else {
const input = document.createElement('input');
input.value = url;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
showCopyToast('📋 Image URL copied!', '#198754');
}
}
} catch (err) {
console.error('Failed:', err);
showCopyToast('❌ Copy failed', '#dc3545');
}
};
function showCopyToast(message, color) {
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: ${color}; color: white; padding: 10px 20px;
border-radius: 8px; font-size: 14px; z-index: 9999;
opacity: 0; transition: opacity 0.2s ease;
pointer-events: none;
`;
document.body.appendChild(toast);
requestAnimationFrame(() => toast.style.opacity = '1');
setTimeout(() => {
toast.style.opacity = '0';
toast.addEventListener('transitionend', () => toast.remove());
}, 2000);
}
// ── 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;
let isNavigating = false;
// ── Register cards as HTMX loads them ────────────────────────────────────
const cardGrid = document.getElementById('cardGrid');
const gridObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
const triggers = node.querySelectorAll
? node.querySelectorAll('[data-card-id]')
: [];
for (const el of triggers) {
const id = Number(el.getAttribute('data-card-id'));
if (id && !cardIndex.includes(id)) cardIndex.push(id);
}
if (node.dataset?.cardId) {
const id = Number(node.dataset.cardId);
if (id && !cardIndex.includes(id)) cardIndex.push(id);
}
}
}
});
gridObserver.observe(cardGrid, { childList: true, subtree: true });
// ── Navigation helpers ────────────────────────────────────────────────────
function getAdjacentIds() {
const idx = cardIndex.indexOf(currentCardId);
return {
prev: idx > 0 ? cardIndex[idx - 1] : null,
next: idx < cardIndex.length - 1 ? cardIndex[idx + 1] : null,
idx,
total: cardIndex.length,
};
}
function updateNavButtons(modal) {
const prevBtn = document.getElementById('modalPrevBtn');
const nextBtn = document.getElementById('modalNextBtn');
if (!modal || !modal.classList.contains('show')) {
prevBtn.classList.add('d-none');
nextBtn.classList.add('d-none');
return;
}
const { prev, next } = getAdjacentIds();
prevBtn.classList.toggle('d-none', prev === null);
nextBtn.classList.toggle('d-none', next === null);
}
function tryTriggerSentinel() {
const sentinel = cardGrid.querySelector('[hx-trigger="revealed"]');
if (!sentinel) return;
if (typeof htmx !== 'undefined') {
htmx.trigger(sentinel, 'revealed');
} else {
sentinel.scrollIntoView({ behavior: 'instant', block: 'end' });
}
}
function initChartAfterSwap(modal) {
const canvas = modal.querySelector('#priceHistoryChart');
if (!canvas) return;
requestAnimationFrame(() => {
modal.dispatchEvent(new CustomEvent('card-modal:swapped', { bubbles: false }));
});
}
async function loadCard(cardId, direction = null) {
if (!cardId || isNavigating) return;
isNavigating = true;
currentCardId = cardId;
const modal = document.getElementById('cardModal');
const url = `/partials/card-modal?cardId=${cardId}`;
const { idx, total } = getAdjacentIds();
if (idx >= total - 3) tryTriggerSentinel();
const doSwap = async () => {
const response = await fetch(url);
const html = await response.text();
if (modal._reconnectChartObserver) modal._reconnectChartObserver();
modal.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) {
modal.dataset.navDirection = direction;
await document.startViewTransition(doSwap).finished;
delete modal.dataset.navDirection;
} else {
await doSwap();
}
isNavigating = false;
const { idx: newIdx, total: newTotal } = getAdjacentIds();
if (newIdx >= newTotal - 3) tryTriggerSentinel();
}
function navigatePrev() {
const { prev } = getAdjacentIds();
if (prev) loadCard(prev, 'prev');
}
function navigateNext() {
const { next } = getAdjacentIds();
if (next) loadCard(next, 'next');
}
document.getElementById('modalPrevBtn').addEventListener('click', navigatePrev);
document.getElementById('modalNextBtn').addEventListener('click', navigateNext);
document.addEventListener('keydown', (e) => {
const modal = document.getElementById('cardModal');
if (!modal.classList.contains('show')) return;
if (e.key === 'ArrowLeft') { e.preventDefault(); navigatePrev(); }
if (e.key === 'ArrowRight') { e.preventDefault(); navigateNext(); }
});
let touchStartX = 0;
let touchStartY = 0;
const SWIPE_THRESHOLD = 50;
document.getElementById('cardModal').addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
}, { passive: true });
document.getElementById('cardModal').addEventListener('touchend', (e) => {
const dx = e.changedTouches[0].clientX - touchStartX;
const dy = e.changedTouches[0].clientY - touchStartY;
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dy) > Math.abs(dx)) return;
if (dx < 0) navigateNext();
else navigatePrev();
}, { passive: true });
document.body.addEventListener('htmx:beforeRequest', async (e) => {
if (e.detail.elt.getAttribute('hx-target') !== '#cardModal') return;
const cardEl = e.detail.elt.closest('[data-card-id]');
if (cardEl) currentCardId = Number(cardEl.getAttribute('data-card-id'));
if (!document.startViewTransition) return;
e.preventDefault();
const url = e.detail.requestConfig.path;
const target = document.getElementById('cardModal');
const sourceImg = cardEl?.querySelector('img');
const response = await fetch(url, { headers: { 'HX-Request': 'true' } });
if (!response.ok) throw new Error(`Failed to load modal: ${response.status}`);
const html = await response.text();
const transitionName = `card-hero-${currentCardId}`;
try {
if (sourceImg) {
sourceImg.style.viewTransitionName = transitionName;
sourceImg.style.opacity = '0';
}
const transition = document.startViewTransition(async () => {
if (sourceImg) sourceImg.style.viewTransitionName = '';
if (target._reconnectChartObserver) target._reconnectChartObserver();
target.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) {
destImg.style.viewTransitionName = transitionName;
if (!destImg.complete) {
await new Promise(resolve => {
destImg.addEventListener('load', resolve, { once: true });
destImg.addEventListener('error', resolve, { once: true });
});
}
}
});
await transition.finished;
updateNavButtons(target);
initChartAfterSwap(target);
switchToRequestedTab();
} catch (err) {
console.error('[card-modal] transition failed:', err);
e.detail.elt.dispatchEvent(new MouseEvent('click', { bubbles: true }));
} finally {
if (sourceImg) {
sourceImg.style.viewTransitionName = '';
sourceImg.style.opacity = '';
}
const destImg = target.querySelector('img.card-image');
if (destImg) destImg.style.viewTransitionName = '';
}
});
const cardModal = document.getElementById('cardModal');
// ── 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();
const pending = sessionStorage.getItem('pendingSearch');
if (pending) {
sessionStorage.removeItem('pendingSearch');
const input = document.getElementById('searchInput');
if (input) input.value = pending;
// The form's hx-trigger="load" will fire automatically on page load,
// picking up the pre-populated input value — no manual trigger needed.
}
});
})();
</script>

View File

@@ -1,14 +0,0 @@
---
import { SignedIn, SignedOut, UserButton, SignInButton, SignUpButton } from "@clerk/astro/components";
---
<div class="row">
<SignedOut>
<div class="col-3">
<SignInButton mode="modal" />
<SignUpButton mode="modal" />
</div>
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</div>

View File

@@ -21,13 +21,16 @@ const energyMap = {
"Fire": fire,
"Water": water,
"Steel": steel,
"Metal": steel,
"Colorless": colorless,
"Fighting": fighting,
"Psychic": psychic,
"Electric": electric,
"Lightning": electric,
};
const svg = energyMap[energy as keyof typeof energyMap] ?? "";
if (!svg && energy) console.warn(`No energy icon found for: ${energy}`);
---
<div class="energy-icon shadow-filter" set:html={svg}></div>
<div class="energy-icon shadow-filter" role="img" aria-label={energy} set:html={svg}></div>

View File

@@ -0,0 +1,14 @@
---
import first from "/src/svg/edition/firstEdition.svg?raw";
const { edition } = Astro.props;
const editionMap = {
"1st Edition Holofoil": first,
"1st Edition": first,
};
const svg = editionMap[edition as keyof typeof editionMap] ?? "";
---
<div class="edition-icon shadow-filter" set:html={svg}></div>

View File

@@ -1,16 +1,33 @@
---
import EnergyWheel from './EnergyWheel.astro';
import '/src/assets/css/main.scss';
import logo from "/src/svg/logo/rat_light.svg?raw";
---
<footer class="bd-footer py-4 py-md-5 mt-0 bottom-0 bg-body-tertiary">
<div class="container py-4 py-md-5 px-4 px-md-3 text-body-secondary">
<div class="row">
<div class="col-3 mb-3">
</div>
<div class="col mb-3 align-items-end">
<a class="btn btn-outline-success rounded p-2 float-end" href="/contact">Contact Us <svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".25" d="M112 176L404 176C411.9 206.7 431 233 456.6 250.2L320 353.9L112 196.1L112 176zM112 256.3L305.5 403.1L320 414.1L334.5 403.1L509.2 270.6C515.3 271.5 521.6 272 528 272L528 464L112 464L112 256.3z"/><path d="M528 64C572.2 64 608 99.8 608 144C608 188.2 572.2 224 528 224C483.8 224 448 188.2 448 144C448 99.8 483.8 64 528 64zM88 128L401 128C400.3 133.2 400 138.6 400 144C400 155 401.4 165.8 404 176L112 176L112 196.1L320 353.9L456.6 250.3C472.1 260.7 489.9 267.8 509.2 270.7L334.5 403.2L320 414.2L305.5 403.2L112 256.4L112 464.1L528 464.1L528 272.1C545 272.1 561.2 268.8 576 262.8L576 512.1L64 512.1L64 128.1L88 128.1z"/></svg></a>
</div>
<footer class="footer py-5 border-top border-subtle" role="contentinfo">
<div class="container">
<div class="row g-4 mb-4">
<div class="col-md-4">
<a href="/" class="d-inline-block mb-3" aria-label="RAT home">
<span set:html={logo} class="logo-svg d-flex" style="--logo-width: 8rem;"></span>
</a>
<p class="text-body-secondary small">Real. Accurate. Transparent. Pokémon card price tracker for collectors who want to buy, sell, and trade with confidence.</p>
</div>
<nav class="col-md-2 ms-md-auto" aria-label="Tools">
<h3 class="h6 fw-semibold text-body-emphasis mb-3">Tools</h3>
<ul class="list-unstyled small text-body-secondary">
<li class="mb-2"><a href="/pokemon" class="text-body-secondary text-decoration-none hover-white">Browse Cards</a></li>
<li class="mb-2"><span class="text-body-tertiary">Inventory/Collection Tracker <em>(soon)</em></span></li>
</ul>
</nav>
<nav class="col-md-2" aria-label="Company">
<h3 class="h6 fw-semibold text-body-emphasis mb-3">Company</h3>
<ul class="list-unstyled small text-body-secondary">
<li class="mb-2"><a href="https://www.route301cards.com/" class="text-body-secondary text-decoration-none hover-white">About</a></li>
<li class="mb-2"><a href="/privacy" class="text-body-secondary text-decoration-none hover-white">Terms and Privacy</a></li>
</ul>
</nav>
</div>
<div class="d-flex flex-wrap justify-content-between align-items-center pt-4 border-top border-subtle">
<p class="text-body-tertiary small mb-0">© {new Date().getFullYear()} RAT. Not affiliated with Nintendo, The Pokémon Company, or their affiliates.</p>
<p class="text-body-tertiary small mb-0">Pokémon and all related names are trademarks of Nintendo / Creatures Inc. / GAME FREAK inc.</p>
</div>
</div>
</footer>
</footer>

90
src/components/Hero.astro Normal file
View File

@@ -0,0 +1,90 @@
---
import { SignInButton, Show } from '@clerk/astro/components'
import type { sign } from 'node:crypto'
---
<!-- ═══════════════════════════════════════════
HERO
═══════════════════════════════════════════ -->
<div class="hero position-relative overflow-hidden">
<div class="hero-bg" aria-hidden="true"></div>
<div class="container py-5 py-md-6 position-relative">
<div class="row align-items-center g-5">
<div class="col-12 col-xl-6">
<p class="eyebrow text-purple-light mb-3">Pokémon Card Price Aggregator</p>
<h1 class="display-4 fw-bold lh-sm mb-4">
The home of</br>
<span class="text-gradient">Real. Accurate. Transparent.</span><br/>
pricing data.
</h1>
<p class="lead text-body-secondary mb-4 pe-lg-4">
Real-time prices across the Pokémon trading card game. See prices for all conditions at a glance — no spreadsheets, no guesswork.
</p>
<div class="d-flex flex-wrap gap-3">
<Show when="signed-out">
<SignInButton asChild mode="modal">
<button class="btn btn-purple btn-lg px-4">
Get Started Free
</button>
</SignInButton>
</Show>
<Show when="signed-in">
<SignInButton asChild mode="modal">
<a href="/pokemon" class="btn btn-outline-light btn-lg px-4">Browse Cards</a>
</SignInButton>
</Show>
</div>
<p class="mt-3 text-body-tertiary small d-none">Free forever. No credit card required.</p>
</div>
<div class="col-xl-6 d-none d-xl-block" aria-hidden="true">
<div class="hero-cards-mockup">
<div class="mockup-card mockup-card--1 shadow-lg rounded-4">
<img class="img-fluid" src="/static/cards/124125.jpg" alt="Sample Pokémon Card" />
</div>
<div class="mockup-card mockup-card--2 shadow-lg rounded-4">
<img class="img-fluid" src="/static/cards/88875.jpg" alt="Sample Pokémon Card" />
</div>
<div class="mockup-card mockup-card--3 shadow-lg rounded-4">
<img class="img-fluid" src="/static/cards/567429.jpg" alt="Sample Pokémon Card" />
</div>
<div class="price-chip price-chip--nm">NM <strong>$114.99</strong></div>
<div class="price-chip price-chip--lp">LP <strong>$85.66</strong></div>
</div>
</div>
</div>
</div>
</div>
<script is:inline>
// Your product image IDs
const productImages = [
"124125.jpg",
"88875.jpg",
"567429.jpg",
"88788.jpg",
"88789.jpg",
"88996.jpg",
"88997.jpg",
"189659.jpg",
"86745.jpg",
"517025.jpg",
"86911.jpg",
"87456.jpg",
"246733.jpg",
"567418.jpg",
"613917.jpg",
];
function getRandomImages(arr, count) {
const shuffled = [...arr].sort(() => 0.5 - Math.random());
return shuffled.slice(0, count);
}
const images = document.querySelectorAll(".mockup-card img");
const selectedImages = getRandomImages(productImages, images.length);
images.forEach((img, index) => {
img.src = `/static/cards/${selectedImages[index]}`;
});
</script>

View 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>

View File

@@ -1,34 +1,36 @@
---
import '/src/assets/css/main.scss';
export const prerender = false;
import { UserButton, SignInButton, Show } from '@clerk/astro/components'
import logo from "/src/svg/logo/rat_light.svg?raw";
---
<script is:inline>
const afterUpdate = (e) => {
const start = document.querySelector('#start');
if (start) {
const val = Number(start.value) || 0;
start.value = (val + 20).toString();
}
// delete the triggering element
if (e && e.detail && e.detail.elt) {
e.detail.elt.remove();
}
};
const beforeSearch = (e) => {
const start = document.querySelector('#start');
if (start) {
start.value = '0';
document.querySelector('#cardGrid').innerHTML = '';
window.scrollTo({ top: 0, behavior: 'instant' });
}
};
</script>
<nav class="navbar navbar-expand sticky-top bg-dark" data-bs-theme="dark">
<div class="container container-fluid">
<a class="navbar-brand d-flex" href="/">
<span class="h3 d-none d-md-flex">Rigid's App Thing</span><span class="h3 d-md-none d-flex m-auto">RAT</span>
<nav class="navbar sticky-top bg-dark shadow" data-bs-theme="dark" aria-label="Main navigation">
<div class="container align-items-center" id="navContainer">
<a class="navbar-brand" href="/">
<span set:html={logo} class="logo-svg d-flex"></span>
</a>
<slot name="navItems"/>
<slot name="searchInput"/>
<div class="d-flex d-md-none nav-user-btn" id="navUserBtn">
<Show when="signed-in">
<UserButton afterSignOutUrl="/" showName={false} />
</Show>
<Show when="signed-out">
<SignInButton asChild mode="modal">
<button class="btn btn-light">Sign In</button>
</SignInButton>
</Show>
<slot name="navItems"/>
</div>
<div class="d-flex flex-column-reverse flex-md-row search-container" id="searchContainer">
<slot name="searchInput"/>
<div class="d-none d-md-flex ms-4 nav-user-btn">
<Show when="signed-in">
<UserButton afterSignOutUrl="/" showName={false} />
</Show>
<Show when="signed-out">
<SignInButton asChild mode="modal">
<button class="btn btn-light">Sign In</button>
</SignInButton>
</Show>
<slot name="navItems"/>
</div>
</div>
</div>
</nav>

View File

@@ -1,10 +1,46 @@
---
import '/src/assets/css/main.scss';
---
<div class="navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item d-flex">
<a class="nav-link btn btn-warning rounded p-2" href="/pokemon"><span class="d-inline-block d-md-none">Cards</span> <svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M256 519.9L256 576L576 576L576 128L378.8 128C408.7 239.7 438.6 351.3 468.5 463C397.7 482 326.8 501 256 519.9z"/><path d="M43.5 113L352.6 30.2L468.6 462.9L159.5 545.7z"/></svg></a>
<button
class="navbar-toggler ms-4 p-1 btn btn-purple border-0"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#navOffcanvas"
aria-controls="navOffcanvas"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div
id="navOffcanvasWrapper"
data-bs-theme="dark"
>
<div
class="offcanvas offcanvas-end"
tabindex="-1"
id="navOffcanvas"
aria-labelledby="navOffcanvasLabel"
>
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="navOffcanvasLabel">Menu</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body px-3 pt-0">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link py-3 border-bottom border-secondary" href="/pokemon">Browse Cards</a>
</li>
<li class="nav-item">
<a class="nav-link py-3" href="/dashboard">Dashboard</a>
</li>
</ul>
</div>
</div>
</div>
</div>
<script is:inline>
document.addEventListener('DOMContentLoaded', () => {
const wrapper = document.getElementById('navOffcanvasWrapper');
if (wrapper) document.body.appendChild(wrapper);
});
</script>

View File

@@ -1,7 +1,6 @@
---
import '/src/assets/css/main.scss';
---
---
<header class="header-top w-100">
<div class="header-wrap">
<div class="header-content">

View File

@@ -36,6 +36,7 @@ const rarityMap = {
"Ultra Rare": ultrarare,
"Secret Rare": secretrare,
"Black White Rare": blackwhiterare,
"Rare Secret": raresecret,
"Shiny Holo Rare": raresecret,
"Rare BREAK": rarebreak,
"Art Rare": artrare,
@@ -44,6 +45,7 @@ const rarityMap = {
};
const svg = rarityMap[rarity as keyof typeof rarityMap] ?? "";
if (!svg && rarity) console.warn(`No rarity icon found for: ${rarity}`);
---
<div class="rarity-icon shadow-filter" set:html={svg}></div>
<div class="rarity-icon shadow-filter" role="img" aria-label={rarity} set:html={svg}></div>

View File

@@ -1,5 +1,5 @@
---
import { SignedIn } from "@clerk/astro/components";
import { Show } from '@clerk/astro/components'
---
<script is:inline>
const afterUpdate = (e) => {
@@ -8,12 +8,13 @@ import { SignedIn } from "@clerk/astro/components";
const val = Number(start.value) || 0;
start.value = (val + 20).toString();
}
// delete the triggering element
if (e && e.detail && e.detail.elt) {
e.detail.elt.remove();
}
};
const beforeSearch = (e) => {
const notfound = document.getElementById('notfound');
if (notfound) notfound.innerHTML = '';
const start = document.querySelector('#start');
if (start) {
start.value = '0';
@@ -23,16 +24,48 @@ import { SignedIn } from "@clerk/astro/components";
};
</script>
<SignedIn>
<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="#offcanvasExample" role="button" aria-controls="offcanvasExample"><span class="d-block d-md-none filter-icon mt-1"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg></span><span class="d-none d-md-block">Filters</span></a>
<Show when="signed-in">
<form
class="d-flex align-items-center"
role="search"
id="searchform"
hx-post="/partials/cards"
hx-target="#cardGrid"
hx-trigger="load, submit"
hx-vals='{"start":"0"}'
hx-on--after-request="afterUpdate()"
hx-on--before-request="beforeSearch()"
>
<div class="input-group">
<input type="hidden" name="start" id="start" value="0" />
<input type="search" name="q" class="form-control form-control-lg" placeholder="Search cards..." />
<button type="submit" class="btn btn-danger btn-lg border border-danger-subtle border-start-0" value="" onclick="const q = document.querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });">
<svg class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M432 272C432 183.6 360.4 112 272 112C183.6 112 112 183.6 112 272C112 360.4 183.6 432 272 432C360.4 432 432 360.4 432 272zM401.1 435.1C365.7 463.2 320.8 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272C480 320.8 463.2 365.7 435.1 401.1L569 535C578.4 544.4 578.4 558.1 569 567.5C559.6 575.9 544.4 575.9 536 567.5L401.1 435.1z"/></svg>
</button>
{Astro.url.pathname === '/pokemon' && (
<a class="btn btn-purple" data-bs-toggle="offcanvas" href="#filterBar" type="button" role="button" aria-controls="filterBar" aria-label="filter">
<span class="d-block d-md-none filter-icon py-2">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M528.8 96.3C558.6 90.8 571.2 118.9 568.9 142.2C572.3 173.4 570.8 207 553.9 230.8C513.9 283.2 459.3 315.9 414.3 364.3C414.9 418.3 419.8 459.8 423.6 511.2C427.6 552.4 388.7 586.8 346.6 570.1C303.2 550.5 259.4 527.5 230.4 493.3C217 453.1 225.9 407.5 222.2 365.3C222.2 365.3 222.1 365.1 222 365C151.4 319.6 59.3 250.9 61 158.4C59.9 121 91.8 96.1 123.8 96.5C259.3 98.5 394.1 104.4 528.8 96.3zM506.1 161.4C378.3 168.2 252 162.1 125.2 160.5C128.6 227 199 270.8 250 306.8C305.5 335.4 281.6 410.5 288.3 461.7C310.8 478.9 334.6 494.6 358.9 505.8C355.4 458 350.7 415.4 350.2 364.6C349.9 349.2 355.3 333.7 366.5 321.7C384.3 302.6 402.8 287.8 421.5 270.1C446.1 245.2 477.9 225.1 499.7 196.7C509 182.2 504.7 174.5 506 161.5z"/></svg>
</span>
<span class="d-none d-md-block fw-medium">Filters</span>
</a>
)}
<input type="hidden" name="start" id="start" value="0" />
<input type="hidden" name="sort" id="sortInput" value="" />
<input type="hidden" name="language" id="languageInput" value="all" />
<input type="search" name="q" id="searchInput" class="form-control search-input" placeholder="Search cards" />
<button
type="submit"
class="btn btn-purple border-start-0"
aria-label="search"
onclick="
const q = this.closest('form').querySelector('[name=q]').value;
dataLayer.push({ event: 'view_search_results', search_term: q });
if (window.location.pathname !== '/pokemon') {
event.preventDefault();
event.stopPropagation();
sessionStorage.setItem('pendingSearch', q);
window.location.href = '/pokemon';
}
"
>
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
</button>
</div>
</form>
</SignedIn>
</Show>

View File

@@ -123,6 +123,8 @@ import mega_evolutions from "/src/svg/set/mega_evolutions.svg?raw";
import phantasmal_flames from "/src/svg/set/phantasmal_flames.svg?raw";
import 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;
@@ -130,7 +132,7 @@ const setMap = {
"JU": jungle,
"FO": fossil,
"B2": base_set_2,
"TR": battle_styles,
"TR": team_rocket,
"G1": gym_heroes,
"G2": gym_challenge,
"SI": southern_islands,
@@ -251,9 +253,11 @@ const setMap = {
"ASC": ascended_heroes,
"DRI": destined_rivals,
"SSP": surging_sparks,
"ME03": perfect_order,
};
const svg = setMap[set as keyof typeof setMap] ?? "";
if (!svg && set) console.warn(`No set icon found for: ${set}`);
---
<div class="set-icon shadow-filter" set:html={svg}></div>
<div class="set-icon shadow-filter" role="img" aria-label={set} set:html={svg}></div>

View File

@@ -1,11 +1,24 @@
// src/db/index.ts
import 'dotenv/config';
import { relations } from './relations.ts';
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
//export const poolConnection = mysql.createPool({ uri: process.env.DATABASE_URL, client_found_rows: false });
export const poolConnection = mysql.createPool({ uri: process.env.DATABASE_URL, flags: ["-FOUND_ROWS"] });
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
export const db = drizzle({ client: poolConnection, relations: relations});
// Handle pool errors to prevent connection corruption
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
});
export const db = drizzle({ client: pool, relations: relations, casing: 'snake_case' });
export type DBInstance = typeof db;
export const ClosePool = () => {
pool.end();
}

View File

@@ -2,11 +2,32 @@ import { defineRelations } from "drizzle-orm";
import * as schema from "./schema.ts";
export const relations = defineRelations(schema, (r) => ({
priceHistory: {
sku: r.one.skus({
from: r.priceHistory.skuId,
to: r.skus.skuId,
}),
},
salesHistory: {
sku: r.one.skus({
from: r.salesHistory.skuId,
to: r.skus.skuId,
}),
},
skus: {
card: r.one.cards({
from: [r.skus.productId, r.skus.variant],
to: [r.cards.productId, r.cards.variant],
}),
history: r.many.priceHistory(),
latestSales: r.many.salesHistory(),
inventories: r.many.inventory(),
},
inventory: {
sku: r.one.skus({
from: r.inventory.skuId,
to: r.skus.skuId,
}),
},
cards: {
prices: r.many.skus(),

View File

@@ -1,22 +1,25 @@
import { mysqlTable, int, varchar, boolean, decimal, datetime, index } from "drizzle-orm/mysql-core"
//import { mysqlTable, int, varchar, boolean, decimal, datetime, index } from "drizzle-orm/mysql-core"
import { integer, varchar, boolean, decimal, timestamp, index, pgSchema, uuid, primaryKey } from "drizzle-orm/pg-core";
export const tcgcards = mysqlTable("tcgcards", {
productId: int().primaryKey(),
export const pokeSchema = pgSchema("pokemon");
export const tcgcards = pokeSchema.table('tcg_cards', {
productId: integer().primaryKey(),
productName: varchar({ length: 255 }).notNull(),
productLineName: varchar({ length: 255 }).default("").notNull(),
productLineUrlName: varchar({ length: 255 }).default("").notNull(),
productStatusId: int().default(0).notNull(),
productTypeId: int().default(0).notNull(),
productStatusId: integer().default(0).notNull(),
productTypeId: integer().default(0).notNull(),
productUrlName: varchar({ length: 255 }).default("").notNull(),
rarityName: varchar({ length: 100 }).default("").notNull(),
sealed: boolean().default(false).notNull(),
sellerListable: boolean().default(false).notNull(),
setId: int(),
shippingCategoryId: int(),
setId: integer(),
shippingCategoryId: integer(),
duplicate: boolean().default(false).notNull(),
foilOnly: boolean().default(false).notNull(),
maxFulfillableQuantity: int(),
totalListings: int(),
maxFulfillableQuantity: integer(),
totalListings: integer(),
score: decimal({ precision: 10, scale: 2, mode: 'number' }),
lowestPrice: decimal({ precision: 10, scale: 2, mode: 'number' }),
lowestPriceWithShipping: decimal({ precision: 10, scale: 2, mode: 'number' }),
@@ -30,73 +33,112 @@ export const tcgcards = mysqlTable("tcgcards", {
cardTypeB: varchar({ length: 100 }),
energyType: varchar({ length: 100 }),
flavorText: varchar({ length: 1000 }),
hp: int(),
hp: integer(),
number: varchar({ length: 50 }).default("").notNull(),
releaseDate: datetime(),
releaseDate: timestamp(),
resistance: varchar({ length: 100 }),
retreatCost: varchar({ length: 100 }),
stage: varchar({ length: 100 }),
weakness: varchar({ length: 100 }),
Artist: varchar({ length: 255 }),
artist: varchar({ length: 255 }),
});
export const cards = mysqlTable("cards", {
cardId: int().notNull().primaryKey().autoincrement(),
productId: int().notNull(),
export const cards = pokeSchema.table('cards', {
cardId: integer().notNull().primaryKey().generatedAlwaysAsIdentity(),
productId: integer().notNull(),
variant: varchar({ length: 100 }).notNull(),
productName: varchar({ length: 255 }),
productLineName: varchar({ length: 255 }),
productUrlName: varchar({ length: 255 }).default("").notNull(),
rarityName: varchar({ length: 100 }),
sealed: boolean().default(false).notNull(),
setId: int(),
setId: integer(),
cardType: varchar({ length: 100 }),
energyType: varchar({ length: 100 }),
number: varchar({ length: 50 }),
Artist: varchar({ length: 255 }),
artist: varchar({ length: 255 }),
},
(table) => [
index("card_productIdIdx").on(table.productId, table.variant),
index('idx_card_product_id').on(table.productId, table.variant),
]);
export const tcg_overrides = mysqlTable("tcg_overrides", {
productId: int().primaryKey(),
export const tcg_overrides = pokeSchema.table('tcg_overrides', {
productId: integer().primaryKey(),
productName: varchar({ length: 255 }),
productLineName: varchar({ length: 255 }),
productUrlName: varchar({ length: 255 }).default("").notNull(),
productUrlName: varchar({ length: 255 }).default('').notNull(),
rarityName: varchar({ length: 100 }),
sealed: boolean().default(false).notNull(),
setId: int(),
setId: integer(),
cardType: varchar({ length: 100 }),
energyType: varchar({ length: 100 }),
number: varchar({ length: 50 }),
Artist: varchar({ length: 255 }),
artist: varchar({ length: 255 }),
});
export const sets = mysqlTable("sets", {
setId: int().primaryKey(),
export const sets = pokeSchema.table('sets', {
setId: integer().primaryKey(),
setName: varchar({ length: 255 }).notNull(),
setUrlName: varchar({ length: 255 }).notNull(),
setCode: varchar({ length: 100 }).notNull(),
});
export const skus = mysqlTable("skus", {
skuId: int().primaryKey(),
cardId: int().default(0).notNull(),
productId: int().notNull(),
export const skus = pokeSchema.table('skus', {
skuId: integer().primaryKey(),
cardId: integer().default(0).notNull(),
productId: integer().notNull(),
condition: varchar({ length: 255 }).notNull(),
language: varchar({ length: 100 }).notNull(),
variant: varchar({ length: 100 }).notNull(),
calculatedAt: datetime(),
calculatedAt: timestamp(),
highestPrice: decimal({ precision: 10, scale: 2 }),
lowestPrice: decimal({ precision: 10, scale: 2 }),
marketPrice: decimal({ precision: 10, scale: 2 }),
priceCount: int(),
priceCount: integer(),
},
(table) => [
index("productIdIdx").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 processingSkus = mysqlTable("processingSkus", {
skuId: int().primaryKey(),
export const priceHistory = pokeSchema.table('price_history', {
skuId: integer().notNull(),
calculatedAt: timestamp().notNull(),
marketPrice: decimal({ precision: 10, scale: 2 }),
},
(table) => [
primaryKey({ name: 'pk_price_history', columns: [table.skuId, table.calculatedAt] })
]);
export const salesHistory = pokeSchema.table('sales_history',{
skuId: integer().notNull(),
orderDate: timestamp().notNull(),
title: varchar({ length: 255 }),
customListingId: varchar({ length: 255 }),
language: varchar({ length: 100 }),
listingType: varchar({ length: 100 }),
purchasePrice: decimal({ precision: 10, scale: 2 }),
quantity: integer(),
shippingPrice: decimal({ precision: 10, scale: 2 })
},
(table) => [
primaryKey({ name: 'pk_sales_history', columns: [table.skuId, table.orderDate] })
]);
export const inventory = pokeSchema.table('inventory',{
inventoryId: uuid().primaryKey().notNull().defaultRandom(),
userId: varchar({ length: 100 }).notNull(),
catalogName: varchar({ length: 100 }),
skuId: integer().notNull(),
quantity: integer(),
purchasePrice: decimal({ precision: 10, scale: 2 }),
note: varchar({ length:255 }),
createdAt: timestamp().notNull().defaultNow(),
},
(table) => [
index('idx_userid_skuId').on(table.userId, table.skuId)
]);
export const processingSkus = pokeSchema.table('processing_skus', {
skuId: integer().primaryKey(),
});

View File

@@ -1,13 +1,14 @@
---
import '/src/assets/css/main.scss';
import NavBar from '../components/NavBar.astro';
import NavItems from '../components/NavItems.astro';
import Search from '../components/Search.astro';
const { title } = Astro.props;
---
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<script is:inline>
window.dataLayer = window.dataLayer || [];
</script>
<!-- Google Tag Manager -->
<script is:inline>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
@@ -17,28 +18,31 @@ import '/src/assets/css/main.scss';
<!-- End Google Tag Manager -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="htmx-config" content='{"historyCacheSize": 50}'/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<title>Rigid's App Thing</title>
<title>{title}</title>
</head>
<body>
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-PPQMZ4PL"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
<slot name="navbar"/>
<NavBar slot="navbar">
<NavItems slot="navItems" />
<Search slot="searchInput" />
</NavBar>
<div class="wrapper">
<div class="main">
<div class="container-fluid container-sm mt-4">
<slot name="page"/>
</div>
</div>
<div class="footer">
<slot name="footer"/>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
<script type="module" src="/src/assets/js/main.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
<script src="../assets/js/main.js"></script>
<script>import '../assets/js/priceChart.js';</script>
</body>
</html>

View File

@@ -1,14 +1,78 @@
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server';
import type { AstroMiddlewareRequest, AstroMiddlewareResponse } from 'astro';
import { clerkMiddleware, createRouteMatcher, clerkClient } from '@clerk/astro/server';
import type { MiddlewareNext } from 'astro';
import 'dotenv/config';
const isProtectedRoute = createRouteMatcher([
'/pokemon',
]);
export const onRequest = clerkMiddleware((auth, context) => {
if (!auth().userId && isProtectedRoute(context.request)) {
// Redirect unauthenticated users to the sign-in page
return auth().redirectToSignIn();
declare global {
namespace App {
interface Locals {
canAddInventory: boolean;
}
}
}
const isProtectedRoute = createRouteMatcher(['/pokemon']);
const isAdminRoute = createRouteMatcher(['/admin']);
const TARGET_ORG_ID = "org_3Baav9czkRLLlC7g89oJWqRRulK";
export const onRequest = clerkMiddleware(async (auth, context, next) => {
const { isAuthenticated, userId, redirectToSignIn, has } = auth();
if (!isAuthenticated && isProtectedRoute(context.request)) {
return redirectToSignIn();
}
// ── Inventory visibility check ──────────────────────────────────────────────
// Resolves to true if the user belongs to the target org OR has the feature
const canAddInventory = process.env.INVENTORY_ACCESS === 'true' ||
(
isAuthenticated &&
userId &&
(
!!has({ permission: "org:feature:inventory_add" }) || // Clerk feature flag
(await getUserOrgIds(context, userId)).includes(TARGET_ORG_ID)
)
);
// Expose the flag to your Astro pages via locals
context.locals.canAddInventory = Boolean(canAddInventory);
// ── Admin route guard (unchanged) ───────────────────────────────────────────
if (isAdminRoute(context.request)) {
if (!isAuthenticated || !userId) {
return redirectToSignIn();
}
try {
const client = await clerkClient(context);
const memberships = await client.organizations.getOrganizationMembershipList({
organizationId: TARGET_ORG_ID,
});
const userMembership = memberships.data.find(
(m) => m.publicUserData?.userId === userId
);
if (!userMembership || userMembership.role !== "org:admin") {
return new Response(null, { status: 404 });
}
} catch (e) {
console.error("Clerk membership check failed:", e);
return context.redirect("/");
}
}
return next();
});
// ── Helper: fetch all org IDs the current user belongs to ───────────────────
async function getUserOrgIds(context: any, userId: string): Promise<string[]> {
try {
const client = await clerkClient(context);
const memberships = await client.users.getOrganizationMembershipList({ userId });
return memberships.data.map((m) => m.organization.id);
} catch (e) {
console.error("Failed to fetch user org memberships:", e);
return [];
}
}

View File

@@ -1,13 +1,10 @@
---
import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import NavBar from '../components/NavBar.astro';
export const prerender = false;
import pokedexList from '../data/pokedex.json';
import Layout from '../layouts/Main.astro';
import Search from '../components/Search.astro';
import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro';
const searchParams = Astro.url.searchParams;
const query = searchParams.get('q') || '*';
import pokedexList from '../data/pokedex.json';
// Get random # (00011025)
const randomNumber = String(Math.floor(Math.random() * 1025) + 1).padStart(4, "0");
@@ -21,11 +18,9 @@ const pokemon = pokedexList.find(p => p["#"] === randomNumber);
// If not found (rare), fallback
const pokemonName = pokemon?.Name || "Unknown Pokémon";
---
<Layout>
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<Layout title="404 - Page Not Found">
<div class="container-fluid container-sm mt-5" slot="page">
<div class="row mb-4">
<div class="col-12 col-md-6">
<h1 class="mb-4">404 - Page Not Found</h1>
<h4>Sorry, the page you are looking for does not exist.</h4>
@@ -34,39 +29,125 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
</p>
</div>
<div class="col-12 col-md-5 offset-md-1">
<div class="alert alert-warning border p-2" role="alert">
<div id="reveal-hint" class="alert alert-warning border p-2" role="alert">
<h4 class="alert-heading">Who's that Pokémon?</h4>
<p class="mb-0">Click the image to reveal.</p>
</div>
<div class="p-0 ratio ratio-1x1 position-relative overflow-hidden d-flex justify-items-center">
<img class="whos-that-pokemon position-absolute h-100" src="/404/lines.gif">
<img class="whos-that-pokemon position-absolute h-100" src="/404/lines.gif" alt="" />
<div class="d-flex flex-col-reverse flex-lg-row">
<div class="">
<img class="w-100 starburst top-0 bottom-0 left-0 right-0" src="/404/glow.png">
<div class="d-flex flex-column-reverse flex-lg-row">
<div>
<img class="w-100 starburst top-0 bottom-0 left-0 right-0" src="/404/glow.png" alt="" />
<!-- ✨ Name is placed in a data attribute for later use -->
<img class="m-auto position-absolute w-50 top-0 left-25 bottom-10 right-0 d-block img-fluid masked-image top-50 start-50 translate-middle" src={pokedexImage} alt={pokemonName} data-name={pokemonName} onclick="dataLayer.push({'event': '404reveal','pokemonName': this.getAttribute('data-name')});"/>
<img
class="m-auto position-absolute w-75 top-0 left-25 bottom-10 right-0 d-block img-fluid masked-image top-50 start-50 translate-middle pokemon-clickable"
src={pokedexImage}
alt=""
data-name={pokemonName}
role="button"
tabindex="0"
draggable="false"
aria-label="Reveal the Pokémon"
/>
</div>
</div>
</div>
<!-- Pokémon name reveal -->
<div class="col-12 text-center mt-3">
<h3 id="pokemon-name" class="opacity-0 transition-opacity">???</h3>
<h3
id="pokemon-name"
class="opacity-0 pokemon-transition"
aria-live="polite"
aria-atomic="true"
>???</h3>
<button
id="play-again"
class="btn btn-purple mt-3 opacity-0 pokemon-transition"
style="pointer-events: none;"
aria-hidden="true"
>
Guess another Pokémon
</button>
</div>
</div>
<script>
const img = document.querySelector('.masked-image');
const nameEl = document.querySelector('#pokemon-name');
img?.addEventListener('click', () => {
img.classList.remove('masked-image');
nameEl.textContent = img.dataset.name || "Unknown Pokémon";
nameEl.classList.remove('opacity-0');
});
</script>
</div>
</div>
<Footer slot="footer" />
</Layout>
</Layout>
<script>
const img = document.querySelector('.masked-image') as HTMLImageElement | null;
const nameEl = document.querySelector('#pokemon-name');
const playAgainBtn = document.querySelector('#play-again') as HTMLButtonElement | null;
const hintEl = document.querySelector('#reveal-hint');
function revealPokemon() {
if (!img || !nameEl) return;
const doReveal = () => {
// Remove masked styles and interactivity from image
img.classList.remove('masked-image', 'pokemon-clickable');
img.removeAttribute('role');
img.removeAttribute('tabindex');
img.removeAttribute('aria-label');
img.style.animation = '';
// Update alt text now that it's revealed
img.alt = img.dataset.name || 'Unknown Pokémon';
// Reveal name
nameEl.textContent = img.dataset.name || 'Unknown Pokémon';
nameEl.classList.remove('opacity-0');
// Update hint text
if (hintEl) {
hintEl.querySelector('p')!.textContent = "It's " + (img.dataset.name || 'Unknown Pokémon') + "!";
}
// Show play again button
if (playAgainBtn) {
playAgainBtn.classList.remove('opacity-0');
playAgainBtn.style.pointerEvents = '';
playAgainBtn.removeAttribute('aria-hidden');
}
// Fire analytics safely
try {
if (typeof dataLayer !== 'undefined') {
dataLayer.push({ event: '404reveal', pokemonName: img.dataset.name });
}
} catch (e) {
// Analytics unavailable, continue silently
}
};
if (!document.startViewTransition) {
doReveal();
return;
}
img.style.viewTransitionName = 'pokemon-reveal';
document.startViewTransition(() => {
doReveal();
}).finished.then(() => {
img.style.viewTransitionName = '';
});
}
img?.addEventListener('click', revealPokemon);
img?.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
revealPokemon();
}
});
playAgainBtn?.addEventListener('click', () => {
window.location.reload();
});
</script>

196
src/pages/api/inventory.ts Normal file
View File

@@ -0,0 +1,196 @@
import type { APIRoute } from 'astro';
import { db } from '../../db/index';
import { inventory, priceHistory } from '../../db/schema';
import { client } from '../../db/typesense';
import { eq } from 'drizzle-orm';
const GainLoss = (purchasePrice: any, marketPrice: any) => {
if (!purchasePrice || !marketPrice) return '<div class="fs-6 fw-semibold">N/A</div>';
const pp = Number(purchasePrice);
const mp = Number(marketPrice);
if (pp === mp) return '<div class="fs-6 fw-semibold text-warning">-</div>';
if (pp > mp) return `<div class="fs-6 fw-semibold text-danger">-$${(pp - mp).toFixed(2)}</div>`;
return `<div class="fs-6 fw-semibold text-success">+$${(mp - pp).toFixed(2)}</div>`;
}
const DollarToInt = (dollar: any) => {
if (dollar === null) return null;
return Math.round(dollar * 100);
}
const getInventory = async (userId: string, cardId: number) => {
const card = await db.query.cards.findFirst({
where: { cardId: cardId, },
with : { prices: {
with: { inventories: { where: { userId: userId } }, }
}, },
});
const invHtml = card?.prices?.flatMap(price => price.inventories.map(inv => {
const marketPrice = price.marketPrice;
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="${price.cardId}"
data-purchase-price="${inv.purchasePrice}"
data-note="${(inv.note || '').replace(/"/g, '&quot;')}">
<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">${price.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, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
// First add to database
const inv = await db.insert(inventory).values({
userId: userId,
skuId: skuId,
catalogName: catalogName,
purchasePrice: purchasePrice.toFixed(2),
quantity: quantity,
note: note,
}).returning();
// Get card details from the database to add to Typesense
const card = await db.query.cards.findFirst({
where: { cardId: cardId },
with: { set: true },
});
try {
// And then add to Typesense for searching
await client.collections('inventories').documents().import(inv.map(i => ({
id: i.inventoryId,
userId: i.userId,
catalogName: i.catalogName,
sku_id: i.skuId.toString(),
purchasePrice: DollarToInt(i.purchasePrice),
productLineName: card?.productLineName,
rarityName: card?.rarityName,
setName: card?.set?.setName || "",
cardType: card?.cardType || "",
energyType: card?.energyType || "",
card_id: card?.cardId.toString() || "",
content: [
card?.productName,
card?.productLineName,
card?.set?.setName || "",
card?.number,
card?.rarityName,
card?.artist || ""
].join(' '),
})));
} catch (error) {
console.error('Error adding inventory to Typesense:', error);
}
}
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) => {
// Update the database
await db.update(inventory).set({
quantity: quantity,
purchasePrice: purchasePrice.toFixed(2),
note: note,
}).where(eq(inventory.inventoryId, inventoryId));
// No need to update Typesense since we don't search by quantity or price
}
export const POST: APIRoute = async ({ request, locals }) => {
// Access form data from the request body
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 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';
const condition = formData.get('condition')?.toString() || 'Near Mint';
const skuId = await db.query.skus.findFirst({
where: { cardId: cardId, condition: condition },
columns: { skuId: true },
}).then(sku => sku?.skuId);
if (!skuId) {
return new Response('SKU not found for card', { status: 404 });
}
await addToInventory(userId!, cardId, skuId, 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:
// No action = list inventory for this card
return getInventory(userId!, cardId);
}
// Always return current inventory after a mutation
return getInventory(userId!, cardId);
};

95
src/pages/api/upload.ts Normal file
View 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 });
}
};

View File

@@ -1,42 +1,89 @@
---
import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro';
export const prerender = false;
import Layout from '../layouts/Main.astro';
import Footer from '../components/Footer.astro';
---
<Layout>
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<h1>Contact Us</h1>
<Layout title="Contact Us">
<div class="container-fluid container-sm my-5" slot="page">
<div class="row mb-4">
<div class="col-12">
<h1>Contact Us</h1>
</div>
<div class="col-12 col-md-8 col-lg-6">
<form action="https://docs.google.com/forms/u/0/d/e/1FAIpQLSc4F7VZjZ6ImWnNRqzMLyAWnyGQdEC3Nr2xtbzugewky239kg/formResponse" method="POST" id="contactForm">
<!-- Name input -->
<div class="mb-3">
<label for="name" class="form-label">Full Name</label>
<input type="text" class="form-control" id="name" name="entry.563494744" required>
</div>
<form action="https://docs.google.com/forms/u/0/d/e/1FAIpQLSc4F7VZjZ6ImWnNRqzMLyAWnyGQdEC3Nr2xtbzugewky239kg/formResponse" method="POST" id="contactForm" target="hidden-iframe">
<!-- Email address input -->
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="entry.577942868" aria-describedby="emailHelp" required>
<div id="emailHelp" class="form-text">We'll never share your email with anyone else.</div>
</div>
<!-- Honeypot field to deter spam -->
<div style="display:none" aria-hidden="true">
<label for="honeypot">Leave this field blank</label>
<input type="text" id="honeypot" name="honeypot" tabindex="-1" autocomplete="off" />
</div>
<!-- Message textarea -->
<div class="mb-3">
<label for="message" class="form-label">Message</label>
<textarea class="form-control" id="message" name="entry.1640055664" rows="4" required></textarea>
</div>
<!-- Name input -->
<div class="mb-3">
<label for="name" class="form-label">Full Name</label>
<input type="text" class="form-control" id="name" name="entry.563494744" required />
</div>
<!-- Submit button -->
<button type="submit" class="btn btn-light">Submit</button>
<!-- Email address input -->
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="entry.577942868" aria-describedby="emailHelp" required />
<div id="emailHelp" class="form-text">We'll never share your email with anyone else.</div>
</div>
<!-- Message textarea -->
<div class="mb-3">
<label for="message" class="form-label">Message</label>
<textarea class="form-control" id="message" name="entry.1640055664" rows="4" required></textarea>
</div>
<!-- Submit button -->
<button type="submit" class="btn btn-purple" id="submitBtn">Submit</button>
</form>
<!-- Hidden iframe absorbs the Google Forms redirect -->
<iframe name="hidden-iframe" style="display:none" aria-hidden="true"></iframe>
<!-- Success message (hidden until submission) -->
<div id="successMsg" class="alert alert-success mt-3 d-none" role="alert">
Thanks for reaching out! We'll get back to you soon.
</div>
</div>
</div>
</div>
<Footer slot="footer" />
</Layout>
<script>
const form = document.getElementById('contactForm') as HTMLFormElement | null;
const submitBtn = document.getElementById('submitBtn') as HTMLButtonElement | null;
const successMsg = document.getElementById('successMsg');
const honeypot = document.getElementById('honeypot') as HTMLInputElement | null;
const iframe = document.querySelector('iframe[name="hidden-iframe"]') as HTMLIFrameElement | null;
form?.addEventListener('submit', (e) => {
// Honeypot check — bail silently if filled in by a bot
if (honeypot?.value) {
e.preventDefault();
return;
}
if (!submitBtn || !successMsg) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Sending...';
});
// iframe load fires after Google Forms redirects into it — treat as success
iframe?.addEventListener('load', () => {
if (!form || !submitBtn || !successMsg) return;
// Ignore the initial empty load before any submission
if (!submitBtn.disabled) return;
form.reset();
form.classList.add('d-none');
successMsg.classList.remove('d-none');
dataLayer.push({ event: 'contact_form_submit' });
});
</script>

294
src/pages/dashboard.astro Normal file
View File

@@ -0,0 +1,294 @@
---
import Layout from "../layouts/Main.astro";
import Footer from "../components/Footer.astro";
import BackToTop from "../components/BackToTop.astro";
import FirstEditionIcon from "../components/FirstEditionIcon.astro";
import { db } from '../db/index';
import { inventory, skus } from '../db/schema';
import { sql, sum, eq } from "drizzle-orm";
const { userId } = Astro.locals.auth();
const summary = await db
.select({
totalQty: sum(inventory.quantity).mapWith(Number),
totalValue: sum(sql`(${inventory.quantity} * ${skus.marketPrice})`).mapWith(Number),
totalGain: sum(sql`(${inventory.quantity} * (${skus.marketPrice} - ${inventory.purchasePrice}))`).mapWith(Number),
})
.from(inventory)
.innerJoin(skus, eq(inventory.skuId, skus.skuId))
.where(eq(inventory.userId, userId!))
.execute()
.then(res => res[0]);
const totalQty = summary.totalQty || 0;
const totalValue = summary.totalValue || 0;
const totalGain = summary.totalGain || 0;
---
<Layout title="Inventory Dashboard">
<div class="container-fluid container-sm mt-3" slot="page">
<BackToTop />
<div class="row mb-4">
<aside class="col-12 col-md-2 border-end border-secondary bg-dark p-3 d-flex flex-column gap-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-uppercase text-secondary fw-bold ls-wide" style="letter-spacing:.08em">Catalogs</h6>
<button
class="btn btn-purple-secondary fs-7"
title="New catalog"
type="button"
data-bs-toggle="modal"
data-bs-target="#newCatalogModal"
>+ New</button>
</div>
<ul id="catalogList" class="list-group list-group-flush">
<li
class="list-group-item list-group-item-action fw-semibold border-0 rounded p-2 d-flex align-items-center active"
data-catalog="all"
role="button"
style="cursor:pointer"
>
<span class="d-flex align-items-center gap-2">
View all cards
</span>
<span class="badge rounded-pill text-bg-secondary small ms-auto">{totalQty}</span>
</li>
{["Case Cards", "Japanese Singles", "Bulk"].map((name) => (
<li
class="ms-2 list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center gap-2"
data-catalog={name}
role="button"
style="cursor:pointer"
>
{name}
</li>
))}
</ul>
<div class="mt-auto pt-3 border-top border-secondary small text-secondary">
<div class="d-flex justify-content-between mb-1"><span>Total Cards</span><span class="text-light fw-semibold">{totalQty}</span></div>
<div class="d-flex justify-content-between mb-1"><span>Market Value</span><span class="text-success fw-semibold">${totalValue.toFixed(0)}</span></div>
<div class="d-flex justify-content-between"><span>Gain/Loss</span><span class={`fw-semibold ${totalGain >= 0 ? "text-success" : "text-danger"}`}>{totalGain >= 0 ? "+" : ""}${Math.abs(totalGain).toFixed(0)}</span></div>
</div>
</aside>
<main class="col-12 col-md-10 p-4">
<div class="d-flex flex-wrap gap-2 align-items-center mb-4">
<div class="d-flex align-items-center gap-1">
<button id="btnGrid" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn active" title="Images view">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1.5rem" height="1.5rem" fill="currentColor"><path d="M351.6 220.4C349.7 258.3 381.1 288.3 418 286.5L527.6 281.1C593 275.5 589.7 214.9 589 162.5C593.9 90.4 588.4 39.6 502.4 44.6C391.5 42.2 352.5 36.7 354.5 164.3C353.4 183.3 352.4 202.4 351.5 220.3zM418.4 168.6L418.4 168.6C419.6 147.4 420.8 125.9 421.6 109.4C421.6 109.2 421.6 109.1 421.7 109C421.7 108.8 422 108.6 422.1 108.6C456.2 108.5 491.4 108.6 525.7 108.7C525.8 108.7 526 109 526.1 109.1L526.1 109.3C525.5 142.4 524.9 184.6 524.2 217.3L415.6 222.6C416.3 207.3 417.4 188.1 418.5 168.7zM301.4 112.5C303.3 74.7 272 44.6 235 46.4L125.4 51.8C40.8 58.8 69.2 164.2 63.1 222.4C62.4 258.3 91.2 288.3 127.5 288.3C159.4 288.3 198.3 288.3 231 288.3C298.6 286.3 297.2 220.3 298.5 168.6C299.6 149.6 300.6 130.5 301.5 112.6zM234.6 164.3C233.4 185.6 232.2 207 231.4 223.5C231.4 223.7 231.4 223.8 231.3 223.9C231.3 224 231.1 224.2 231.1 224.2L231 224.3C198.3 224.2 159.3 224.3 127.3 224.3C127.3 224.3 127.2 224.3 127.1 224.2C127 224.1 126.9 224 126.9 224L126.9 223.8C127.5 192.1 128.1 149.9 128.8 115.8L237.4 110.5C236.7 125.8 235.6 145 234.5 164.4zM63.6 404C64.5 421.9 65.5 441 66.6 460C68.2 512.2 65.9 577.1 134.1 579.7L237.6 579.7C273.9 579.7 302.7 549.7 302 513.9C301.7 498.8 301.4 480.4 301.1 461.9C301.8 409.5 305 348.9 239.7 343.3L130 338C93 336.2 61.7 366.2 63.6 404.1zM130.4 455.8C129.3 436.4 128.3 417.1 127.5 401.8L236.1 407.1C236.8 441.2 237.3 483.4 238 515C238 515.1 238 515.1 238 515.2C238 515.3 237.9 515.4 237.8 515.5C237.7 515.6 237.6 515.6 237.6 515.6C205.8 515.6 166.4 515.5 134 515.6C133.9 515.6 133.7 515.3 133.7 515.2C133.6 515.1 133.6 515 133.6 514.8C132.8 498.3 131.6 476.9 130.4 455.6L130.4 455.6zM523 578.1C560 579.9 591.3 549.8 589.4 512C588.5 494.1 587.5 475 586.4 456C585.9 381.4 580.2 328.2 490 336.3C457.9 336.4 439 336.3 415.4 336.3C379.1 336.3 350.3 366.3 351 402.1C351.3 417.2 351.6 435.6 351.9 454.1C351.2 506.5 348 567.1 413.3 572.7L523 578.1zM525.5 514.1L416.9 508.8C416.2 474.7 415.7 432.5 415 400.9L415 400.7C415 400.6 415.3 400.3 415.3 400.3C443.6 400.3 484.7 400.3 518.9 400.3C518.9 400.3 519 400.3 519.1 400.4C519.2 400.5 519.3 400.6 519.3 400.7C519.4 400.8 519.4 400.9 519.4 401.1C521.1 435.4 524 484.9 525.4 514.3z"/></svg>
<span class="ms-1">Grid</span>
</button>
<button id="btnTable" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn" title="List view">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1.5rem" height="1.5rem" fill="currentColor" style="d-inline"><path d="M102.3 211.8C110.7 214 118.9 213.7 126.2 210C132.6 208 138.3 204 142.4 198.4C184.2 169.6 148.5 98.8 98.3 108.3C39.6 119.9 49.3 205.8 102.2 211.8zM114.4 375.6C114.4 375.6 114.8 375.5 116.2 375.3L116.5 375.2C169.2 368.9 178.6 283.3 120.1 271.7C69.9 262.3 34.2 332.9 76 361.8C80.1 367.4 85.9 371.4 92.3 373.4C99 376.6 106.8 377.6 114.5 375.5zM116.8 423.5C63.5 413.9 30 495.4 84.4 522.3C130.5 544.4 183.2 485.4 150.4 446.7C147.9 440.2 143.4 434.9 137.7 431.2C132.1 426.4 124.8 423.5 116.8 423.5zM352.8 508.4C423.1 503.6 491.2 499 561.5 501.8C579.2 502.5 594.1 488.8 594.8 471.1C595.5 453.4 581.7 438.6 564.1 437.9C490.4 435 416.4 439.9 344.2 444.8C310.8 447 277.8 449.3 245.4 450.7C227.7 451.4 214 466.4 214.8 484C215.5 501.7 230.5 515.4 248.1 514.6C283.8 513 318.6 510.7 352.8 508.4zM344 344.8L344.3 344.8C412.9 343.4 479.9 342.9 548.3 346.2C566 347 580.9 333.3 581.7 315.6C582.5 298 568.8 283 551.1 282.2C482.2 278.8 412.2 279.3 343.1 280.7C310.3 281.3 277.9 281.9 245.8 281.9C228.1 281.9 213.8 296.2 213.8 313.9C213.8 331.6 228.1 345.9 245.8 345.9C278.5 345.9 311.4 345.3 343.9 344.7zM444.8 187.4C480.8 186.5 519 181.7 551.8 187.6C569.2 190.7 585.8 179.1 588.9 161.7C592 144.3 580.4 127.7 563 124.6C484.4 115.2 408.3 126.5 331 129.9C301.8 131.9 273.7 133 246 131.4C228.4 130.3 213.2 143.8 212.2 161.4C213.8 217.2 300.2 190.8 335.5 193.7C372.2 191.2 408.7 188.4 444.8 187.4z"/></svg>
<span class="ms-1">List</span>
</button>
</div>
<div class="vr opacity-25 mx-1"></div>
<a href="/pokemon" class="btn btn-vendor">+ Add Card</a>
<button
type="button"
class="btn btn-secondary"
data-bs-toggle="modal"
data-bs-target="#bulkImportModal"
>Bulk Import</button>
<div class="ms-auto position-relative">
<div class="input-group">
<input type="hidden" name="start" id="start" value="0" />
<input type="hidden" name="sort" id="sortInput" value="" />
<input type="hidden" name="language" id="languageInput" value="all" />
<input type="search" name="i" id="searchInput" class="form-control search-input" placeholder="Search your inventory" />
<button
type="submit"
class="btn btn-purple border-start-0"
aria-label="search"
onclick="
const i = this.closest('form').querySelector('[name=i]').value;
dataLayer.push({ event: 'view_inventory_results', search_term: i });
if (window.location.pathname !== '/dashboard') {
event.preventDefault();
event.stopPropagation();
sessionStorage.setItem('pendingSearch', 1);
window.location.href = '/dashboard';
}
"
>
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
</button>
</div>
</div>
</div>
<div id="inventoryView">
<div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load">
</div>
<!-- <div id="tableView" style="display:none">
<div class="inv-list-wrap">
<table class="table align-middle mb-0 inv-list-table">
<tbody id="inventoryRows">
{inventory.map(card => {
const market = nmPrice(card);
const purchase = nmPurchase(card);
const diff = market - purchase;
const pct = purchase > 0 ? (diff / purchase) * 100 : 0;
const isGain = diff >= 0;
return (
<tr class="inv-list-row">
<td class="inv-list-cardcell">
<div class="inv-list-card">
<div
class="inv-list-thumb card-trigger"
data-card-id={card.productId}
data-bs-toggle="modal"
data-bs-target="#cardModal"
>
<img
src={`/cards/${card.productId}.jpg`}
alt={card.productName}
loading="lazy"
decoding="async"
onerror="this.onerror=null;this.src='/cards/default.jpg';"
/>
</div>
<div class="inv-list-info">
<div
class="inv-list-name"
data-card-id={card.productId}
data-bs-toggle="modal"
data-bs-target="#cardModal"
style="cursor:pointer"
>
{card.productName}
</div>
<div class="inv-list-meta">
<div class="inv-list-setlink">{card.setName}</div>
<div>{card.rarityName}</div>
<div>{card.number}</div>
</div>
<div class="inv-list-condition">
<span>Near Mint</span>
<span>•</span>
<span>{card.variant !== "Normal" ? card.variant : "Holofoil"}</span>
</div>
</div>
<div class="inv-list-right">
<div class={`inv-list-price-line ${isGain ? "up" : "down"}`}>
<span class="inv-grid-arrow small">{isGain ? "▲" : "▼"}</span>
<span class="inv-list-price">${market.toFixed(2)}</span>
</div>
<div class={`inv-list-delta ${isGain ? "up" : "down"}`}>
{isGain ? "+" : "-"}${Math.abs(diff).toFixed(2)} ({isGain ? "+" : "-"}{Math.abs(pct).toFixed(2)}%)
</div>
<div class="inv-list-qty">Qty: {card.qty}</div>
</div>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div class="text-secondary small mt-2 ps-1" id="rowCount"></div>
</div> -->
</div>
</main>
</div>
</div>
<!-- <div class="modal fade" id="newCatalogModal" tabindex="-1" aria-labelledby="newCatalogLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="newCatalogLabel">Create Catalog</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<label class="form-label small text-secondary text-uppercase fw-semibold" for="catalogNameInput">Catalog Name</label>
<input id="catalogNameInput" type="text" class="form-control bg-dark-subtle text-light border-secondary" placeholder="e.g. Japanese Holos" />
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="createCatalogBtn">Create</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="bulkImportModal" tabindex="-1" aria-labelledby="bulkImportLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="bulkImportLabel">Bulk CSV Import</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="small text-secondary mb-3">
Upload a CSV exported from Collectr, TCGPlayer, or any marketplace. Columns: <code>name, set, condition, qty, price, market</code>.
</p>
<label class="form-label small text-secondary text-uppercase fw-semibold" for="csvFileInput">Choose File</label>
<input id="csvFileInput" type="file" accept=".csv" class="form-control bg-dark-subtle text-light border-secondary" />
<div id="csvPreview" class="mt-3 d-none">
<p class="small text-secondary fw-semibold mb-1">Preview</p>
<div class="border border-secondary rounded p-2 small text-secondary" id="csvPreviewContent">—</div>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="csvUploadBtn">Upload &amp; Preview</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="inventoryEditModal" tabindex="-1" aria-labelledby="inventoryEditLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="inventoryEditLabel">Edit Inventory</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="inventoryEditBody">
<p class="text-secondary small">Select a card to edit its quantity and purchase price.</p>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success">Save</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="addCardModal" tabindex="-1" aria-labelledby="addCardLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="addCardLabel">Add Card</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="text" class="form-control bg-dark-subtle text-light border-secondary mb-3" placeholder="Search card name…" id="addCardSearch" />
<p class="text-secondary small">Search results will appear here. Connect to your card database API to enable live search.</p>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" disabled>Add Selected</button>
</div>
</div>
</div>
</div> -->
<Footer slot="footer" />
</Layout>

View File

@@ -1,33 +1,186 @@
---
export const prerender = false;
import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import Search from '../components/Search.astro';
import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro';
export const prerender = false;
import { Waitlist as WaitlistAstro } from '@clerk/astro/components'
import { Show, SignInButton } from '@clerk/astro/components'
import Hero from '../components/Hero.astro';
import BackToTop from '../components/BackToTop.astro';
import NavItems from '../components/NavItems.astro';
---
<Layout>
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<h1>Rigid's App Thing</h1>
<h5 class="text-secondary">(working title)</h5>
<div class="col-12 col-md-6 col-xl-7 mb-2">
<h4 class="mt-3">Welcome!</h4>
<p class="mt-2">
This single-page web application is currently in a closed beta. Access to the beta will be limited, and the selection process will be highly curated. You are welcome to request access - if you do not get into the beta, don't worry! After the closed beta is complete, the app will move into a more open beta.</p>
</p>
<p class="my-2">
If you would like to join the waitlist, please enter your email address. You will receive an email with instructions on how to access the app when it becomes available to you.
</p>
<p class="my-2">
If you aren't interested in joining the waitlist, that is okay too! Feel free to play "Who's that Pokémon?" with the random Pokémon generator <a href="/404">here</a>. Refresh the page to see a new Pokémon!
</p>
<Layout title="RAT - Realtime, Accurate and Transparent TCG Pricing Data" >
<Hero slot="page" />
<div slot="page">
<!-- ═══════════════════════════════════════════
SOCIAL PROOF / STATS BAR
═══════════════════════════════════════════ -->
<section class="stats-bar py-4 border-top border-bottom border-subtle" aria-label="Platform statistics">
<div class="container">
<ul class="list-unstyled d-flex flex-wrap justify-content-center justify-content-md-between gap-4 mb-0 text-center">
<li>
<strong class="d-block fs-4 fw-bold text-orchid">Pokémon TCG</strong>
<span class="text-body-secondary small">All EN and JP Sets</span>
</li>
<li>
<strong class="d-block fs-4 fw-bold text-orchid">All Conditions</strong>
<span class="text-body-secondary small">NM · LP · MP · HP · DMG</span>
</li>
<li>
<strong class="d-block fs-4 fw-bold text-orchid">Real-Time</strong>
<span class="text-body-secondary small">Accurate Market Prices</span>
</li>
<li>
<strong class="d-block fs-4 fw-bold text-orchid">100% Free</strong>
<span class="text-body-secondary small">Pricing Features Always Free</span>
</li>
</ul>
</div>
<div class="col-12 col-md-6 col-xl-4 offset-xl-1">
<WaitlistAstro />
</section>
<!-- ═══════════════════════════════════════════
CORE FEATURES
═══════════════════════════════════════════ -->
<section class="py-6" aria-labelledby="features-heading">
<div class="container">
<header class="text-center mb-5">
<h2 id="features-heading" class="h1 fw-bold">Everything you need to collect smarter</h2>
<p class="text-body-secondary lead mt-2">Built by collectors, for collectors. No fluff.</p>
</header>
<div class="row g-4">
<article class="col-md-5 offset-md-1">
<div class="feature-card h-100 p-4 rounded-3">
<div class="feature-icon mb-3" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" fill="currentColor"><path d="M61.1 80C65.6 80 69.9 80.9 73.8 82.6C94.9 83.1 114.1 87.8 133.4 89C200.4 91.5 253.4 119.6 315.5 154.1C395 101.5 477 91.8 567.9 91.8C575.4 91.8 582.3 94.4 587.7 98.7C599.8 103.9 607.9 116.3 607 130.2C608.8 244.9 610.7 359.8 607.1 480.4C607 499.1 590.9 513.5 572.4 512.2C492.7 511.5 409.1 518.2 343.4 564.9C337.4 572 328.1 576.5 318.1 576.2C278.5 566 227.1 523.4 183.2 514.2C146.8 502.7 108.4 497.3 59 497.3C41 497.7 25.4 481 27.1 463.1C32.7 382.2 31.5 301.8 30.2 219.9C29.6 184.4 29.1 148.6 29.1 112.3C29.1 94.6 43.4 80.3 61.1 80.3zM351.3 487C411.1 455.7 476.9 449.1 543.4 448.6C542.9 366.4 549.9 301.3 544.5 225.1C543.3 202.7 542.1 179.4 542.2 155.9C467.7 157.9 407.4 169.4 349.4 208.2L351 318.9C352.1 348.3 351.7 428.5 351.3 487zM285.4 210.6C222.2 172.7 168.9 152.1 99.5 149.6C98 149.4 94.8 148.9 93.3 148.7C94.1 243.5 97.5 338.5 92.9 433.9C165.6 437.5 224.2 455.5 287.2 489.7C287.6 430.8 288.1 349.6 286.9 321.1C286.9 319.9 285.3 212 285.3 210.6z"/></svg>
</div>
<h3 class="h5 fw-semibold mb-2">Complete Card Database</h3>
<p class="text-body-secondary mb-0">Search across every English and Japanese set. Find any card instantly with the condition-by-condition pricing you need to buy, sell, or trade with confidence.</p>
</div>
</article>
<article class="col-md-5">
<div class="feature-card h-100 p-4 rounded-3">
<div class="feature-icon mb-3" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" fill="currentColor"><path d="M352 75.9C341.8 27.5 275.6 48.1 289.7 94.7C228.2 99.8 143.7 131.4 148 205.2C146.2 288.3 198 322.6 268.5 334.4C275.9 336.1 282.3 337.7 289.4 339.1C287.8 386.9 288.9 436.4 289.2 486.6C281 486 272.6 485.3 263.8 484.5C230.4 486.3 155 458.7 152.2 513.8C158.4 561.8 224.6 538.9 257.8 548.2C267.9 549.1 278.2 550 288.6 550.7C280.7 604.6 357.9 605.5 352.6 551.4C436.9 548.7 522.3 498.7 510.9 401.3C491.9 312.8 416.8 300.9 354.7 285.5C355.7 247.4 353.5 210.7 354.9 172.9C355 167.1 355.2 161.2 355.3 155.2C390.7 151.6 466.5 183.2 472.1 128.2C470.4 86.7 423.3 98.1 394.1 93.7C380.6 92.7 367.6 91.5 353.6 91.1C353.2 86.1 352.6 81.1 352 76.1zM291.2 159.1C291.1 162.7 291 166.3 291 169.9C289.7 203.1 291.5 240.2 291 273.8C287.7 273 284.3 272.2 280.9 271.5C266.1 268.8 226.6 257.8 221.8 247.9C216.1 237.1 211.8 221.1 212.1 206.6C212.4 191.9 217 184.6 221.9 181.4C239.9 169.3 264.3 163 291.3 159.1zM353.2 350.2C395.6 359.3 446.4 378.1 447.9 411.8C452.7 424.7 433.7 465.1 421.1 468.7C398.1 479.6 376 484.9 353.3 487C353.2 469.7 353.1 452.1 352.9 436.4C352.5 402.6 352.4 382 353.3 350.2z"/></svg>
</div>
<h3 class="h5 fw-semibold mb-2">Condition-Graded Pricing</h3>
<p class="text-body-secondary mb-0">NM, LP, MP, HP, and DMG prices displayed side by side. Stop guessing what a played card is worth — see every tier at once so you never undersell or overpay.</p>
</div>
</article>
<article class="col-md-5 offset-md-1">
<div class="feature-card h-100 p-4 rounded-3">
<div class="feature-icon mb-3" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" fill="currentColor"><path d="M131.3 233.2C168.5 232.6 164.2 190.9 162.6 164.5C190.2 164.1 230.5 171.2 232.2 132.3C230.2 92.4 188.2 101 160.1 100.5C159.4 73.8 164.9 32.3 127.4 31.6C89.7 32.2 94.7 74.8 96.1 101.4C84.7 101.4 73.4 102.9 62 103.7C44.4 104.9 31.1 120.2 32.3 137.9C33.5 155.6 48.9 168.8 66.5 167.6L97.5 165.4L98.6 165.4C99.1 191.9 94.2 232.7 131.3 233.3zM351.7 89.4C346.2 56.4 295.9 55.4 289.2 88.2C274.7 149.7 261.2 189.6 226.5 236.8L162.1 274.8C154.2 278 146 281.3 137.7 284.7C119.7 292.1 101.3 299.6 85.2 305.6C57.3 314.9 58 357.6 86.2 366C123.2 378.2 185.4 393.6 225.9 403.2L275.1 508.8L284.9 541C292.3 568.9 334.2 571.4 344.8 544.5C366.2 495.4 391.6 447.3 414 398C421.1 395.3 427.8 392.7 434.3 390.2C465.2 377.4 499.8 367.3 533.2 358.9C560.9 352.5 565.3 311.7 539.6 299.5C530.9 295.1 522.2 290.5 513.3 285.9L513.3 285.9C480.7 268.9 446.8 251.2 412 236L369.3 162.7C363.7 146 355.7 109.7 351.7 89.5L351.7 89.4L351.7 89.4zM318.2 449.9L277.3 362.1C273.1 353.2 265.1 346.6 255.5 344.4L255.2 344.4L254.3 344.2C235.6 339.9 217.8 335.5 194.9 329.8L264.5 288.7C290.5 265.4 306.2 230.8 319.4 204.3L362 277.2C380.9 299.8 414.9 303.7 439.5 319.7C428.6 323.6 418 327.7 407.7 331.7L407.7 331.7C391.2 339.5 369.1 341 361.1 359.5C347.8 389.5 332.9 419.7 318.3 449.8zM579.4 551.9C621.3 551.7 621.2 488.1 579.4 487.9L546.3 487.9C545.8 458.8 550.9 428.4 516.7 424C480.7 422.1 479.7 460.5 482.3 487.9L451.4 487.9C433.7 487.9 419.4 502.2 419.4 519.9C419.4 537.6 433.7 551.9 451.4 551.9L482.7 551.9C480.9 580.3 479.1 614.5 514.2 615.9C549.5 614.4 546.9 580.5 546.7 551.9L579.3 551.9z"/></svg>
</div>
<h3 class="h5 fw-semibold mb-2">All Variants</h3>
<p class="text-body-secondary mb-0">We display every card variant separately—no stacking—so you can see true edition-level prices, trends, and rarity at a glance..</p>
</div>
</article>
<article class="col-md-5">
<div class="feature-card h-100 p-4 rounded-3">
<div class="feature-icon mb-3" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" fill="currentColor"><path d="M282.5 63.6C42.4 80.2-19.5 451.3 198.9 550.3C304.7 599.7 469.7 580.2 540.7 463.3C568.1 414.5 576.4 348.1 576.4 298.4C570.5 220.7 504.8 130 442.3 92.4C392.2 63.7 333.8 64.3 282.5 63.6zM182.6 175.9C206.7 151.4 247.1 133.5 283.8 127.6C336.9 128.2 388.8 127.6 429.6 160.2C456.6 183.9 513.6 262.4 512.4 298.4C512.4 343.9 504.4 397.3 484.9 431.9C440.5 507.9 311.1 532.7 226.4 492.4C106.4 438.8 101.7 266.4 182.6 175.9zM353.7 207.9C354.1 166.1 290.4 165.2 289.7 207.1L288.4 315.8C290.9 362.7 346.7 343.6 378.7 349.6C404.9 352.1 446.9 357.6 448.4 320.3C446 272.8 385.2 290 352.8 284.1L353.8 207.9L353.8 207.9z"/></svg>
</div>
<h3 class="h5 fw-semibold mb-2">Fast Search, Instant Results</h3>
<p class="text-body-secondary mb-0">Type a card name + number or search by eras like "e-reader" or "SWSH". Powerful filters let you drill into exactly the set, variant, rarity or card type you care about.</p>
</div>
</article>
</div>
</section>
<!-- ═══════════════════════════════════════════
UPCOMING PREMIUM / CTA TEASER
═══════════════════════════════════════════ -->
<section class="premium-section py-6" aria-labelledby="premium-heading">
<div class="container">
<div class="row align-items-center g-4">
<div class="col-lg-5">
<p class="eyebrow text-lilac mb-3">Coming Soon · Premium</p>
<h2 id="premium-heading" class="h1 fw-bold mb-3">
<span class="text-gradient">Your collection,<br/>fully managed.</span>
</h2>
<p class="text-body-secondary lead mb-4">
We're building a suite of inventory and curation tools for serious collectors. Sign up free today and be first in line when they launch.
</p>
<Show when="signed-out">
<SignInButton asChild mode="modal">
<button class="btn btn-purple btn-lg px-4">
Join Now! — It's Free
</button>
</SignInButton>
</Show>
</div>
<div class="col-lg-7">
<div class="premium-list">
<div class="premium-item p-4 rounded-3 mb-3">
<div class="d-flex align-items-start gap-3">
<span class="badge-coming">Coming Soon</span>
<div>
<h3 class="h6 fw-semibold mb-1">Collection Portfolio Tracker</h3>
<p class="text-body-secondary small mb-0">Add cards you own with their condition and purchase price. Watch your total collection value update in real time as market prices shift — so you always know your net position.</p>
</div>
</div>
</div>
<div class="premium-item p-4 rounded-3 mb-3">
<div class="d-flex align-items-start gap-3">
<span class="badge-coming">Coming Soon</span>
<div>
<h3 class="h6 fw-semibold mb-1">Latest Sales Aggregation</h3>
<p class="text-body-secondary small mb-0">See recent sale prices across different trusted marketplaces, including TCGPlayer and eBay. Make informed purchases based on real-time market activity, not just Market Price.</p>
</div>
</div>
</div>
<div class="premium-item p-4 rounded-3">
<div class="d-flex align-items-start gap-3">
<span class="badge-coming">Coming Soon</span>
<div>
<h3 class="h6 fw-semibold mb-1">Graded Card Inventory</h3>
<p class="text-body-secondary small mb-0">Log PSA, BGS, and CGC slabs with their cert numbers, grades, and current values. Track your graded portfolio separately from your raw collection.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════
FINAL CTA
═══════════════════════════════════════════ -->
<section class="cta-section py-6" aria-labelledby="cta-heading">
<div class="container text-center">
<h2 id="cta-heading" class="display-5 fw-bold mb-3">Ready to join the RAT Pack?</h2>
<p class="lead text-body-secondary mb-4 mx-auto" style="max-width: 520px;">
Join free today. Browse every card, track prices across conditions, and get early access to premium collection tools as we build them.
</p>
<div class="d-flex flex-wrap justify-content-center gap-3">
<Show when="signed-out">
<SignInButton asChild mode="modal">
<button class="btn btn-purple btn-lg px-5">
Create Free Account
</button>
</SignInButton>
</Show>
<a href="/pokemon" class="btn btn-outline-light btn-lg px-5">Browse Cards First</a>
</div>
</div>
</section>
<BackToTop />
</div>
<Footer slot="footer" />
</Layout>
</Layout>

26
src/pages/myprices.astro Normal file
View 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>

View File

@@ -4,7 +4,14 @@ import SetIcon from '../../components/SetIcon.astro';
import EnergyIcon from '../../components/EnergyIcon.astro';
import RarityIcon from '../../components/RarityIcon.astro';
import { db } from '../../db/index';
import { privateDecrypt } from "node:crypto";
import { priceHistory, skus } from '../../db/schema';
import { eq, inArray } from 'drizzle-orm';
import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
import { Tooltip } from "bootstrap";
// auth check for inventory management features
const { canAddInventory } = Astro.locals;
export const partial = true;
export const prerender = false;
@@ -12,8 +19,6 @@ export const prerender = false;
const searchParams = Astro.url.searchParams;
const cardId = Number(searchParams.get('cardId')) || 0;
// query the database for the card with the given productId and return the card data as json
const card = await db.query.cards.findFirst({
where: { cardId: Number(cardId) },
with: {
@@ -24,9 +29,7 @@ const card = await db.query.cards.findFirst({
function timeAgo(date: Date | null) {
if (!date) return "Not applicable";
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
const intervals: Record<string, number> = {
year: 31536000,
month: 2592000,
@@ -34,189 +37,557 @@ function timeAgo(date: Date | null) {
hour: 3600,
minute: 60
};
for (const [unit, value] of Object.entries(intervals)) {
const count = Math.floor(seconds / value);
if (count >= 1) return `${count} ${unit}${count > 1 ? "s" : ""} ago`;
}
return "just now";
}
// Get the most recent calculatedAt across all prices
const calculatedAt = (() => {
if (!card?.prices?.length) return null;
// Extract all valid calculatedAt timestamps
const dates = card.prices
.map(p => p.calculatedAt)
.filter(d => d) // remove null/undefined
.map(d => new Date(d));
.filter(d => d)
.map(d => new Date(d!));
if (!dates.length) return null;
// Return the most recent one
return new Date(Math.max(...dates.map(d => d.getTime())));
})();
// ── Fetch price history + compute volatility ──────────────────────────────
const cardSkus = card?.prices?.length
? await db.select().from(skus).where(eq(skus.cardId, cardId))
: [];
const skuIds = cardSkus.map(s => s.skuId);
const historyRows = skuIds.length
? await db
.select({
skuId: priceHistory.skuId,
calculatedAt: priceHistory.calculatedAt,
marketPrice: priceHistory.marketPrice,
condition: skus.condition,
})
.from(priceHistory)
.innerJoin(skus, eq(priceHistory.skuId, skus.skuId))
.where(inArray(priceHistory.skuId, skuIds))
.orderBy(priceHistory.calculatedAt)
: [];
// Rolling 30-day cutoff for volatility calculation
const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000);
const byCondition: Record<string, number[]> = {};
for (const row of historyRows) {
if (row.marketPrice == null) continue;
if (!row.calculatedAt) continue;
if (new Date(row.calculatedAt) < thirtyDaysAgo) continue;
const price = Number(row.marketPrice);
if (price <= 0) continue;
if (!byCondition[row.condition]) byCondition[row.condition] = [];
byCondition[row.condition].push(price);
}
function computeVolatility(prices: number[]): { label: string; monthlyVol: number } {
if (prices.length < 2) return { label: '—', monthlyVol: 0 };
const returns: number[] = [];
for (let i = 1; i < prices.length; i++) {
returns.push(Math.log(prices[i] / prices[i - 1]));
}
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
const variance = returns.reduce((sum, r) => sum + (r - mean) ** 2, 0) / (returns.length - 1);
const monthlyVol = Math.sqrt(variance) * Math.sqrt(30);
const label = monthlyVol >= 0.30 ? 'High'
: monthlyVol >= 0.15 ? 'Medium'
: 'Low';
return { label, monthlyVol: Math.round(monthlyVol * 100) / 100 };
}
const volatilityByCondition: Record<string, { label: string; monthlyVol: number }> = {};
for (const [condition, prices] of Object.entries(byCondition)) {
volatilityByCondition[condition] = computeVolatility(prices);
}
// ── Price history for chart (full history, not windowed) ──────────────────
const priceHistoryForChart = historyRows.map(row => ({
condition: row.condition,
calculatedAt: row.calculatedAt
? new Date(row.calculatedAt).toISOString().split('T')[0]
: null,
marketPrice: row.marketPrice,
})).filter(r => r.calculatedAt !== null);
// ── Determine which range buttons to show ────────────────────────────────
const now = Date.now();
const oldestDate = historyRows.length
? Math.min(...historyRows
.filter(r => r.calculatedAt)
.map(r => new Date(r.calculatedAt!).getTime()))
: now;
const dataSpanDays = (now - oldestDate) / 86_400_000;
const showRanges = {
'1m': dataSpanDays >= 1,
'3m': dataSpanDays >= 60,
'6m': dataSpanDays >= 180,
'1y': dataSpanDays >= 365,
'all': dataSpanDays >= 400,
};
const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
const conditionAttributes = (price: any) => {
const volatility = (() => {
const current = price?.marketPrice;
const low = price?.lowestPrice;
const high = price?.highestPrice;
const median = price?.medianPrice;
const condition: string = price?.condition || "Near Mint";
const vol = volatilityByCondition[condition] ?? { label: '—', monthlyVol: 0 };
if (current === null || low === null || high === null) return "—";
const range = Number(high) - Number(low);
if (range <= 0) return "Low";
const position = (Number(current) - Number(low)) / range;
if (position > 0.76) return "High";
if (position > 0.49) return "Medium";
return "Low";
const volatilityClass = (() => {
switch (vol.label) {
case "High": return "alert-danger";
case "Medium": return "alert-warning";
case "Low": return "alert-success";
default: return "alert-dark";
}
})();
const volatilityClass =
volatility === "High" ? "alert-danger" :
volatility === "Medium" ? "alert-warning" :
volatility === "Low" ? "alert-success" :
"";
const volatilityDisplay = vol.label === '—'
? '—'
: `${vol.label} (${(vol.monthlyVol * 100).toFixed(0)}%)`;
const condition: string = price?.condition || "Near Mint";
return {
"Near Mint": { label: "nav-nm", volatility: volatility, volatilityClass: volatilityClass, class:"show active" },
"Lightly Played": { label: "nav-lp", volatility: volatility, volatilityClass: volatilityClass },
"Moderately Played": { label: "nav-mp", volatility: volatility, volatilityClass: volatilityClass },
"Heavily Played": { label: "nav-hp", volatility: volatility, volatilityClass: volatilityClass },
"Damaged": { label: "nav-dmg", volatility: volatility, volatilityClass: volatilityClass},
"Near Mint": { label: "nav-nm", volatility: volatilityDisplay, volatilityClass, class: "show active" },
"Lightly Played": { label: "nav-lp", volatility: volatilityDisplay, volatilityClass },
"Moderately Played":{ label: "nav-mp", volatility: volatilityDisplay, volatilityClass },
"Heavily Played": { label: "nav-hp", volatility: volatilityDisplay, volatilityClass },
"Damaged": { label: "nav-dmg", volatility: volatilityDisplay, volatilityClass }
}[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`;
};
---
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
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">
<div class="modal-content" data-card-id={card?.cardId}>
<div class="modal-header border-0">
<div class="container-fluid row align-items-center">
<div class="h4 card-title pe-2 col-sm-12 col-md-auto mb-sm-1">{card?.productName}</div>
<div class="text-secondary col-auto">{card?.number}</div>
<div class="text-secondary col-auto">{card?.variant}</div>
<div class="h4 card-title pe-2 col-sm-12 col-md-auto mb-sm-1">{card?.productName}</div>
<div class="text-secondary col-auto">{card?.number}</div>
<div class="text-light col-auto">{card?.variant}</div>
</div>
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body pt-0">
<div class="d-flex gap-2 align-items-center">
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="modal"></button>
</div>
</div>
<div class="modal-body pt-0">
<div class="container-fluid">
<div class="card mb-2 border-0">
<div class="card mb-2 border-0">
<div class="row g-4">
<div class="col-sm-12 col-md-3">
<div class="position-relative mt-1"><img src={`/cards/${card?.productId}.jpg`} class="card-image w-100 img-fluid rounded-4" alt={card?.productName} onerror="this.onerror=null;this.src='/cards/default.jpg'" onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});"><span class="position-absolute bottom-0 start-0 d-inline"><SetIcon set={card?.set?.setCode} /></span><span class="position-absolute top-0 end-0 d-inline"><EnergyIcon energy={card?.energyType} /></span><span class="rarity-icon-large position-absolute bottom-0 end-0 d-inline"><RarityIcon rarity={card?.rarityName} /></span></div>
<div class="d-flex flex-row justify-content-between mt-2">
<div class="p text-secondary">{card?.set?.setCode}</div>
<div class="p text-secondary">Illustrator: {card?.Artist}</div>
</div>
</div>
<div class="col-sm-12 col-md-7">
<ul class="nav nav-tabs nav-fill border-0 me-3 mb-2" id="myTab" role="tablist">
<!-- Card image column -->
<div class="col-sm-12 col-md-3">
<div class="position-relative mt-1">
<!-- card-image-wrap gives the modal image shimmer effects
without the hover lift/scale that image-grow has in main.scss -->
<div
class="card-image-wrap rounded-4"
data-energy={card?.energyType}
data-rarity={card?.rarityName}
data-variant={card?.variant}
data-name={card?.productName}
>
<img
src={`/static/cards/${card?.productId}.jpg`}
class="card-image w-100 img-fluid rounded-4"
alt={card?.productName}
crossorigin="anonymous"
onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow, .card-image-wrap')?.setAttribute('data-default','true')"
onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});"
/>
</div>
<span class="position-absolute top-50 start-0 d-inline"><FirstEditionIcon edition={card?.variant} /></span>
<span class="position-absolute bottom-0 start-0 d-inline"><SetIcon set={card?.set?.setCode} /></span>
<span class="position-absolute top-0 end-0 d-inline"><EnergyIcon energy={card?.energyType} /></span>
<span class="rarity-icon-large position-absolute bottom-0 end-0 d-inline"><RarityIcon rarity={card?.rarityName} /></span>
</div>
<div class="d-flex flex-column flex-lg-row justify-content-between mt-2">
<div class="text-secondary"><span class="d-flex d-xxl-none">{card?.set?.setCode}</span><span class="d-none d-xxl-flex">{card?.set?.setName}</span></div>
<div class="text-secondary">Illus<span class="d-none d-xxl-inline">trator</span>: {card?.artist}</div>
</div>
</div>
<!-- Tabs + price data column -->
<div class="col-sm-12 col-md-7">
<ul class="nav nav-tabs nav-fill border-0 me-3 mb-2" id="myTab" role="tablist">
<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">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">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">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">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">Damaged</span><span class="d-inline">DMG</span>
</button>
</li>
{canAddInventory && (
<li class="nav-item" role="presentation">
<button class="nav-link nm active" id="nm-tab" data-bs-toggle="tab" data-bs-target="#nav-nm" type="button" role="tab" aria-controls="nav-nm" aria-selected="true"><span class="d-none d-md-inline">Near Mint</span><span class="d-md-none">NM</span></button>
<button class="nav-link 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>
<li class="nav-item" role="presentation">
<button class="nav-link lp" id="lp-tab" data-bs-toggle="tab" data-bs-target="#nav-lp" type="button" role="tab" aria-controls="nav-lp" aria-selected="false"><span class="d-none d-md-inline">Lightly Played</span><span class="d-md-none">LP</span></button>
</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-md-inline">Moderately Played</span><span class="d-md-none">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-md-inline">Heavily Played</span><span class="d-md-none">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-md-inline">Damaged</span><span class="d-md-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-md-inline">Inventory</span><span class="d-md-none">+/-</span></button>
</li>
</ul>
<div class="tab-content" id="myTabContent">
{card?.prices.slice().sort((a, b) => conditionOrder.indexOf(a.condition) - conditionOrder.indexOf(b.condition)).map((price) => {
const attributes = conditionAttributes(price);
return (
<div class={`tab-pane fade ${attributes?.label} ${attributes?.class}`} id={`${attributes?.label}`} role="tabpanel" tabindex="0">
<div class="d-block gap-1 d-md-flex">
<div class="d-flex flex-row flex-md-column gap-1 col-12 col-md-2 mb-0">
<div class="alert alert-secondary rounded p-2 flex-fill mb-1">
<h6>Market Price</h6>
<p class="pb-0">${price.marketPrice}</p>
</div>
<div class="alert alert-secondary rounded p-2 flex-fill mb-1">
<h6>Lowest Price</h6>
<p class="pb-0">${price.lowestPrice}</p>
</div>
<div class="alert alert-secondary rounded p-2 flex-fill mb-1">
<h6>Highest Price</h6>
<p class="pb-0">${price.highestPrice}</p>
</div>
<div class={`alert alert-secondary rounded p-2 flex-fill mb-1 ${attributes?.volatilityClass}`}>
<h6>Volatility</h6>
<p class="pb-0">{attributes?.volatility}</p>
</div>
)}
</ul>
<div class="tab-content" id="myTabContent">
{card?.prices.slice().sort((a, b) => conditionOrder.indexOf(a.condition) - conditionOrder.indexOf(b.condition)).map((price) => {
const attributes = conditionAttributes(price);
return (
<div class={`tab-pane fade ${attributes?.label} ${attributes?.class ?? ''}`} id={`${attributes?.label}`} role="tabpanel" tabindex="0">
<div class="d-flex flex-column gap-1">
<!-- Stat cards -->
<div class="d-flex flex-fill flex-row gap-1 flex-wrap flex-lg-nowrap">
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Market Price</h6>
<p class="mb-0 mt-1">${price.marketPrice}</p>
</div>
<div class="d-flex flex-column gap-1 col-12 col-md-10 mb-0 me-2">
<div class="alert alert-secondary rounded p-2 mb-1">
<h6>Latest Sales</h6>
</div>
<div class="alert alert-secondary rounded p-2 mb-1">
<h6>Placeholder for graph</h6>
</div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Lowest Price</h6>
<p class="mb-0 mt-1">${price.lowestPrice}</p>
</div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Highest Price</h6>
<p class="mb-0 mt-1">${price.highestPrice}</p>
</div>
<div class={`alert rounded p-2 flex-fill d-flex flex-column mb-0 ${attributes?.volatilityClass}`}>
<h6 class="mb-auto d-flex justify-content-between align-items-start">
<span class="me-1">Volatility</span>
<span
class="volatility-info float-end mt-0"
data-bs-toggle="tooltip"
data-bs-placement="top"
data-bs-container="body"
data-bs-custom-class="volatility-popover"
data-bs-trigger="hover focus click"
data-bs-html="true"
data-bs-title={`
<div class='tooltip-heading fw-bold mb-1'>Monthly Volatility</div>
<div class='small'>
<p class="mb-1">
<strong>What this measures:</strong> how much the market price tends to move day-to-day,
scaled up to a monthly expectation.
</p>
<p class="mb-0">
A card with <strong>30% volatility</strong> typically swings ±30% over a month.
</p>
</div>
`}
>
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 16 16"> <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/> <path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/> </svg>
</span>
</h6>
<p class="mb-0 mt-1">{attributes?.volatility}</p>
</div>
</div>
<!-- Table only — chart is outside the tab panes -->
<div class="w-100">
<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>
<thead class="table-dark">
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">Price</th>
</tr>
</thead>
<tbody>
<tr><td> </td><td> </td><td> </td></tr>
<tr><td> </td><td> </td><td> </td></tr>
<tr><td> </td><td> </td><td> </td></tr>
<tr><td> </td><td> </td><td> </td></tr>
<tr><td> </td><td> </td><td> </td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
);
})}
{canAddInventory && (
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0">
<div class="row g-5">
<div class="col-12 col-md-6">
<h6 class="mt-1 mb-2">Add {card?.productName} to inventory</h6>
<form id="inventoryForm" data-inventory-form novalidate>
<div class="row gx-3 gy-1">
<div class="col-12 col-lg-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-12 col-lg-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>
</div>
);
})}
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0">
<div class="row g-2 mt-2">
<div class="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 btn-group-sm condition-input w-100 col-12" role="group" aria-label="Condition">
<input
type="radio"
class="btn-check"
name="condition"
id="cond-nm"
value="Near Mint"
autocomplete="off"
checked
/>
<label class="btn btn-cond-nm" for="cond-nm">NM</label>
<input
type="radio"
class="btn-check"
name="condition"
id="cond-lp"
value="Lightly Played"
autocomplete="off"
/>
<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="3"
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>
</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>
<div class="col-sm-12 col-md-2 mt-0 mt-md-5">
<a class="btn btn-secondary mb-2 w-100" href={`https://www.tcgplayer.com/product/${card?.productId}`} target="_blank" onclick="dataLayer.push({'event': 'tcgplayerClick', 'tcgplayerUrl': this.getAttribute('href')});"><img src="/vendors/tcgplayer.webp"> TCGPlayer</a>
<a class="btn btn-secondary mb-2 w-100" href={`${ebaySearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'ebayClick', 'ebayUrl': this.getAttribute('href')});"><span set:html={ebay} /></a>
<!-- Chart lives permanently outside tab-content so Bootstrap never hides it. -->
<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>
<div id="priceHistoryEmpty" class="d-none text-secondary text-center py-4">
No sales data for the selected period/condition
</div>
<div class="position-relative" style="height: 200px;">
<canvas
id="priceHistoryChart"
class="price-history-chart"
data-card-id={card?.cardId}
data-history={JSON.stringify(priceHistoryForChart)}>
</canvas>
</div>
<div class="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end mt-2" role="group" aria-label="Time range">
{showRanges['1m'] && <button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button>}
{showRanges['3m'] && <button type="button" class="btn btn-dark price-range-btn" data-range="3m">3M</button>}
{showRanges['6m'] && <button type="button" class="btn btn-dark price-range-btn" data-range="6m">6M</button>}
{showRanges['1y'] && <button type="button" class="btn btn-dark price-range-btn" data-range="1y">1Y</button>}
{showRanges['all'] && <button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>}
</div>
</div>
</div>
</div>
</div>
<!-- External links column -->
<div class="col-sm-12 col-md-2 mt-0 mt-md-5 d-flex flex-row flex-md-column">
<a class="btn btn-dark mb-2 w-100 p-2" href={`https://www.tcgplayer.com/product/${card?.productId}`} target="_blank" onclick="dataLayer.push({'event': 'tcgplayerClick', 'tcgplayerUrl': this.getAttribute('href')});"><img src="/vendors/tcgplayer.webp"> <span class="d-none d-lg-inline">TCGPlayer</span></a>
<a class="btn btn-dark mb-2 w-100 p-2" href={`${ebaySearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'ebayClick', 'ebayUrl': this.getAttribute('href')});"><span set:html={ebay} /></a>
<a class="btn btn-dark mb-2 w-100 p-2" href={`${altSearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'altClick', 'altUrl': this.getAttribute('href')});"><svg width="48" height="20.16" viewBox="0 0 48 20" fill="none"><path d="M14.2761 19.9996H18.5308L11.6934 0.0712891H7.76953L14.2761 19.9996Z" fill="#ffffff"></path><path d="M6.17778 19.9986H6.14536L3.19643 11.2305L0 19.9988L6.17768 19.9989L6.17778 19.9986Z" fill="#ffffff"></path><path d="M24.7842 0H20.6759V19.9661H34.3427V16.5426H24.7842V0Z" fill="#ffffff"></path><path d="M41.6644 3.42355H47.4981V0H31.5033V3.42355H37.5561V19.9661H41.6644V3.42355Z" fill="#ffffff"></path></svg></a>
</div>
</div>
<div class="text-end my-0"><small class="text-body-secondary">Prices last changed: {timeAgo(calculatedAt)}</small></div>
</div>
</div>
</div>
<div class="text-end my-0"><small class="text-body-tertiary">Prices last changed: {timeAgo(calculatedAt)}</small></div>
</div>
</div>
</div>
</div>
</div>
</div>
<script is:inline>
async function copyImage(img) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
// draw the real image pixels
ctx.drawImage(img, 0, 0);
// convert to blob
canvas.toBlob(async (blob) => {
await navigator.clipboard.write([
new ClipboardItem({ "image/png": blob })
]);
console.log("Copied image via canvas.");
});
}
</script>
</div>

View File

@@ -1,12 +1,17 @@
---
import { client } from '../../db/typesense';
import RarityIcon from '../../components/RarityIcon.astro';
import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
export const prerender = false;
import * as util from 'util';
// auth check for inventory management features
const { canAddInventory } = Astro.locals;
// 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",
@@ -14,13 +19,50 @@ const facetFields:any = {
"energyType": "Energy Type"
}
// ── Allowed sort values ───────────────────────────────────────────────────
const sortMap: Record<string, string> = {
'releaseDate:desc,number:asc': '_text_match:asc,releaseDate:desc,number:asc',
'releaseDate:asc,number:asc': '_text_match:asc,releaseDate:asc,number:asc',
'marketPrice:desc': 'marketPrice:desc,releaseDate:desc,number:asc',
'marketPrice:asc': 'marketPrice:asc,releaseDate:desc,number:asc',
'number:asc': '_text_match:asc,number:asc',
'number:desc': '_text_match:asc,number:desc',
};
const DEFAULT_SORT = '_text_match:asc,releaseDate:desc,number:asc';
// get the query from post request using form data
const formData = await Astro.request.formData();
const query = formData.get('q')?.toString() || '';
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')
.filter(([key]) => key !== 'q' && key !== 'start' && key !== 'sort' && key !== 'language')
.reduce((acc, [key, value]) => {
if (!acc[key]) {
acc[key] = [];
@@ -34,27 +76,28 @@ const filterChecked = (field: string, value: string) => {
};
const filterBy = Object.entries(filters).map(([field, values]) => {
return `${field}:=[${values.join(',')}]`;
return `${field}:=[${values.map(v => '`'+v+'`').join(',')}]`;
}).join(' && ');
const facetFilter = (facet:string) => {
const otherFilters = Object.entries(filters)
.filter(([field]) => field !== facet)
.map(([field, values]) => `${field}:=[${values.join(',')}]`)
.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: `$skus(id:*) && sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${filterBy ? ` && ${filterBy}` : ''}`,
per_page: 20,
facet_by: '',
max_facet_values: 0,
page: Math.floor(start / 20) + 1,
sort_by: '_text_match:asc, releaseDate:desc, number:asc',
sort_by: resolvedSort,
include_fields: '$skus(*)',
}];
@@ -76,8 +119,8 @@ if (start === 0) {
const searchRequests = { searches: searchArray };
const commonSearchParams = {
q: query,
query_by: 'productLineName,productName,setName,number,rarityName,Artist',
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
@@ -90,6 +133,7 @@ const totalHits = cardResults?.found;
// format price to 2 decimal places (or 0 if price >=100) and adds a $ sign, if the price is null it returns ""
const formatPrice = (condition:string, skus: any) => {
if (typeof skus === 'undefined' || skus.length === 0) return '—';
const sku:any = skus.find((price:any) => price.condition === condition);
if (typeof sku === 'undefined' || typeof sku.marketPrice === 'undefined') return '—';
@@ -114,10 +158,23 @@ const facetNames = (name:string) => {
}
const facets = searchResults.results.slice(1).map((result: any) => {
return result.facet_counts[0];
});
const facet = result.facet_counts?.[0];
if (!facet) return null;
// Sort: checked items first, then alphabetically
facet.counts = facet.counts.sort((a: any, b: any) => {
const aChecked = filters[facet.field_name]?.includes(a.value) ?? false;
const bChecked = filters[facet.field_name]?.includes(b.value) ?? false;
if (aChecked && !bChecked) return -1;
if (!aChecked && bChecked) return 1;
return a.value.localeCompare(b.value);
});
return facet;
}).filter(Boolean);
---
{(start === 0) &&
<div id="facetContainer" hx-swap-oob="true">
@@ -125,7 +182,7 @@ const facets = searchResults.results.slice(1).map((result: any) => {
<button type="button" data-bs-dismiss="offcanvas" class="btn btn-danger me-2" id="clear-filters">Clear</button>
<button type="submit" form="searchform" data-bs-dismiss="offcanvas" class="btn btn-success">Apply Filters</button>
</div>
{facets.map((facet) => (
{facets.map((facet: any) => (
<div class="mt-2 mb-4 facet-group row align-items-center justify-content-between">
<div class="fs-5 m-0 col-auto pb-1 border-bottom border-light-subtle">{facetNames(facet.field_name)}</div>
{(facet.counts.length > 20) &&
@@ -144,24 +201,42 @@ const facets = searchResults.results.slice(1).map((result: any) => {
</div>
))}
</div>
<div id="activeFilters" class="mb-2 d-flex align-items-center justify-content-end 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" data-toggle="sort-dropdown" aria-expanded="false">Sort by</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item sort-option" href="#" data-sort="releaseDate:desc,number:asc" data-label="Set: Newest to Oldest">Set: Newest to Oldest</a></li>
<li><a class="dropdown-item sort-option" href="#" data-sort="releaseDate:asc,number:asc" data-label="Set: Oldest to Newest">Set: Oldest to Newest</a></li>
<li><a class="dropdown-item sort-option" href="#" data-sort="marketPrice:desc" data-label="Price: High to Low">Price: High to Low</a></li>
<li><a class="dropdown-item sort-option" href="#" data-sort="marketPrice:asc" data-label="Price: Low to High">Price: Low to High</a></li>
<li><a class="dropdown-item sort-option" href="#" data-sort="number:asc" data-label="Card Number: Ascending">Card Number: Ascending</a></li>
<li><a class="dropdown-item sort-option" href="#" data-sort="number:desc" data-label="Card Number: Descending">Card Number: Descending</a></li>
</ul>
</div>
<span id="sortLabel" class="ms-1 text-secondary small">{sortKey ? ({"releaseDate:desc,number:asc":"Set: Newest to Oldest","releaseDate:asc,number:asc":"Set: Oldest to Newest","marketPrice:desc":"Price: High to Low","marketPrice:asc":"Price: Low to High","number:asc":"Card Number: Ascending","number:desc":"Card Number: Descending"}[sortKey] ?? '') : ''}</span>
<div class="btn-group btn-group-sm ms-2 flex-shrink-0" role="group" aria-label="Language filter">
<button type="button" class={`btn btn-outline-secondary language-btn${language === 'all' ? ' active' : ''}`} data-lang="all">All</button>
<button type="button" class={`btn btn-outline-secondary language-btn${language === 'en' ? ' active' : ''}`} data-lang="en">EN</button>
<button type="button" class={`btn btn-outline-secondary language-btn${language === 'jp' ? ' active' : ''}`} data-lang="jp">JP</button>
</div>
</div>
<div id="totalResults" class="d-flex text-secondary small d-none align-items-center" hx-swap-oob="true">
{totalHits} {totalHits === 1 ? ' result' : ' results'}
</div>
<div id="activeFilters" class="d-flex small ms-auto align-items-center" hx-swap-oob="true">
{(Object.entries(filters).length > 0) &&
<span class="me-1">Filtered by:</span>
<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 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) {
@@ -187,7 +262,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')) {
@@ -198,7 +274,7 @@ const facets = searchResults.results.slice(1).map((result: any) => {
document.getElementById('searchform').dispatchEvent(new Event('submit', {bubbles:true, cancelable:true}));
});
}
</script>
}
@@ -210,11 +286,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>
<div hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});">
<img src={`/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 card-image image-grow w-100" onerror="this.onerror=null;this.src='/cards/default.jpg'"/>
{canAddInventory && (
<button type="button" class="btn btn-sm inventory-button position-relative float-end shadow-filter text-center p-2 fw-bold" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="event.stopPropagation(); sessionStorage.setItem('openModalTab', 'nav-vendor');">
+/
</button>
)}
<div class="card-trigger position-relative" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});">
<div class="image-grow rounded-4 card-image" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}><img src={`/static/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 w-100" onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow')?.setAttribute('data-default','true')"/><span class="position-absolute top-50 start-0 d-inline medium-icon"><FirstEditionIcon edition={card?.variant} /></span>
<div class="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) => (
@@ -224,13 +305,13 @@ const facets = searchResults.results.slice(1).map((result: any) => {
</div>
))}
</div>
<div class="h5 my-0">{card.productName}</div>
<div class="fs-5 fw-semibold my-0">{card.productName}</div>
<div class="d-flex flex-row lh-1 mt-1 justify-content-between">
<div class="text-secondary flex-grow-1 d-none d-md-flex">{card.setName}</div>
<div class="text-secondary">{card.number}</div>
<div class="text-body-tertiary flex-grow-1 d-none d-lg-flex fst-normal">{card.setName}</div>
<div class="text-body-tertiary fst-normal">{card.number}</div>
<span class="ps-2 small-icon"><RarityIcon rarity={card.rarityName} /></span>
</div>
<div>{card.variant}</div><span class="d-none">{card.productId}</span>
<div class="text-secondary fst-italic">{card.variant}</div><span class="d-none">{card.productId}</span>
</div>
))}

View File

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

View File

@@ -0,0 +1,71 @@
import type { APIRoute } from 'astro';
import { db } from '../../db/index';
import { priceHistory, skus } from '../../db/schema';
import { eq, inArray } from 'drizzle-orm';
export const prerender = false;
export const GET: APIRoute = async ({ url }) => {
const cardId = Number(url.searchParams.get('cardId')) || 0;
const cardSkus = await db
.select()
.from(skus)
.where(eq(skus.cardId, cardId));
if (!cardSkus.length) {
return new Response(JSON.stringify([]), { headers: { 'Content-Type': 'application/json' } });
}
const skuIds = cardSkus.map(s => s.skuId);
const historyRows = await db
.select({
skuId: priceHistory.skuId,
calculatedAt: priceHistory.calculatedAt,
marketPrice: priceHistory.marketPrice,
condition: skus.condition,
})
.from(priceHistory)
.innerJoin(skus, eq(priceHistory.skuId, skus.skuId))
.where(inArray(priceHistory.skuId, skuIds))
.orderBy(priceHistory.calculatedAt);
// Rolling 30-day cutoff for volatility
const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000);
const byCondition: Record<string, number[]> = {};
for (const row of historyRows) {
if (row.marketPrice == null) continue;
if (!row.calculatedAt) continue;
if (new Date(row.calculatedAt) < thirtyDaysAgo) continue;
const price = Number(row.marketPrice);
if (price <= 0) continue;
if (!byCondition[row.condition]) byCondition[row.condition] = [];
byCondition[row.condition].push(price);
}
function computeVolatility(prices: number[]): { label: string; monthlyVol: number } {
if (prices.length < 2) return { label: '—', monthlyVol: 0 };
const returns: number[] = [];
for (let i = 1; i < prices.length; i++) {
returns.push(Math.log(prices[i] / prices[i - 1]));
}
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
const variance = returns.reduce((sum, r) => sum + (r - mean) ** 2, 0) / (returns.length - 1);
const monthlyVol = Math.sqrt(variance) * Math.sqrt(30);
const label = monthlyVol >= 0.30 ? 'High'
: monthlyVol >= 0.15 ? 'Medium'
: 'Low';
return { label, monthlyVol: Math.round(monthlyVol * 100) / 100 };
}
const volatilityByCondition: Record<string, { label: string; monthlyVol: number }> = {};
for (const [condition, prices] of Object.entries(byCondition)) {
volatilityByCondition[condition] = computeVolatility(prices);
}
return new Response(JSON.stringify({ history: historyRows, volatilityByCondition }), {
headers: { 'Content-Type': 'application/json' }
});
};

View File

@@ -1,14 +1,9 @@
---
import Layout from '../layouts/Main.astro';
import Search from '../components/Search.astro';
import CardGrid from "../components/CardGrid.astro";
import NavBar from '../components/NavBar.astro';
export const prerender = false;
import Layout from '../layouts/Main.astro';
import CardGrid from "../components/CardGrid.astro";
---
<Layout>
<NavBar slot="navbar">
<Search slot="searchInput" />
</NavBar>
<CardGrid slot="page" />
<Layout title="Card Search">
<CardGrid slot="page"/>
</Layout>

24
src/pages/privacy.astro Normal file
View File

@@ -0,0 +1,24 @@
---
export const prerender = false;
import Footer from '../components/Footer.astro';
import Layout from '../layouts/Main.astro';
---
<Layout title="Terms and Privacy" >
<div class="container-fluid container-sm my-5" slot="page">
<section class="legal-info p-6 bg-gray-50 text-gray-800 rounded-md space-y-3">
<h2 class="text-xl font-semibold">Privacy & Terms</h2>
<p>
By signing in with your Google account, you agree to the following Terms of Service and Privacy Policy:
</p>
<p>
<strong>Terms of Service:</strong> You may use our services only as permitted by law. Your account is for personal use and must be kept secure. We reserve the right to modify or discontinue services at any time. Misuse of the service may result in account suspension.
</p>
<p>
<strong>Privacy Policy:</strong> We use Clerk.js to handle authentication, ensuring that your Google account information is securely processed. We do not store your Google password. Personal data is only used to provide and improve our services. We do not sell your information to third parties. You have the right to access, correct, or delete your data by contacting us.
</p>
</div>
<Footer slot="footer" />
</Layout>

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><image width="48" height="48" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAQAAAD9CzEMAAAFRklEQVR42u2XXYhVVRTH5+PemcnsC0NjaPqizOghc5Iw08gs7CUTihT6UAqqB0v8wiyxkcIIwwoKRQrH0Qn0ITWNyszIZmxGyzDDMs3R7CG1B2lm7pyv/Wu17mLfe5ghL9d5dC3OOf+z9t7rv/Zea5+7b8UFGQQhrZWIGh401xmy4rQKucxSrWpE5+M6q84VD6hZ31qG+xof73A2u33R7qA92BUeDkngU5fhJmut0pmUueaNbjNbSUiLGiJiaGa0ZaYMgukcDXCo9BGdinZGbWF7vMOdcIQxXtowCtWSXNfI9bKFGhC6BBopNM9BxH3JEWkVanhRjHVUU/O/JN65xqKRB61UsVaZbtPFloKSazEi2vkByIFb4LmrLG8Du7cyVE164YQTwK2A8y7vtbfP9W0WuAB+iHaEnW4nj1sJ9ycwdrlLp66AHKEMxEbsJS+jBN8DRL7lsKCcS6w9hk7nKQZ232Kdzc1+NT4mqBeYLHgCKupjRN6p3LrCjgiJS96O9KuqohrYEGm9fOjmui3maKw2nFY8QdD9ir5Q63LFnbYhLqYLuoVioWZygPhbQgjgOsSg8ckA1il+Sl1NElSv6Am1/iFogyJTnVDyu8IUQVauGYm6rxd4uR9wEhimAYWCGhCbT/fNwNl8PyHcrs/VDvpQ6He3xZ/8BIQZbXszH63oSEGrFK0UtJ63eBc4rJZXBU316JSiZU7rVoM2Cn0Rv64NCJZZRCuARxX9LWg0H/A9i5nGM7JYdzGHLazyM3kS+M3GnY4hPirAZ1YfWhKXarU5W1Pm06vPz4BfeURQWsfwHXCtbrblZpsNLsT9GZ4FXssbleWXCha5Ba7VEQjJLut+ja34fHsfwlCyeWSWZXQUUc5Ak+jA5Gkkebo8bDOrS+gGTvpB+3hJ903WW6YxRdc0o7YFfGSUa/Divk7ok8deJLvSSr2DkBwRKgFwhuHSsJ1v5V5rm+R2mfUx4D3BdWqplWut93uEZp7lFm35JoEwR0WgOZgUQTJDQCs97PEDXofC90sGHuS42l/BCIwYfmakIqsYzaCWa4Mm2S10kBsn5hbRCqYg1IzS7WSuTBuUYClmtTnMZZd/q9V6eQiRKIGpeYLVCJ1EQzfz5L6BfyyyaixK24sT0wS+tYurZPnm2ftzHJXZaxbeVoK4HbojAYzjSrk3aYVN1VRXpiY+Pk3gvzGdQBtD7LAxQu25COL9ShAI/DixAd7lEnZr3OcgULRGv0hp3QaE5Gcg8ICbzWTqizpsZI3cLyqJoJX3fRYmSmifOIm/B8gTcCCBhHyphgfjja7JNXKCzSXPYCUBD7PWnYnpE+jAZJYSuHoOxeSUw4uDPaXkQAthk43Qh3LEHazmTsSB9LBcjWWmeyPZGR3XH0ygR8zpKrpbXTQZQUH/AnrFcXzMNbvnaSxm14eWQrEOozmCcLI6LszgDiVYZARGK/70lNRK+ohckx9aqLaMmOo81VAhoD3v2OsUJXiH9AGqHZIQrrdDci2ZgQ5hqeO5BvhVDMF0AcKq0R8q+uqM0ZiG6MeaMIIfBXrH5xDL3BWadNfgP8+X+RguodKsV4PDJRZ/iedTP/cX/psD3OBzUNCM/zbpUWWpfZZKJvDnDKWImwT21zmQ6PKst94lEqRPSut0ofriZEVR9d3Ick7pj0oMLYXepYvNwcmdBx0BIgk5AZFoKChGHkI+08+2rD9RlbbYS5NcHw4vwiQMTWX9v+l/nLfneLc42eT2uo5kq1vCfYWdVpb7dLo9SVqzqdSWT+IXq06uStFqQVm/qQZHGEAvyCDJv7+zPKs2IGdiAAAAAElFTkSuQmCC"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-28885955-490e-4e5c-bba8-d29f831e6862" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 984.04107 353.35931"><g id="uuid-83941b04-85a7-4465-b9e0-012c209dc6a7"><path d="M653.62926,104.05915v32.23634h89.02801v217.06382h152.81484v-217.06382h88.56896V0h-330.4118v104.05915" style="fill:#fcf5f3;"/><g id="uuid-43578a3f-54c5-443e-9235-237fee6994bd"><path d="M262.03609,194.11787c12.84855-3.67235,23.01598-8.79185,30.51628-15.37407,7.49408-6.57599,13.07885-14.06852,16.7512-22.48691,3.67079-8.41061,6.03603-16.90058,7.11284-25.46836,1.06747-8.56155,1.60587-16.5209,1.60587-23.86404,0-25.99898-4.89698-46.80844-14.68473-62.41126-9.79552-15.60281-22.87438-26.91707-39.23656-33.95833C247.73105,3.51985,229.75366,0,210.17973,0H0v224.43011c2.52455-6.88838,5.14382-13.5023,7.8372-19.64065,18.36155-41.84105,49.28436-78.81895,78.08584-103.94867,28.79857-25.12952,75.6109-42.0873,99.07464-43.44673,23.46355-1.35962,37.54529,13.10025,43.17616,23.29335,5.63106,10.19291,4.89232,45.84328-18.69786,69.97672-23.59309,24.13363-53.42645,29.7079-70.68143,25.4174-17.25206-4.2907-23.29685-19.71592-21.08936-33.74282,2.20749-14.02709,13.38326-30.67734,26.43002-37.99014,13.04968-7.3126,24.90662-7.22157,30.40541-.71424,5.49588,6.50714-3.95517,14.38071-3.10146,16.03482.8537,1.6545,8.51098-4.88337,8.01945-13.10783-.49425-8.22447-11.28761-17.50765-32.61563-8.89591-21.33094,8.61193-32.92335,38.51084-32.92335,38.51084,0,0-44.99289,21.68242-70.52913,55.88934C23.29374,218.98539,8.70899,252.47748,0,290.56468v62.79464h144.55516v-94.53497h4.12984l24.78213,94.53497h161.07607l-72.5071-159.24145Z" style="fill:#fcf5f3;"/></g><path d="M615.38918,0h-189.98648l-90.86418,353.35931h148.685l4.59044-42.21956h65.16396l4.58888,42.21956h148.68656L615.38918,0ZM561.10669,138.27405l-14.62209,89.89785c-.2126.75042-.89766,1.26859-1.67765,1.26859h-17.93032c-.84476,0-1.56814-.6057-1.71655-1.43723l-3.03397-37.78104c-.15269-.85468-.91089-1.46699-1.7786-1.43607-1.01009.03598-1.77821.92062-1.67181,1.92584l-3.6422,36.80149c.10893,1.02974-.69849,1.92701-1.73386,1.92701h-19.2526c-.86888,0-1.6051-.63974-1.72647-1.50006l-12.68478-89.89785c-.14841-1.05172.67009-1.99081,1.73231-1.98711h78.06678c1.15422.00389,1.98614,1.10793,1.67181,2.21858Z" style="fill:#fcf5f3;"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,14 +1 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 14 13">
<!-- Generator: Adobe Illustrator 30.2.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 1) -->
<defs>
<style>
.st0 {
fill: #fdc401;
stroke: #fff;
stroke-width: .7px;
}
</style>
</defs>
<path class="st0" d="M7,1.2l1.6,3.5v.3c.1,0,.4,0,.4,0l3.8.4-2.9,2.5-.2.2v.3c0,0,.9,3.7.9,3.7l-3.3-1.9h-.2c0-.1-.2,0-.2,0l-3.3,1.9.8-3.7v-.3c0,0-.2-.2-.2-.2l-2.9-2.5,3.8-.4h.3s.1-.3.1-.3l1.6-3.5Z"/>
</svg>
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13.18305 11.96081"><path d="M6.54153.35l1.6,3.5v.3h.4l3.8.4-2.9,2.5-.2.2v.3l.9,3.7-3.3-1.9h-.2c0-.1-.2,0-.2,0l-3.3,1.9.8-3.7v-.3l-.2-.2L.84153,4.55l3.8-.4h.3l.1-.3L6.64153.35h-.1Z" style="fill:#fdc401; stroke:#fff; stroke-width:.7px;"/></svg>

Before

Width:  |  Height:  |  Size: 569 B

After

Width:  |  Height:  |  Size: 340 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

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