Compare commits
1 Commits
feat/inven
...
feat/holog
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6299c07b87 |
7
.env.d.ts
vendored
7
.env.d.ts
vendored
@@ -1,7 +0,0 @@
|
|||||||
/// <reference types="astro/client" />
|
|
||||||
|
|
||||||
declare namespace App {
|
|
||||||
interface Locals {
|
|
||||||
canAddInventory: boolean;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,9 +26,6 @@ pnpm-debug.log*
|
|||||||
# imges from tcgplayer
|
# imges from tcgplayer
|
||||||
public/cards/*
|
public/cards/*
|
||||||
|
|
||||||
# static assets
|
|
||||||
/static/
|
|
||||||
|
|
||||||
# anything test
|
# anything test
|
||||||
test.*
|
test.*
|
||||||
|
|
||||||
|
|||||||
58
CLAUDE.md
58
CLAUDE.md
@@ -1,58 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Pokemon TCG card database and inventory management app. Users search cards, track market prices, and manage their collections. Closed beta.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev # Start dev server (binds 0.0.0.0:4321)
|
|
||||||
npm run build # Production build → ./dist/
|
|
||||||
npm run preview # Preview production build
|
|
||||||
```
|
|
||||||
|
|
||||||
No test framework or linter is configured.
|
|
||||||
|
|
||||||
Utility scripts in `scripts/` are run directly with `tsx` (e.g., `npx tsx scripts/reindex.ts`).
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Astro 5** (SSR mode, Node.js standalone adapter)
|
|
||||||
- **PostgreSQL** via Drizzle ORM (schema in `pokemon` namespace, snake_case DB columns)
|
|
||||||
- **Typesense** for full-text card search
|
|
||||||
- **Clerk** for authentication
|
|
||||||
- **HTMX** for dynamic interactions (no SPA framework)
|
|
||||||
- **Bootstrap 5** with custom SCSS overrides, dark theme
|
|
||||||
- **Chart.js** for price history charts
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Data Flow
|
|
||||||
|
|
||||||
TCGPlayer source data → `tcg_cards` → denormalized `cards` (per variant) → `skus` (per condition/language) → `price_history` / `sales_history`. User collections stored in `inventory` table linked to Clerk userId.
|
|
||||||
|
|
||||||
PostgreSQL is source of truth. Typesense mirrors card/sku/inventory data for search. Both must be kept in sync — see `src/pages/api/inventory.ts` for the sync pattern (write to PG, then upsert/delete in Typesense).
|
|
||||||
|
|
||||||
### Key Directories
|
|
||||||
|
|
||||||
- `src/pages/` — Astro routes and API endpoints
|
|
||||||
- `src/pages/partials/` — HTMX partial responses (HTML fragments returned to `hx-post` targets)
|
|
||||||
- `src/pages/api/` — JSON/file API endpoints (`upload.ts` for CSV, `inventory.ts` for CRUD)
|
|
||||||
- `src/components/` — Reusable `.astro` components
|
|
||||||
- `src/db/` — Drizzle schema (`schema.ts`), relations (`relations.ts`), DB connection (`index.ts`), Typesense client (`typesense.ts`)
|
|
||||||
- `scripts/` — Data ingestion and indexing utilities (not part of the app runtime)
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
|
|
||||||
Clerk middleware in `src/middleware.ts` protects routes via `createRouteMatcher`. Auth context accessed via `Astro.locals.auth()` in pages/API routes.
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
|
|
||||||
Drizzle config uses `casing: 'snake_case'` — define schema fields in camelCase, they map to snake_case columns automatically. Migrations live in `./drizzle/`. Schema is scoped to the `pokemon` PostgreSQL schema.
|
|
||||||
|
|
||||||
### Frontend Patterns
|
|
||||||
|
|
||||||
Pages use HTMX for interactivity — forms POST to `/partials/*` endpoints that return HTML fragments. No client-side routing. View Transitions API enabled for page navigation animations. Card modals and inventory forms are HTMX-driven with `hx-post`, `hx-target`, and `hx-swap` attributes.
|
|
||||||
@@ -18,17 +18,5 @@ export default defineConfig({
|
|||||||
output: "server",
|
output: "server",
|
||||||
security: {
|
security: {
|
||||||
checkOrigin: false
|
checkOrigin: false
|
||||||
},
|
}
|
||||||
vite: {
|
|
||||||
css: {
|
|
||||||
preprocessorOptions: {
|
|
||||||
scss: {
|
|
||||||
// Silences deprecation warnings from dependencies
|
|
||||||
quietDeps: true,
|
|
||||||
// Specifically silence color function warnings
|
|
||||||
silenceDeprecations: ['color-functions', 'import','global-builtin'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
745
package-lock.json
generated
745
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,6 @@
|
|||||||
"@clerk/astro": "^3.0.1",
|
"@clerk/astro": "^3.0.1",
|
||||||
"@clerk/shared": "^4.0.0",
|
"@clerk/shared": "^4.0.0",
|
||||||
"@clerk/themes": "^2.4.55",
|
"@clerk/themes": "^2.4.55",
|
||||||
"@popperjs/core": "^2.11.8",
|
|
||||||
"astro": "^5.17.1",
|
"astro": "^5.17.1",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,6 @@ import type { DBInstance } from '../src/db/index.ts';
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { sql } from 'drizzle-orm'
|
import { sql } from 'drizzle-orm'
|
||||||
|
|
||||||
import * as util from 'util';
|
|
||||||
|
|
||||||
|
|
||||||
const DollarToInt = (dollar: any) => {
|
const DollarToInt = (dollar: any) => {
|
||||||
if (dollar === null) return null;
|
if (dollar === null) return null;
|
||||||
@@ -64,7 +62,7 @@ export const createCardCollection = async () => {
|
|||||||
{ name: 'releaseDate', type: 'int32' },
|
{ name: 'releaseDate', type: 'int32' },
|
||||||
{ name: 'marketPrice', type: 'int32', optional: true, sort: true },
|
{ name: 'marketPrice', type: 'int32', optional: true, sort: true },
|
||||||
{ name: 'content', type: 'string', token_separators: ['/'] },
|
{ name: 'content', type: 'string', token_separators: ['/'] },
|
||||||
// { name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
|
{ name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
console.log(chalk.green('Collection "cards" created successfully.'));
|
console.log(chalk.green('Collection "cards" created successfully.'));
|
||||||
@@ -85,40 +83,11 @@ export const createSkuCollection = async () => {
|
|||||||
{ name: 'highestPrice', type: 'int32', optional: true },
|
{ name: 'highestPrice', type: 'int32', optional: true },
|
||||||
{ name: 'lowestPrice', type: 'int32', optional: true },
|
{ name: 'lowestPrice', type: 'int32', optional: true },
|
||||||
{ name: 'marketPrice', 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.'));
|
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) => {
|
export const upsertCardCollection = async (db:DBInstance) => {
|
||||||
const pokemon = await db.query.cards.findMany({
|
const pokemon = await db.query.cards.findMany({
|
||||||
@@ -144,7 +113,7 @@ export const upsertCardCollection = async (db:DBInstance) => {
|
|||||||
content: [card.productName, card.productLineName, card.set?.setName || "", card.number, card.rarityName, card.artist || ""].join(' '),
|
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,
|
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
|
||||||
...(marketPrice !== null && { marketPrice }),
|
...(marketPrice !== null && { marketPrice }),
|
||||||
// sku_id: card.prices.map(price => price.skuId.toString())
|
sku_id: card.prices.map(price => price.skuId.toString())
|
||||||
};
|
};
|
||||||
}), { action: 'upsert' });
|
}), { action: 'upsert' });
|
||||||
console.log(chalk.green('Collection "cards" indexed successfully.'));
|
console.log(chalk.green('Collection "cards" indexed successfully.'));
|
||||||
@@ -158,39 +127,10 @@ export const upsertSkuCollection = async (db:DBInstance) => {
|
|||||||
highestPrice: DollarToInt(sku.highestPrice),
|
highestPrice: DollarToInt(sku.highestPrice),
|
||||||
lowestPrice: DollarToInt(sku.lowestPrice),
|
lowestPrice: DollarToInt(sku.lowestPrice),
|
||||||
marketPrice: DollarToInt(sku.marketPrice),
|
marketPrice: DollarToInt(sku.marketPrice),
|
||||||
card_id: sku.cardId.toString(),
|
|
||||||
})), { action: 'upsert' });
|
})), { action: 'upsert' });
|
||||||
console.log(chalk.green('Collection "skus" indexed successfully.'));
|
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.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -234,7 +174,4 @@ where not exists (select 1 from cards where product_id=t.product_id and variant=
|
|||||||
`);
|
`);
|
||||||
console.log(`Inserted ${inserts.rowCount} rows into cards table`);
|
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`);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,9 +204,9 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log(`item: ${item.setId}\tdetail: ${detailData.setId}`);
|
console.log(`item: ${item.setId}\tdetail: ${detailData.setId}`);
|
||||||
// console.log(`item: ${item.setCode}\tdetail: ${detailData.setCode}`);
|
console.log(`item: ${item.setCode}\tdetail: ${detailData.setCode}`);
|
||||||
// console.log(`item: ${item.setName}\tdetail: ${detailData.setName}`);
|
console.log(`item: ${item.setName}\tdetail: ${detailData.setName}`);
|
||||||
// set is...
|
// set is...
|
||||||
await db.insert(schema.sets).values({
|
await db.insert(schema.sets).values({
|
||||||
setId: detailData.setId,
|
setId: detailData.setId,
|
||||||
@@ -242,7 +242,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// get image if it doesn't already exist
|
// get image if it doesn't already exist
|
||||||
const imagePath = path.join(process.cwd(), 'static', 'cards', `${item.productId}.jpg`);
|
const imagePath = path.join(process.cwd(), 'public', 'cards', `${item.productId}.jpg`);
|
||||||
if (!await helper.FileExists(imagePath)) {
|
if (!await helper.FileExists(imagePath)) {
|
||||||
const imageResponse = await fetch(`https://tcgplayer-cdn.tcgplayer.com/product/${item.productId}_in_1000x1000.jpg`);
|
const imageResponse = await fetch(`https://tcgplayer-cdn.tcgplayer.com/product/${item.productId}_in_1000x1000.jpg`);
|
||||||
if (imageResponse.ok) {
|
if (imageResponse.ok) {
|
||||||
@@ -280,6 +280,6 @@ else {
|
|||||||
await helper.UpdateVariants(db);
|
await helper.UpdateVariants(db);
|
||||||
|
|
||||||
// index the card updates
|
// index the card updates
|
||||||
await helper.upsertCardCollection(db);
|
helper.upsertCardCollection(db);
|
||||||
|
|
||||||
await ClosePool();
|
await ClosePool();
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ import { db, ClosePool } from '../src/db/index.ts';
|
|||||||
import * as Indexing from './pokemon-helper.ts';
|
import * as Indexing from './pokemon-helper.ts';
|
||||||
|
|
||||||
|
|
||||||
// await Indexing.createCardCollection();
|
//await Indexing.createCardCollection();
|
||||||
await Indexing.createSkuCollection();
|
//await Indexing.createSkuCollection();
|
||||||
await Indexing.createInventoryCollection();
|
await Indexing.upsertCardCollection(db);
|
||||||
|
|
||||||
// await Indexing.upsertCardCollection(db);
|
|
||||||
await Indexing.upsertSkuCollection(db);
|
await Indexing.upsertSkuCollection(db);
|
||||||
await Indexing.upsertInventoryCollection(db);
|
|
||||||
await ClosePool();
|
await ClosePool();
|
||||||
console.log(chalk.green('Pokémon reindex complete.'));
|
console.log(chalk.green('Pokémon reindex complete.'));
|
||||||
|
|||||||
@@ -40,8 +40,8 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// Shared texture/asset references
|
// Shared texture/asset references
|
||||||
$grain: url('/public/holofoils/grain.webp');
|
$grain: url('/holofoils/grain.webp');
|
||||||
$glitter: url('/public/holofoils/glitter.png');
|
$glitter: url('/holofoils/glitter.png');
|
||||||
$glittersize: 25%;
|
$glittersize: 25%;
|
||||||
|
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ $glittersize: 25%;
|
|||||||
/// No-mask fallback for cards using the illusion foil pattern.
|
/// No-mask fallback for cards using the illusion foil pattern.
|
||||||
@mixin no-mask-illusion {
|
@mixin no-mask-illusion {
|
||||||
--mask: none;
|
--mask: none;
|
||||||
--foil: url('/public/holofoils/illusion.png');
|
--foil: url('/holofoils/illusion.png');
|
||||||
--imgsize: 33%;
|
--imgsize: 33%;
|
||||||
-webkit-mask-image: var(--mask);
|
-webkit-mask-image: var(--mask);
|
||||||
mask-image: var(--mask);
|
mask-image: var(--mask);
|
||||||
@@ -919,7 +919,7 @@ $glittersize: 25%;
|
|||||||
--space: 4%;
|
--space: 4%;
|
||||||
clip-path: var(--clip);
|
clip-path: var(--clip);
|
||||||
background-image:
|
background-image:
|
||||||
url('/public/holofoils/cosmos-bottom.png'),
|
url('/holofoils/cosmos-bottom.png'),
|
||||||
$cosmos-stripe,
|
$cosmos-stripe,
|
||||||
radial-gradient(
|
radial-gradient(
|
||||||
farthest-corner circle at var(--pointer-x) var(--pointer-y),
|
farthest-corner circle at var(--pointer-x) var(--pointer-y),
|
||||||
@@ -939,7 +939,7 @@ $glittersize: 25%;
|
|||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
background-image: url('/public/holofoils/cosmos-middle-trans.png'), $cosmos-stripe;
|
background-image: url('/holofoils/cosmos-middle-trans.png'), $cosmos-stripe;
|
||||||
background-blend-mode: lighten, multiply;
|
background-blend-mode: lighten, multiply;
|
||||||
background-position:
|
background-position:
|
||||||
var(--cosmosbg, center center),
|
var(--cosmosbg, center center),
|
||||||
@@ -953,7 +953,7 @@ $glittersize: 25%;
|
|||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
background-image: url('/public/holofoils/cosmos-top-trans.png'), $cosmos-stripe;
|
background-image: url('/holofoils/cosmos-top-trans.png'), $cosmos-stripe;
|
||||||
background-blend-mode: multiply, multiply;
|
background-blend-mode: multiply, multiply;
|
||||||
background-position:
|
background-position:
|
||||||
var(--cosmosbg, center center),
|
var(--cosmosbg, center center),
|
||||||
@@ -1081,7 +1081,7 @@ $glittersize: 25%;
|
|||||||
.card__shine,
|
.card__shine,
|
||||||
.card__shine::after {
|
.card__shine::after {
|
||||||
--mask: none;
|
--mask: none;
|
||||||
--foil: url('/public/holofoils/trainerbg.png');
|
--foil: url('/holofoils/trainerbg.png');
|
||||||
--imgsize: 25% auto;
|
--imgsize: 25% auto;
|
||||||
}
|
}
|
||||||
.card__shine::after { background-blend-mode: difference; }
|
.card__shine::after { background-blend-mode: difference; }
|
||||||
@@ -1205,7 +1205,7 @@ $glittersize: 25%;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:not(.masked) .card__shine {
|
&:not(.masked) .card__shine {
|
||||||
--foil: url('/public/holofoils/illusion-mask.png');
|
--foil: url('/holofoils/illusion-mask.png');
|
||||||
--imgsize: 33%;
|
--imgsize: 33%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1377,7 +1377,7 @@ $glittersize: 25%;
|
|||||||
.card__glare { @extend %secret-rare-glare; }
|
.card__glare { @extend %secret-rare-glare; }
|
||||||
|
|
||||||
&:not(.masked) .card__shine {
|
&:not(.masked) .card__shine {
|
||||||
--foil: url('/public/holofoils/geometric.png');
|
--foil: url('/holofoils/geometric.png');
|
||||||
--imgsize: 33%;
|
--imgsize: 33%;
|
||||||
filter: brightness(calc((var(--pointer-from-center) * 0.3) + 0.2)) contrast(2) saturate(0.75);
|
filter: brightness(calc((var(--pointer-from-center) * 0.3) + 0.2)) contrast(2) saturate(0.75);
|
||||||
}
|
}
|
||||||
@@ -1648,7 +1648,7 @@ $glittersize: 25%;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:not(.masked) .card__shine {
|
&:not(.masked) .card__shine {
|
||||||
--foil: url('/public/holofoils/illusion-mask.png');
|
--foil: url('/holofoils/illusion-mask.png');
|
||||||
--imgsize: 33%;
|
--imgsize: 33%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1719,7 +1719,7 @@ $glittersize: 25%;
|
|||||||
&:not(.masked) {
|
&:not(.masked) {
|
||||||
.card__shine,
|
.card__shine,
|
||||||
.card__shine::after {
|
.card__shine::after {
|
||||||
--foil: url('/public/holofoils/trainerbg.png');
|
--foil: url('/holofoils/trainerbg.png');
|
||||||
--imgsize: 20%;
|
--imgsize: 20%;
|
||||||
background-blend-mode: color-burn, hue, hard-light;
|
background-blend-mode: color-burn, hue, hard-light;
|
||||||
filter: brightness(calc((var(--pointer-from-center) * 0.05) + .6)) contrast(1.5) saturate(1.2);
|
filter: brightness(calc((var(--pointer-from-center) * 0.05) + .6)) contrast(1.5) saturate(1.2);
|
||||||
@@ -1828,7 +1828,7 @@ $glittersize: 25%;
|
|||||||
|
|
||||||
&:not(.masked) {
|
&:not(.masked) {
|
||||||
.card__shine {
|
.card__shine {
|
||||||
--foil: url('/public/holofoils/geometric.png');
|
--foil: url('/holofoils/geometric.png');
|
||||||
--imgsize: 33%;
|
--imgsize: 33%;
|
||||||
filter: brightness(calc((var(--pointer-from-center) * 0.3) + 0.2)) contrast(2) saturate(0.75);
|
filter: brightness(calc((var(--pointer-from-center) * 0.3) + 0.2)) contrast(2) saturate(0.75);
|
||||||
}
|
}
|
||||||
@@ -2027,7 +2027,7 @@ $glittersize: 25%;
|
|||||||
.card__shine,
|
.card__shine,
|
||||||
.card__shine::after {
|
.card__shine::after {
|
||||||
--mask: none;
|
--mask: none;
|
||||||
--foil: url('/public/holofoils/vmaxbg.jpg');
|
--foil: url('/holofoils/vmaxbg.jpg');
|
||||||
--imgsize: 60% 30%;
|
--imgsize: 60% 30%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2102,7 +2102,7 @@ $glittersize: 25%;
|
|||||||
.card__shine,
|
.card__shine,
|
||||||
.card__shine::after {
|
.card__shine::after {
|
||||||
--mask: none;
|
--mask: none;
|
||||||
--foil: url('/public/holofoils/ancient.png');
|
--foil: url('/holofoils/ancient.png');
|
||||||
--imgsize: 18% 15%;
|
--imgsize: 18% 15%;
|
||||||
background-blend-mode: exclusion, hue, hard-light;
|
background-blend-mode: exclusion, hue, hard-light;
|
||||||
filter: brightness(calc((var(--pointer-from-center) * .25) + .35)) contrast(1.8) saturate(1.75);
|
filter: brightness(calc((var(--pointer-from-center) * .25) + .35)) contrast(1.8) saturate(1.75);
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// HOLOFOIL INTEGRATION
|
// HOLOFOIL INTEGRATION
|
||||||
// _holofoil-integration.scss
|
// =============================================================================
|
||||||
|
//
|
||||||
|
// Three effect zones, determined by rarity and variant:
|
||||||
|
//
|
||||||
|
// NONE — no effect at all
|
||||||
|
// variant = Normal (or no recognised rarity/variant)
|
||||||
|
//
|
||||||
|
// INVERSE — effect on borders only (everything except the art window)
|
||||||
|
// variant = Reverse Holofoil
|
||||||
|
// rarity = Prism Rare
|
||||||
|
//
|
||||||
|
// ART WINDOW — effect clipped to the artwork area only
|
||||||
|
// rarity = Rare | Amazing Rare | Classic Collection | Holo Rare
|
||||||
|
// variant = Holofoil | 1st Edition Holofoil
|
||||||
|
//
|
||||||
|
// FULL CARD — effect over the entire card
|
||||||
|
// rarity = Ultra Rare | Character Rare | Illustration Rare |
|
||||||
|
// Special Illustration Rare | Double Rare | Hyper Rare |
|
||||||
|
// Mega Rare | Mega Attack Rare | ACE Spec Rare | ACE Rare |
|
||||||
|
// Art Rare | Special Art Rare | Black White Rare |
|
||||||
|
// Character Super Rare | Mega Ultra Rare | Rare BREAK |
|
||||||
|
// Secret Rare | Shiny Holo Rare | Shiny Rare |
|
||||||
|
// Shiny Secret Rare | Shiny Ultra Rare
|
||||||
|
//
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
@import "card";
|
|
||||||
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// 1. WRAPPER NORMALISATION
|
// 1. CSS CUSTOM PROPERTIES — set on every wrapper element
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
%holofoil-wrapper-base {
|
.image-grow,
|
||||||
--card-aspect: 0.718;
|
.card-image-wrap {
|
||||||
--card-radius: 4.55% / 3.5%;
|
|
||||||
|
|
||||||
|
// Pointer tracking — updated by holofoil-init.js on mousemove
|
||||||
--pointer-x: 50%;
|
--pointer-x: 50%;
|
||||||
--pointer-y: 50%;
|
--pointer-y: 50%;
|
||||||
--background-x: 50%;
|
--background-x: 50%;
|
||||||
@@ -21,29 +42,23 @@
|
|||||||
--pointer-from-center: 0;
|
--pointer-from-center: 0;
|
||||||
--pointer-from-top: 0.5;
|
--pointer-from-top: 0.5;
|
||||||
--pointer-from-left: 0.5;
|
--pointer-from-left: 0.5;
|
||||||
--card-scale: 1;
|
|
||||||
--card-opacity: 0;
|
--card-opacity: 0;
|
||||||
|
--card-scale: 1;
|
||||||
|
|
||||||
--grain: url('/public/holofoils/grain.webp');
|
// Card geometry — matches Bootstrap's rounded-4 (--bs-border-radius-xl)
|
||||||
--glitter: url('/public/holofoils/glitter.png');
|
--card-radius: var(--bs-border-radius-xl, 0.375rem);
|
||||||
--glittersize: 25%;
|
|
||||||
--space: 5%;
|
|
||||||
--angle: 133deg;
|
|
||||||
--imgsize: cover;
|
|
||||||
|
|
||||||
--red: #f80e35;
|
// Art window clip — original poke-holo values, correct for standard TCG card scans
|
||||||
--yellow: #eedf10;
|
// inset(top right bottom left): top=9.85%, sides=8%, bottom=52.85% (art bottom at 47.15%)
|
||||||
--green: #21e985;
|
--clip-art: inset(9.85% 8% 52.85% 8%);
|
||||||
--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 palette
|
||||||
|
--sunpillar-1: hsl(2, 100%, 73%);
|
||||||
|
--sunpillar-2: hsl(53, 100%, 69%);
|
||||||
|
--sunpillar-3: hsl(93, 100%, 69%);
|
||||||
|
--sunpillar-4: hsl(176, 100%, 76%);
|
||||||
|
--sunpillar-5: hsl(228, 100%, 74%);
|
||||||
|
--sunpillar-6: hsl(283, 100%, 73%);
|
||||||
--sunpillar-clr-1: var(--sunpillar-1);
|
--sunpillar-clr-1: var(--sunpillar-1);
|
||||||
--sunpillar-clr-2: var(--sunpillar-2);
|
--sunpillar-clr-2: var(--sunpillar-2);
|
||||||
--sunpillar-clr-3: var(--sunpillar-3);
|
--sunpillar-clr-3: var(--sunpillar-3);
|
||||||
@@ -51,40 +66,76 @@
|
|||||||
--sunpillar-clr-5: var(--sunpillar-5);
|
--sunpillar-clr-5: var(--sunpillar-5);
|
||||||
--sunpillar-clr-6: var(--sunpillar-6);
|
--sunpillar-clr-6: var(--sunpillar-6);
|
||||||
|
|
||||||
// NOTE: no overflow:hidden here -- that would clip the lift/scale transform
|
// Colour tokens
|
||||||
// on .image-grow. Overflow is handled by the child .holo-shine/.holo-glare.
|
--red: #f80e35;
|
||||||
position: relative;
|
--yellow: #eedf10;
|
||||||
isolation: isolate;
|
--green: #21e985;
|
||||||
border-radius: var(--card-radius);
|
--blue: #0dbde9;
|
||||||
}
|
--violet: #c929f1;
|
||||||
|
|
||||||
%holofoil-energy-glows {
|
// Glow
|
||||||
|
--card-glow: hsl(175, 100%, 90%);
|
||||||
|
|
||||||
|
// Texture assets
|
||||||
|
--grain: url('/holofoils/grain.webp');
|
||||||
|
--glitter: url('/holofoils/glitter.png');
|
||||||
|
--glittersize: 25%;
|
||||||
|
--foil: none;
|
||||||
|
|
||||||
|
// Energy glow overrides
|
||||||
&[data-energy="Water"] { --card-glow: hsl(192, 97%, 60%); }
|
&[data-energy="Water"] { --card-glow: hsl(192, 97%, 60%); }
|
||||||
&[data-energy="Fire"] { --card-glow: hsl(9, 81%, 59%); }
|
&[data-energy="Fire"] { --card-glow: hsl(9, 81%, 59%); }
|
||||||
&[data-energy="Grass"] { --card-glow: hsl(96, 81%, 65%); }
|
&[data-energy="Grass"] { --card-glow: hsl(96, 81%, 65%); }
|
||||||
&[data-energy="Lightning"] { --card-glow: hsl(54, 87%, 63%); }
|
&[data-energy="Lightning"] { --card-glow: hsl(54, 87%, 63%); }
|
||||||
&[data-energy="Psychic"] { --card-glow: hsl(281, 62%, 58%); }
|
&[data-energy="Psychic"] { --card-glow: hsl(281, 62%, 58%); }
|
||||||
&[data-energy="Fighting"] { --card-glow: rgb(145, 90, 39); }
|
&[data-energy="Fighting"] { --card-glow: rgb(145, 90, 39); }
|
||||||
&[data-energy="Darkness"] { --card-glow: hsl(189, 77%, 27%); }
|
&[data-energy="Darkness"] { --card-glow: hsl(189, 77%, 27%); }
|
||||||
&[data-energy="Metal"] { --card-glow: hsl(184, 20%, 70%); }
|
&[data-energy="Metal"] { --card-glow: hsl(184, 20%, 70%); }
|
||||||
&[data-energy="Dragon"] { --card-glow: hsl(51, 60%, 35%); }
|
&[data-energy="Dragon"] { --card-glow: hsl(51, 60%, 35%); }
|
||||||
&[data-energy="Fairy"] { --card-glow: hsl(323, 100%, 89%); }
|
&[data-energy="Fairy"] { --card-glow: hsl(323, 100%, 89%); }
|
||||||
|
|
||||||
|
// Hover activates opacity; JS updates pointer vars
|
||||||
|
&:hover,
|
||||||
|
&[data-holo-active] { --card-opacity: 0.2; }
|
||||||
|
|
||||||
|
display: block; // ensure wrapper is a block-level containing block
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
border-radius: var(--card-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// 2. SHINE + GLARE CHILD DIVS
|
// 2. HOLO-SHINE AND HOLO-GLARE BASE STRUCTURE
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
%shine-base {
|
.holo-shine,
|
||||||
|
.holo-glare {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
border-radius: var(--card-radius);
|
border-radius: var(--card-radius);
|
||||||
overflow: hidden; // clipping lives here, not on the parent
|
// NO overflow:hidden — it interferes with clip-path on the element itself
|
||||||
z-index: 3;
|
|
||||||
will-change: transform, opacity, background-image, background-size,
|
will-change: transform, opacity, background-image, background-size,
|
||||||
background-position, background-blend-mode, filter;
|
background-position, background-blend-mode, filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The img inside has mb-2 but the wrapper already has the right size from
|
||||||
|
// aspect-ratio on .card-image — zero the margin so img fills wrapper flush.
|
||||||
|
.image-grow > img,
|
||||||
|
.card-image-wrap > img {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holo-shine {
|
||||||
|
z-index: 3;
|
||||||
|
mix-blend-mode: color-dodge;
|
||||||
|
opacity: var(--card-opacity);
|
||||||
|
|
||||||
&::before,
|
&::before,
|
||||||
&::after {
|
&::after {
|
||||||
@@ -92,97 +143,380 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
border-radius: var(--card-radius);
|
border-radius: var(--card-radius);
|
||||||
|
// Sunpillar palette shift for ::before depth layer
|
||||||
|
--sunpillar-clr-1: var(--sunpillar-5);
|
||||||
|
--sunpillar-clr-2: var(--sunpillar-6);
|
||||||
|
--sunpillar-clr-3: var(--sunpillar-1);
|
||||||
|
--sunpillar-clr-4: var(--sunpillar-2);
|
||||||
|
--sunpillar-clr-5: var(--sunpillar-3);
|
||||||
|
--sunpillar-clr-6: var(--sunpillar-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
// Second palette shift for uppermost pseudo layer
|
||||||
|
--sunpillar-clr-1: var(--sunpillar-6);
|
||||||
|
--sunpillar-clr-2: var(--sunpillar-1);
|
||||||
|
--sunpillar-clr-3: var(--sunpillar-2);
|
||||||
|
--sunpillar-clr-4: var(--sunpillar-3);
|
||||||
|
--sunpillar-clr-5: var(--sunpillar-4);
|
||||||
|
--sunpillar-clr-6: var(--sunpillar-5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
%glare-base {
|
.holo-glare {
|
||||||
pointer-events: none;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
border-radius: var(--card-radius);
|
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
transform: translateZ(0);
|
mix-blend-mode: overlay;
|
||||||
overflow: hidden;
|
opacity: var(--card-opacity);
|
||||||
will-change: transform, opacity, background-image, background-size,
|
background-image: radial-gradient(
|
||||||
background-position, background-blend-mode, filter;
|
farthest-corner circle at var(--pointer-x) var(--pointer-y),
|
||||||
}
|
hsla(0, 0%, 100%, 0.8) 10%,
|
||||||
|
hsla(0, 0%, 100%, 0.65) 20%,
|
||||||
|
hsla(0, 0%, 0%, 0.5) 90%
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grain texture on ::before — soft-light blend adds physical substrate feel
|
||||||
// -----------------------------------------------------------------------------
|
&::before {
|
||||||
// 3. MODES
|
content: '';
|
||||||
// -----------------------------------------------------------------------------
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
// -- 3a. GRID -----------------------------------------------------------------
|
background-image: var(--grain);
|
||||||
// No idle animation. Effect is invisible until hover.
|
background-size: 33%;
|
||||||
|
background-repeat: repeat;
|
||||||
.image-grow,
|
mix-blend-mode: soft-light;
|
||||||
.card-image-wrap {
|
opacity: 0.15;
|
||||||
@extend %holofoil-wrapper-base;
|
}
|
||||||
@extend %holofoil-energy-glows;
|
|
||||||
|
// Glitter texture on ::after — overlay blend adds sparkle points
|
||||||
// No effect if the image fell back to default.jpg
|
&::after {
|
||||||
&[data-default="true"] {
|
content: '';
|
||||||
.holo-shine,
|
position: absolute;
|
||||||
.holo-glare { display: none !important; }
|
inset: 0;
|
||||||
|
background-image: var(--glitter);
|
||||||
|
background-size: var(--glittersize) var(--glittersize);
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-position:
|
||||||
|
calc(50% - ((5px * 2) * var(--pointer-from-left)) + 5px)
|
||||||
|
calc(50% - ((5px * 2) * var(--pointer-from-top)) + 5px);
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
opacity: calc(var(--card-opacity) * 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.holo-shine { @extend %shine-base; }
|
|
||||||
.holo-glare { @extend %glare-base; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// -- 3b. GRID HOVER -----------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// The existing main.scss .image-grow:hover handles lift + scale.
|
// 3. ZONE HELPERS — reusable effect mixin
|
||||||
// We layer the holo effect on top without overriding transform or transition.
|
// The standard prismatic effect, applied at different clip regions below.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
.image-grow:hover,
|
// Standard shine background (used by ART WINDOW and FULL CARD zones)
|
||||||
.image-grow[data-holo-active] {
|
@mixin prismatic-shine {
|
||||||
--card-opacity: 0.45;
|
background-image:
|
||||||
|
var(--grain),
|
||||||
|
repeating-linear-gradient(110deg,
|
||||||
|
var(--violet), var(--blue), var(--green), var(--yellow), var(--red),
|
||||||
|
var(--violet), var(--blue), var(--green), var(--yellow), var(--red),
|
||||||
|
var(--violet), var(--blue), var(--green), var(--yellow), var(--red)
|
||||||
|
);
|
||||||
|
background-position:
|
||||||
|
center center,
|
||||||
|
calc(((50% - var(--background-x)) * 2.6) + 50%)
|
||||||
|
calc(((50% - var(--background-y)) * 3.5) + 50%);
|
||||||
|
background-size: 33%, 400% 400%;
|
||||||
|
background-repeat: repeat, no-repeat;
|
||||||
|
background-blend-mode: soft-light, normal;
|
||||||
|
filter: brightness(.8) contrast(.85) saturate(.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin prismatic-glare {
|
||||||
|
opacity: calc(var(--card-opacity) * 0.4);
|
||||||
|
filter: brightness(0.8) contrast(1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// -- 3c. MODAL ----------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Sweeps once per minute. Peaks at 0.35.
|
// 4. ZONE 0 — NORMAL: no effect
|
||||||
// Pointer tracking bumps opacity to 0.45 while hovering.
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
@keyframes holo-modal-pulse {
|
.image-grow[data-variant="Normal" i],
|
||||||
0% {
|
.card-image-wrap[data-variant="Normal" i] {
|
||||||
--card-opacity: 0;
|
.holo-shine,
|
||||||
|
.holo-glare { display: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 5. ZONE 1 — ART WINDOW EFFECT
|
||||||
|
// rarity: Rare, Amazing Rare, Classic Collection, Holo Rare
|
||||||
|
// variant: Holofoil, 1st Edition Holofoil
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.image-grow[data-rarity="Rare" i]:not([data-variant="Reverse Holofoil" i]):not([data-variant="Normal" i]),
|
||||||
|
.card-image-wrap[data-rarity="Rare" i]:not([data-variant="Reverse Holofoil" i]):not([data-variant="Normal" i]),
|
||||||
|
.image-grow[data-rarity="Amazing Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Amazing Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Classic Collection" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Classic Collection" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-variant="1st Edition Holofoil" i],
|
||||||
|
.card-image-wrap[data-variant="1st Edition Holofoil" i] {
|
||||||
|
|
||||||
|
.holo-shine {
|
||||||
|
clip-path: var(--clip-art);
|
||||||
|
@include prismatic-shine;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holo-glare {
|
||||||
|
clip-path: var(--clip-art);
|
||||||
|
@include prismatic-glare;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 5b. ZONE 1 BORDER ADDITION — Holofoil + 1st Edition Holofoil
|
||||||
|
//
|
||||||
|
// Real holofoil cards have the foil stamp on both the art window AND the card
|
||||||
|
// border. The element carries the art window clip; ::before carries the border
|
||||||
|
// clip via the same zero-width tunnel polygon as Zone 3.
|
||||||
|
// ::before inherits background-image/size/position from the parent via inherit.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.image-grow[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-variant="1st Edition Holofoil" i],
|
||||||
|
.card-image-wrap[data-variant="1st Edition Holofoil" i] {
|
||||||
|
|
||||||
|
.holo-shine {
|
||||||
|
&::before {
|
||||||
|
background-image: inherit;
|
||||||
|
background-size: inherit;
|
||||||
|
background-position: inherit;
|
||||||
|
background-repeat: inherit;
|
||||||
|
background-blend-mode: inherit;
|
||||||
|
filter: inherit;
|
||||||
|
mix-blend-mode: color-dodge;
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%,
|
||||||
|
8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.holo-glare {
|
||||||
|
&::before {
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%,
|
||||||
|
8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 6. ZONE 2 — FULL CARD EFFECT
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.image-grow[data-rarity="Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Character Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Character Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Special Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Special Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Double Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Double Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Hyper Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Hyper Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Mega Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Mega Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Mega Attack Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Mega Attack Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="ACE Spec Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="ACE Spec Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="ACE Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="ACE Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Special Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Special Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Black White Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Black White Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Character Super Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Character Super Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Mega Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Mega Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Rare BREAK" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Rare BREAK" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Shiny Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Shiny Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Shiny Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Shiny Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Shiny Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Shiny Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.image-grow[data-rarity="Shiny Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
.card-image-wrap[data-rarity="Shiny Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]) {
|
||||||
|
|
||||||
|
.holo-shine {
|
||||||
|
clip-path: none;
|
||||||
|
@include prismatic-shine;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holo-glare {
|
||||||
|
clip-path: none;
|
||||||
|
@include prismatic-glare;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 7. ZONE 3 — INVERSE (borders only): Reverse Holofoil + Prism Rare
|
||||||
|
//
|
||||||
|
// Applies the effect to everything EXCEPT the art window.
|
||||||
|
// Uses the "zero-width tunnel" technique from css-tricks.com/cutting-inner-part-element-using-clip-path/
|
||||||
|
// Outer rectangle drawn anticlockwise, closes back to 0% 0%, then the inner
|
||||||
|
// art window rectangle is drawn clockwise — nonzero winding treats the inner
|
||||||
|
// shape as a hole, leaving the art window transparent.
|
||||||
|
//
|
||||||
|
// Outer (anticlockwise): 0 0 → 0 100% → 100% 100% → 100% 0 → 0 0
|
||||||
|
// Inner art window (clockwise): 8% 9.85% → 92% 9.85% → 92% 47.15% → 8% 47.15%
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.image-grow[data-variant="Reverse Holofoil" i],
|
||||||
|
.card-image-wrap[data-variant="Reverse Holofoil" i],
|
||||||
|
.image-grow[data-rarity="Prism Rare" i],
|
||||||
|
.card-image-wrap[data-rarity="Prism Rare" i] {
|
||||||
|
|
||||||
|
// Energy colour tint — multiply blend darkens the card toward --card-glow.
|
||||||
|
// z-index 2 puts it above the card image (z-index 1) but below holo-shine (3).
|
||||||
|
// Opacity tied to --card-opacity so it appears/disappears with the hover effect.
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
background: var(--card-glow);
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
opacity: calc(var(--card-opacity) * 0.5);
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%,
|
||||||
|
8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.holo-shine {
|
||||||
|
// Energy-aware gradient — weaves --card-glow (set per data-energy on the
|
||||||
|
// wrapper) into the prismatic colour sequence so each energy type gets a
|
||||||
|
// tinted shimmer: Grass = green, Fire = orange, Water = cyan, etc.
|
||||||
|
background-image:
|
||||||
|
var(--grain),
|
||||||
|
repeating-linear-gradient(110deg,
|
||||||
|
var(--card-glow),
|
||||||
|
var(--blue),
|
||||||
|
var(--card-glow),
|
||||||
|
var(--green),
|
||||||
|
var(--yellow),
|
||||||
|
var(--card-glow),
|
||||||
|
var(--red),
|
||||||
|
var(--violet),
|
||||||
|
var(--card-glow)
|
||||||
|
);
|
||||||
|
background-position:
|
||||||
|
center center,
|
||||||
|
calc(((50% - var(--background-x)) * 2.6) + 50%)
|
||||||
|
calc(((50% - var(--background-y)) * 3.5) + 50%);
|
||||||
|
background-size: 33%, 400% 400%;
|
||||||
|
background-repeat: repeat, no-repeat;
|
||||||
|
background-blend-mode: soft-light, normal;
|
||||||
|
filter: brightness(1.0) contrast(1.0) saturate(1.4);
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%,
|
||||||
|
8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85%
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holo-glare {
|
||||||
|
@include prismatic-glare;
|
||||||
|
// Hot-spot tinted with energy colour to match the shine treatment
|
||||||
|
background-image: radial-gradient(
|
||||||
|
farthest-corner circle at var(--pointer-x) var(--pointer-y),
|
||||||
|
var(--card-glow) 0%,
|
||||||
|
hsla(0, 0%, 100%, 0.3) 20%,
|
||||||
|
hsla(0, 0%, 0%, 0.5) 90%
|
||||||
|
);
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%,
|
||||||
|
8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85%
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 8. MODAL ANIMATION
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Note: --card-opacity is intentionally NOT registered via @property.
|
||||||
|
// Registering it as <number> makes it interpolatable, causing the browser
|
||||||
|
// to smoothly transition it when JS sets it via inline style — creating the
|
||||||
|
// unwanted slow fade-in. Without registration it changes instantly.
|
||||||
|
@property --pointer-x { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
|
||||||
|
@property --pointer-y { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
|
||||||
|
@property --background-x { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
|
||||||
|
@property --background-y { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
|
||||||
|
@property --pointer-from-center { syntax: '<number>'; inherits: true; initial-value: 0; }
|
||||||
|
@property --pointer-from-left { syntax: '<number>'; inherits: true; initial-value: 0.5; }
|
||||||
|
@property --pointer-from-top { syntax: '<number>'; inherits: true; initial-value: 0.5; }
|
||||||
|
|
||||||
|
@keyframes holo-modal-opacity {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
4% { opacity: 0; }
|
||||||
|
8% { opacity: 0.35; }
|
||||||
|
85% { opacity: 0.35; }
|
||||||
|
90%, 100%{ opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes holo-modal-position {
|
||||||
|
0% {
|
||||||
--pointer-x: 50%; --pointer-y: 50%;
|
--pointer-x: 50%; --pointer-y: 50%;
|
||||||
--background-x: 50%; --background-y: 50%;
|
--background-x: 50%; --background-y: 50%;
|
||||||
--pointer-from-center: 0; --pointer-from-left: 0.5; --pointer-from-top: 0.5;
|
--pointer-from-center: 0; --pointer-from-left: 0.5; --pointer-from-top: 0.5;
|
||||||
}
|
}
|
||||||
4% { --card-opacity: 0; }
|
8% {
|
||||||
8% {
|
|
||||||
--card-opacity: 0.35;
|
|
||||||
--pointer-x: 25%; --pointer-y: 15%;
|
--pointer-x: 25%; --pointer-y: 15%;
|
||||||
--background-x: 38%; --background-y: 28%;
|
--background-x: 38%; --background-y: 28%;
|
||||||
--pointer-from-center: 0.85; --pointer-from-left: 0.25; --pointer-from-top: 0.15;
|
--pointer-from-center: 0.85; --pointer-from-left: 0.25; --pointer-from-top: 0.15;
|
||||||
}
|
}
|
||||||
25% {
|
25% {
|
||||||
--pointer-x: 70%; --pointer-y: 30%;
|
--pointer-x: 70%; --pointer-y: 30%;
|
||||||
--background-x: 64%; --background-y: 34%;
|
--background-x: 64%; --background-y: 34%;
|
||||||
--pointer-from-center: 0.9; --pointer-from-left: 0.70; --pointer-from-top: 0.30;
|
--pointer-from-center: 0.9; --pointer-from-left: 0.70; --pointer-from-top: 0.30;
|
||||||
}
|
}
|
||||||
45% {
|
45% {
|
||||||
--pointer-x: 80%; --pointer-y: 70%;
|
--pointer-x: 80%; --pointer-y: 70%;
|
||||||
--background-x: 74%; --background-y: 68%;
|
--background-x: 74%; --background-y: 68%;
|
||||||
--pointer-from-center: 0.88; --pointer-from-left: 0.80; --pointer-from-top: 0.70;
|
--pointer-from-center: 0.88; --pointer-from-left: 0.80; --pointer-from-top: 0.70;
|
||||||
}
|
}
|
||||||
65% {
|
65% {
|
||||||
--pointer-x: 35%; --pointer-y: 80%;
|
--pointer-x: 35%; --pointer-y: 80%;
|
||||||
--background-x: 38%; --background-y: 76%;
|
--background-x: 38%; --background-y: 76%;
|
||||||
--pointer-from-center: 0.8; --pointer-from-left: 0.35; --pointer-from-top: 0.80;
|
--pointer-from-center: 0.8; --pointer-from-left: 0.35; --pointer-from-top: 0.80;
|
||||||
}
|
}
|
||||||
85% {
|
85% {
|
||||||
--card-opacity: 0.35;
|
|
||||||
--pointer-x: 25%; --pointer-y: 15%;
|
--pointer-x: 25%; --pointer-y: 15%;
|
||||||
--background-x: 38%; --background-y: 28%;
|
--background-x: 38%; --background-y: 28%;
|
||||||
--pointer-from-center: 0.85;
|
--pointer-from-center: 0.85;
|
||||||
}
|
}
|
||||||
90% { --card-opacity: 0; }
|
|
||||||
100% {
|
100% {
|
||||||
--card-opacity: 0;
|
|
||||||
--pointer-x: 50%; --pointer-y: 50%;
|
--pointer-x: 50%; --pointer-y: 50%;
|
||||||
--background-x: 50%; --background-y: 50%;
|
--background-x: 50%; --background-y: 50%;
|
||||||
--pointer-from-center: 0;
|
--pointer-from-center: 0;
|
||||||
@@ -190,160 +524,162 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-image-wrap.holo-modal-mode {
|
.card-image-wrap.holo-modal-mode {
|
||||||
--card-opacity: 0;
|
// Animate pointer vars on the wrapper so CSS custom props interpolate
|
||||||
|
animation: holo-modal-position 60s ease-in-out infinite;
|
||||||
|
animation-delay: var(--shimmer-delay, -2s);
|
||||||
|
|
||||||
.holo-shine,
|
.holo-shine,
|
||||||
.holo-glare {
|
.holo-glare {
|
||||||
animation: holo-modal-pulse 60s ease-in-out infinite;
|
// Animate opacity directly — no @property needed, native interpolation
|
||||||
|
animation: holo-modal-opacity 60s ease-in-out infinite;
|
||||||
animation-delay: var(--shimmer-delay, -2s);
|
animation-delay: var(--shimmer-delay, -2s);
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-holo-active] {
|
&[data-holo-active] {
|
||||||
--card-opacity: 0.45;
|
animation-play-state: paused;
|
||||||
|
.holo-shine { opacity: 0.20; }
|
||||||
|
.holo-glare { opacity: calc(0.20 * 0.4); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 9. MOBILE / TOUCH — static holofoil overlay, no JS tracking
|
||||||
|
//
|
||||||
|
// @media (hover: none) targets touchscreens only.
|
||||||
|
// Technique from joshdance.com/100/Day50: two rainbow gradients at opposing
|
||||||
|
// fixed positions interact via blend modes to create a static holographic sheen.
|
||||||
|
// Where the two gradient bands cross, the additive blending creates bright
|
||||||
|
// rainbow intersections that read as a light-catch effect — no pointer needed.
|
||||||
|
//
|
||||||
|
// Implementation:
|
||||||
|
// - .holo-shine gets the two-gradient stack at fixed diagonal positions
|
||||||
|
// - ::before moves in the opposite direction (negative position) so the
|
||||||
|
// crossing point creates the characteristic holofoil bright intersection
|
||||||
|
// - opacity is always-on at a low value — no hover event needed
|
||||||
|
// - will-change reset to auto — no GPU layer reservation needed
|
||||||
|
// - Glitter hidden — parallax position is meaningless without tracking
|
||||||
|
// - No CSS animation on touch — pure static CSS, zero JS involvement
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
|
||||||
|
.holo-shine,
|
||||||
|
.holo-glare {
|
||||||
|
will-change: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable any animation on modal cards on touch — static treatment handles it
|
||||||
|
.card-image-wrap.holo-modal-mode {
|
||||||
|
animation: none;
|
||||||
|
|
||||||
.holo-shine,
|
.holo-shine,
|
||||||
.holo-glare { animation-play-state: paused; }
|
.holo-glare { animation: none; }
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// 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
|
// Suppress glitter — parallax position is meaningless without pointer tracking
|
||||||
&[data-rarity="rare holo"] {
|
.holo-glare::after { display: none; }
|
||||||
.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
|
// ── Static holofoil overlay for all effect zones on touch ─────────────────
|
||||||
&[data-rarity="rare holo cosmos"] {
|
// Override the JS-driven background-position values with fixed diagonals.
|
||||||
.holo-shine { clip-path: var(--clip); }
|
// The ::before pseudo moves in the opposite direction to create crossing bands.
|
||||||
&[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); } }
|
.image-grow,
|
||||||
&[data-rarity="amazing rare"] { .holo-shine { clip-path: var(--clip); } }
|
.card-image-wrap {
|
||||||
|
|
||||||
&[data-rarity="trainer gallery rare holo"],
|
// Zone 1 — art window
|
||||||
&[data-rarity="rare holo"][data-trainer-gallery="true"] {
|
&[data-rarity="Rare" i]:not([data-variant="Reverse Holofoil" i]):not([data-variant="Normal" i]),
|
||||||
.holo-shine { clip-path: var(--clip-borders); }
|
&[data-rarity="Amazing Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
}
|
&[data-rarity="Classic Collection" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-variant="1st Edition Holofoil" i],
|
||||||
|
// Zone 2 — full card
|
||||||
|
&[data-rarity="Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Character Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Special Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Double Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Hyper Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Mega Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Mega Attack Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="ACE Spec Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="ACE Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Special Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Black White Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Character Super Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Mega Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Rare BREAK" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Shiny Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Shiny Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Shiny Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
&[data-rarity="Shiny Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
|
||||||
|
// Zone 3 — inverse (Reverse Holofoil + Prism Rare)
|
||||||
|
// Energy colour woven in via --card-glow, same as desktop Zone 3
|
||||||
|
&[data-variant="Reverse Holofoil" i],
|
||||||
|
&[data-rarity="Prism Rare" i] {
|
||||||
|
|
||||||
&[data-rarity="rare shiny"] {
|
// Energy colour multiply tint — kept subtle on mobile
|
||||||
.holo-shine { clip-path: var(--clip); }
|
&::after {
|
||||||
&[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); }
|
opacity: 0.04;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reverse holo by rarity — borders only
|
.holo-shine {
|
||||||
&[data-rarity$="reverse holo"] { .holo-shine { clip-path: var(--clip-invert); } }
|
background-image:
|
||||||
// Reverse Holofoil variant — borders only
|
var(--grain),
|
||||||
&[data-variant="Reverse Holofoil"] { .holo-shine { clip-path: var(--clip-invert); } }
|
repeating-linear-gradient(110deg,
|
||||||
|
var(--card-glow),
|
||||||
|
var(--blue),
|
||||||
|
var(--card-glow),
|
||||||
|
var(--green),
|
||||||
|
var(--yellow),
|
||||||
|
var(--card-glow),
|
||||||
|
var(--red),
|
||||||
|
var(--violet),
|
||||||
|
var(--card-glow)
|
||||||
|
);
|
||||||
|
background-size: 33%, 400% 400%;
|
||||||
|
background-repeat: repeat, no-repeat;
|
||||||
|
background-blend-mode: soft-light, normal;
|
||||||
|
background-position: center, 38% 25%;
|
||||||
|
filter: brightness(1.0) contrast(1.1) saturate(1.0);
|
||||||
|
opacity: 0.35;
|
||||||
|
|
||||||
// True holofoil variants + full-bleed rarities — no clip
|
&::before {
|
||||||
&[data-variant="Holofoil"],
|
background-image:
|
||||||
&[data-variant="1st Edition Holofoil"],
|
repeating-linear-gradient(110deg,
|
||||||
&[data-variant="Unlimited Holofoil"],
|
var(--card-glow),
|
||||||
&[data-rarity="rare ultra"],
|
var(--blue),
|
||||||
&[data-rarity="rare holo v"],
|
var(--card-glow),
|
||||||
&[data-rarity="rare holo vmax"],
|
var(--green),
|
||||||
&[data-rarity="rare holo vstar"],
|
var(--yellow),
|
||||||
&[data-rarity="rare shiny v"],
|
var(--card-glow),
|
||||||
&[data-rarity="rare shiny vmax"],
|
var(--red),
|
||||||
&[data-rarity="rare rainbow"],
|
var(--violet),
|
||||||
&[data-rarity="rare rainbow alt"],
|
var(--card-glow)
|
||||||
&[data-rarity="rare secret"] {
|
);
|
||||||
.holo-shine { clip-path: none; }
|
background-size: 400% 400%;
|
||||||
}
|
background-position: 62% 75%;
|
||||||
|
mix-blend-mode: color-dodge;
|
||||||
|
opacity: 0.18;
|
||||||
|
filter: brightness(1.0) contrast(1.1) saturate(1.0);
|
||||||
|
}
|
||||||
|
|
||||||
// Foil variant shine/glare — clip handled above per variant type
|
&::after { display: none; }
|
||||||
&[data-variant="Holofoil"],
|
}
|
||||||
&[data-variant="Reverse Holofoil"],
|
|
||||||
&[data-variant="1st Edition Holofoil"],
|
.holo-glare {
|
||||||
&[data-variant="Unlimited Holofoil"] {
|
opacity: 0.15;
|
||||||
.holo-shine {
|
background-image: radial-gradient(
|
||||||
background-image:
|
farthest-corner circle at 35% 25%,
|
||||||
radial-gradient(
|
var(--card-glow) 0%,
|
||||||
circle at var(--pointer-x) var(--pointer-y),
|
hsla(0, 0%, 100%, 0.2) 30%,
|
||||||
#fff 5%, #000 50%, #fff 80%
|
hsla(0, 0%, 0%, 0.3) 90%
|
||||||
),
|
|
||||||
linear-gradient(
|
|
||||||
var(--foil-angle, -45deg),
|
|
||||||
#000 15%, #fff, #000 85%
|
|
||||||
);
|
);
|
||||||
background-blend-mode: soft-light, difference;
|
filter: brightness(0.8) contrast(1.5);
|
||||||
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
@@ -1,53 +1,57 @@
|
|||||||
/**
|
/**
|
||||||
* holofoil-init.js
|
* holofoil-init.js
|
||||||
* -----------------------------------------------------------------------------
|
|
||||||
* Instruments .image-grow and .card-image-wrap with the holofoil effect system.
|
* 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() {
|
(function HolofoilSystem() {
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// -- Constants --------------------------------------------------------------
|
// Variants that receive NO effect
|
||||||
|
const NO_EFFECT_VARIANTS = new Set(['normal']);
|
||||||
|
|
||||||
const SHIMMER_SEL = [
|
// Variants that always receive an effect regardless of rarity
|
||||||
'.image-grow[data-rarity]',
|
const HOLO_VARIANTS = new Set([
|
||||||
'.image-grow[data-variant="Holofoil"]',
|
'reverse holofoil',
|
||||||
'.image-grow[data-variant="1st Edition Holofoil"]',
|
'holofoil',
|
||||||
'.image-grow[data-variant="Unlimited Holofoil"]',
|
'1st edition holofoil',
|
||||||
'.image-grow[data-variant="Reverse Holofoil"]',
|
]);
|
||||||
'.card-image-wrap[data-rarity]',
|
|
||||||
'.card-image-wrap[data-variant="Holofoil"]',
|
// Rarities that receive an effect
|
||||||
'.card-image-wrap[data-variant="1st Edition Holofoil"]',
|
const HOLO_RARITIES = new Set([
|
||||||
'.card-image-wrap[data-variant="Unlimited Holofoil"]',
|
// Art window zone
|
||||||
'.card-image-wrap[data-variant="Reverse Holofoil"]',
|
'rare',
|
||||||
].join(',');
|
'amazing rare',
|
||||||
|
'classic collection',
|
||||||
|
'holo rare',
|
||||||
|
// Full card zone
|
||||||
|
'ultra rare',
|
||||||
|
'character rare',
|
||||||
|
'illustration rare',
|
||||||
|
'special illustration rare',
|
||||||
|
'double rare',
|
||||||
|
'hyper rare',
|
||||||
|
'mega rare',
|
||||||
|
'mega attack rare',
|
||||||
|
'ace spec rare',
|
||||||
|
'ace rare',
|
||||||
|
'art rare',
|
||||||
|
'special art rare',
|
||||||
|
'black white rare',
|
||||||
|
'character super rare',
|
||||||
|
'mega ultra rare',
|
||||||
|
'rare break',
|
||||||
|
'secret rare',
|
||||||
|
'shiny holo rare',
|
||||||
|
'shiny rare',
|
||||||
|
'shiny secret rare',
|
||||||
|
'shiny ultra rare',
|
||||||
|
// Inverse zone
|
||||||
|
'prism rare',
|
||||||
|
]);
|
||||||
|
|
||||||
const ALL_WRAPPERS_SEL = '.image-grow, .card-image-wrap';
|
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 rand = (min, max) => parseFloat((Math.random() * (max - min) + min).toFixed(2));
|
||||||
const clamp01 = n => Math.max(0, Math.min(1, n));
|
const clamp01 = n => Math.max(0, Math.min(1, n));
|
||||||
|
|
||||||
@@ -56,13 +60,13 @@
|
|||||||
const fromTop = clamp01((y - rect.top) / rect.height);
|
const fromTop = clamp01((y - rect.top) / rect.height);
|
||||||
const fromCenter = clamp01(Math.sqrt((fromLeft - 0.5) ** 2 + (fromTop - 0.5) ** 2) * 2);
|
const fromCenter = clamp01(Math.sqrt((fromLeft - 0.5) ** 2 + (fromTop - 0.5) ** 2) * 2);
|
||||||
return {
|
return {
|
||||||
px: fromLeft * 100,
|
px: fromLeft * 100,
|
||||||
py: fromTop * 100,
|
py: fromTop * 100,
|
||||||
fromLeft,
|
fromLeft,
|
||||||
fromTop,
|
fromTop,
|
||||||
fromCenter,
|
fromCenter,
|
||||||
bgX: 50 + (fromLeft - 0.5) * 30,
|
bgX: 50 + (fromLeft - 0.5) * -30,
|
||||||
bgY: 50 + (fromTop - 0.5) * 30,
|
bgY: 50 + (fromTop - 0.5) * -30,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,12 +80,18 @@
|
|||||||
el.style.setProperty('--background-y', v.bgY.toFixed(1) + '%');
|
el.style.setProperty('--background-y', v.bgY.toFixed(1) + '%');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isHoloVariant = v => ['Holofoil', 'Reverse Holofoil', '1st Edition Holofoil', 'Unlimited Holofoil'].includes(v);
|
function shouldHaveEffect(el) {
|
||||||
const isModalWrapper = el => el.classList.contains('card-image-wrap');
|
if (el.dataset.default === 'true') return false;
|
||||||
const isDefault = el => el.dataset.default === 'true';
|
// Also check if the card image itself is the default fallback
|
||||||
|
const img = el.querySelector('img');
|
||||||
|
if (img && img.src && img.src.endsWith('/cards/default.jpg')) return false;
|
||||||
// -- Child injection --------------------------------------------------------
|
const variant = (el.dataset.variant || '').toLowerCase().trim();
|
||||||
|
const rarity = (el.dataset.rarity || '').toLowerCase().trim();
|
||||||
|
if (NO_EFFECT_VARIANTS.has(variant)) return false;
|
||||||
|
if (HOLO_VARIANTS.has(variant)) return true;
|
||||||
|
if (HOLO_RARITIES.has(rarity)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function injectChildren(el) {
|
function injectChildren(el) {
|
||||||
if (el.querySelector('.holo-shine')) return;
|
if (el.querySelector('.holo-shine')) return;
|
||||||
@@ -93,18 +103,10 @@
|
|||||||
el.appendChild(glare);
|
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) {
|
function watchForDefault(el) {
|
||||||
if (isDefault(el)) return;
|
if (el.dataset.default === 'true') return;
|
||||||
|
|
||||||
var observer = new MutationObserver(function() {
|
var observer = new MutationObserver(function() {
|
||||||
if (isDefault(el)) {
|
if (el.dataset.default === 'true') {
|
||||||
var shine = el.querySelector('.holo-shine');
|
var shine = el.querySelector('.holo-shine');
|
||||||
var glare = el.querySelector('.holo-glare');
|
var glare = el.querySelector('.holo-glare');
|
||||||
if (shine) shine.style.display = 'none';
|
if (shine) shine.style.display = 'none';
|
||||||
@@ -112,81 +114,54 @@
|
|||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(el, { attributes: true, attributeFilter: ['data-default'] });
|
observer.observe(el, { attributes: true, attributeFilter: ['data-default'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canHover = window.matchMedia('(hover: hover)').matches;
|
||||||
// -- Stamp ------------------------------------------------------------------
|
|
||||||
|
|
||||||
function stamp(el) {
|
function stamp(el) {
|
||||||
if (el.dataset.holoInit) return;
|
if (el.dataset.holoInit) return;
|
||||||
|
if (!shouldHaveEffect(el)) {
|
||||||
// Skip if already a default fallback image
|
|
||||||
if (isDefault(el)) {
|
|
||||||
el.dataset.holoInit = 'skip';
|
el.dataset.holoInit = 'skip';
|
||||||
return;
|
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);
|
injectChildren(el);
|
||||||
|
if (el.classList.contains('card-image-wrap')) {
|
||||||
// Per-card foil visual randomisation (angle/brightness/saturation)
|
if (canHover) {
|
||||||
if (hasHoloVariant) {
|
// Desktop: use hover + pointer tracking, same as grid cards.
|
||||||
el.style.setProperty('--foil-angle', Math.round(rand(FOIL_ANGLE_MIN, FOIL_ANGLE_MAX)) + 'deg');
|
// No animation — CSS :hover rule controls --card-opacity directly.
|
||||||
el.style.setProperty('--foil-brightness', rand(FOIL_BRITE_MIN, FOIL_BRITE_MAX).toFixed(2));
|
el.classList.remove('holo-modal-mode');
|
||||||
el.style.setProperty('--foil-saturation', rand(FOIL_SAT_MIN, FOIL_SAT_MAX ).toFixed(2));
|
} else {
|
||||||
|
// Touch: use the autonomous CSS animation sweep.
|
||||||
|
el.classList.add('holo-modal-mode');
|
||||||
|
el.style.setProperty('--shimmer-delay', rand(-8, 0) + 's');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
watchForDefault(el);
|
||||||
|
|
||||||
el.dataset.holoInit = '1';
|
el.dataset.holoInit = '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
function stampAll(root) {
|
|
||||||
(root || document).querySelectorAll(ALL_WRAPPERS_SEL).forEach(stamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// -- Pointer tracking -------------------------------------------------------
|
|
||||||
|
|
||||||
const pointerState = new WeakMap();
|
const pointerState = new WeakMap();
|
||||||
|
|
||||||
function onPointerEnter(e) {
|
function onPointerEnter(e) {
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
if (el.dataset.holoInit !== '1' || isDefault(el)) return;
|
if (el.dataset.holoInit !== '1') return;
|
||||||
|
|
||||||
el.dataset.holoActive = '1';
|
el.dataset.holoActive = '1';
|
||||||
|
// Inline style wins over CSS immediately — @property not registered for
|
||||||
|
// --card-opacity so no interpolation. All calc() multipliers in child
|
||||||
|
// rules (glare * 0.4, glitter * 0.6) work correctly from this single var.
|
||||||
|
el.style.setProperty('--card-opacity', '0.2');
|
||||||
if (!pointerState.has(el)) pointerState.set(el, { rafId: null });
|
if (!pointerState.has(el)) pointerState.set(el, { rafId: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerMove(e) {
|
function onPointerMove(e) {
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
if (el.dataset.holoInit !== '1') return;
|
if (el.dataset.holoInit !== '1') return;
|
||||||
|
|
||||||
const state = pointerState.get(el);
|
const state = pointerState.get(el);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
if (state.rafId) cancelAnimationFrame(state.rafId);
|
if (state.rafId) cancelAnimationFrame(state.rafId);
|
||||||
state.rafId = requestAnimationFrame(function() {
|
state.rafId = requestAnimationFrame(function() {
|
||||||
const rect = el.getBoundingClientRect();
|
applyPointerVars(el, pointerVars(e.clientX, e.clientY, el.getBoundingClientRect()));
|
||||||
applyPointerVars(el, pointerVars(e.clientX, e.clientY, rect));
|
|
||||||
state.rafId = null;
|
state.rafId = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -194,46 +169,39 @@
|
|||||||
function onPointerLeave(e) {
|
function onPointerLeave(e) {
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
if (el.dataset.holoInit !== '1') return;
|
if (el.dataset.holoInit !== '1') return;
|
||||||
|
|
||||||
const state = pointerState.get(el);
|
const state = pointerState.get(el);
|
||||||
if (state && state.rafId) { cancelAnimationFrame(state.rafId); state.rafId = null; }
|
if (state && state.rafId) { cancelAnimationFrame(state.rafId); state.rafId = null; }
|
||||||
|
|
||||||
delete el.dataset.holoActive;
|
delete el.dataset.holoActive;
|
||||||
|
// Remove inline style so CSS default (--card-opacity: 0) takes over instantly
|
||||||
if (isModalWrapper(el)) {
|
el.style.removeProperty('--card-opacity');
|
||||||
// Let the CSS animation resume driving --card-opacity
|
|
||||||
el.style.removeProperty('--card-opacity');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachListeners(el) {
|
function attachListeners(el) {
|
||||||
if (el.dataset.holoListeners) return;
|
if (el.dataset.holoListeners) return;
|
||||||
|
// On touch-only devices the CSS static shimmer handles the effect.
|
||||||
|
// Skip JS pointer tracking — pointermove never fires on touchscreens
|
||||||
|
// and registering listeners wastes memory with no benefit.
|
||||||
|
if (!window.matchMedia('(hover: hover)').matches) return;
|
||||||
el.addEventListener('pointerenter', onPointerEnter, { passive: true });
|
el.addEventListener('pointerenter', onPointerEnter, { passive: true });
|
||||||
el.addEventListener('pointermove', onPointerMove, { passive: true });
|
el.addEventListener('pointermove', onPointerMove, { passive: true });
|
||||||
el.addEventListener('pointerleave', onPointerLeave, { passive: true });
|
el.addEventListener('pointerleave', onPointerLeave, { passive: true });
|
||||||
el.dataset.holoListeners = '1';
|
el.dataset.holoListeners = '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachAllListeners(root) {
|
function stampAll(root) {
|
||||||
(root || document).querySelectorAll(SHIMMER_SEL).forEach(function(el) {
|
(root || document).querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) {
|
||||||
stamp(el);
|
stamp(el);
|
||||||
if (el.dataset.holoInit === '1') attachListeners(el);
|
if (el.dataset.holoInit === '1') attachListeners(el);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// -- MutationObserver: react to HTMX / infinite scroll ----------------------
|
|
||||||
|
|
||||||
function observeGrid() {
|
function observeGrid() {
|
||||||
var grid = document.getElementById('cardGrid');
|
var grid = document.getElementById('cardGrid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
|
||||||
new MutationObserver(function(mutations) {
|
new MutationObserver(function(mutations) {
|
||||||
for (var i = 0; i < mutations.length; i++) {
|
mutations.forEach(function(m) {
|
||||||
var nodes = mutations[i].addedNodes;
|
m.addedNodes.forEach(function(node) {
|
||||||
for (var j = 0; j < nodes.length; j++) {
|
if (node.nodeType !== 1) return;
|
||||||
var node = nodes[j];
|
|
||||||
if (node.nodeType !== 1) continue;
|
|
||||||
if (node.matches && node.matches(ALL_WRAPPERS_SEL)) {
|
if (node.matches && node.matches(ALL_WRAPPERS_SEL)) {
|
||||||
stamp(node);
|
stamp(node);
|
||||||
if (node.dataset.holoInit === '1') attachListeners(node);
|
if (node.dataset.holoInit === '1') attachListeners(node);
|
||||||
@@ -244,8 +212,8 @@
|
|||||||
if (el.dataset.holoInit === '1') attachListeners(el);
|
if (el.dataset.holoInit === '1') attachListeners(el);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
});
|
||||||
}).observe(grid, { childList: true, subtree: true });
|
}).observe(grid, { childList: true, subtree: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,20 +221,32 @@
|
|||||||
var modal = document.getElementById('cardModal');
|
var modal = document.getElementById('cardModal');
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
|
|
||||||
new MutationObserver(function() {
|
new MutationObserver(function(mutations) {
|
||||||
modal.querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) {
|
mutations.forEach(function(m) {
|
||||||
stamp(el);
|
m.addedNodes.forEach(function(node) {
|
||||||
if (el.dataset.holoInit === '1') attachListeners(el);
|
if (node.nodeType !== 1) return;
|
||||||
|
|
||||||
|
var wrappers = [];
|
||||||
|
if (node.matches && node.matches(ALL_WRAPPERS_SEL)) wrappers.push(node);
|
||||||
|
if (node.querySelectorAll) {
|
||||||
|
node.querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) {
|
||||||
|
wrappers.push(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
wrappers.forEach(function(el) {
|
||||||
|
// Reset stamp so each new card is evaluated fresh
|
||||||
|
delete el.dataset.holoInit;
|
||||||
|
stamp(el);
|
||||||
|
if (el.dataset.holoInit === '1') attachListeners(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}).observe(modal, { childList: true, subtree: true });
|
}).observe(modal, { childList: true, subtree: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// -- Bootstrap --------------------------------------------------------------
|
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
stampAll();
|
stampAll();
|
||||||
attachAllListeners();
|
|
||||||
observeGrid();
|
observeGrid();
|
||||||
observeModal();
|
observeModal();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,52 @@
|
|||||||
import * as bootstrap from 'bootstrap';
|
import * as bootstrap from 'bootstrap';
|
||||||
window.bootstrap = bootstrap;
|
window.bootstrap = bootstrap;
|
||||||
|
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
// trap browser back and close the modal if open
|
||||||
|
const cardModal = document.getElementById('cardModal');
|
||||||
// Initialize all Bootstrap modals
|
const loadingMsg = cardModal.innerHTML;
|
||||||
document.querySelectorAll('.modal').forEach(modalEl => {
|
// Push a new history state when the modal is shown
|
||||||
bootstrap.Modal.getOrCreateInstance(modalEl);
|
cardModal.addEventListener('shown.bs.modal', () => {
|
||||||
});
|
history.pushState({ modalOpen: true }, null, '#cardModal');
|
||||||
|
});
|
||||||
// Initialize tooltips
|
// Listen for the browser's back button (popstate event)
|
||||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
window.addEventListener('popstate', (e) => {
|
||||||
if (!el._tooltipInstance) {
|
if (cardModal.classList.contains('show')) {
|
||||||
el._tooltipInstance = new bootstrap.Tooltip(el, { container: 'body' });
|
const modalInstance = bootstrap.Modal.getInstance(cardModal);
|
||||||
|
if (modalInstance) {
|
||||||
|
modalInstance.hide();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------- 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";
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Trigger a back navigation when the modal is closed via its native controls (X, backdrop click)
|
||||||
|
cardModal.addEventListener('hide.bs.modal', () => {
|
||||||
|
cardModal.innerHTML = loadingMsg;
|
||||||
|
if (history.state && history.state.modalOpen) {
|
||||||
|
history.back();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
import { Tooltip } from "bootstrap";
|
||||||
|
|
||||||
|
// Initialize all tooltips globally
|
||||||
|
const initTooltips = () => {
|
||||||
|
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||||
|
if (!el._tooltipInstance) {
|
||||||
|
el._tooltipInstance = new Tooltip(el, {
|
||||||
|
container: 'body', // ensures tooltip is appended to body, important for modals
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run on page load
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initTooltips);
|
||||||
|
} else {
|
||||||
|
initTooltips();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: observe DOM changes for dynamically added tooltips (e.g., modals loaded later)
|
||||||
|
const observer = new MutationObserver(() => initTooltips());
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
@@ -3,11 +3,11 @@ import Chart from 'chart.js/auto';
|
|||||||
const CONDITIONS = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
|
const CONDITIONS = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
|
||||||
|
|
||||||
const CONDITION_COLORS = {
|
const CONDITION_COLORS = {
|
||||||
"Near Mint": { active: 'hsla(88, 50%, 67%, 1)', muted: 'hsla(88, 50%, 67%, 0.67)' },
|
"Near Mint": { active: 'rgba(156, 204, 102, 1)', muted: 'rgba(156, 204, 102, 0.67)' },
|
||||||
"Lightly Played": { active: 'hsla(66, 70%, 68%, 1)', muted: 'hsla(66, 70%, 68%, 0.67)' },
|
"Lightly Played": { active: 'rgba(211, 225, 86, 1)', muted: 'rgba(211, 225, 86, 0.67)' },
|
||||||
"Moderately Played": { active: 'hsla(54, 100%, 73%, 1)', muted: 'hsla(54, 100%, 73%, 0.67)' },
|
"Moderately Played": { active: 'rgba(255, 238, 87, 1)', muted: 'rgba(255, 238, 87, 0.67)' },
|
||||||
"Heavily Played": { active: 'hsla(46, 100%, 65%, 1)', muted: 'hsla(46, 100%, 65%, 0.67)' },
|
"Heavily Played": { active: 'rgba(255, 201, 41, 1)', muted: 'rgba(255, 201, 41, 0.67)' },
|
||||||
"Damaged": { active: 'hsla(36, 100%, 65%, 1)', muted: 'hsla(36, 100%, 65%, 0.67)' },
|
"Damaged": { active: 'rgba(255, 167, 36, 1)', muted: 'rgba(255, 167, 36, 0.67)' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '1y': 365, 'all': Infinity };
|
const RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '1y': 365, 'all': Infinity };
|
||||||
@@ -32,12 +32,6 @@ function setEmptyState(isEmpty) {
|
|||||||
canvasWrapper.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) {
|
function buildChartData(history, rangeKey) {
|
||||||
const cutoff = RANGE_DAYS[rangeKey] === Infinity
|
const cutoff = RANGE_DAYS[rangeKey] === Infinity
|
||||||
? new Date(0)
|
? new Date(0)
|
||||||
@@ -45,14 +39,20 @@ function buildChartData(history, rangeKey) {
|
|||||||
|
|
||||||
const filtered = history.filter(r => new Date(r.calculatedAt) >= cutoff);
|
const filtered = history.filter(r => new Date(r.calculatedAt) >= cutoff);
|
||||||
|
|
||||||
|
// Always build the full date axis for the selected window, even if sparse.
|
||||||
|
// Generate one label per day in the range so the x-axis reflects the
|
||||||
|
// chosen period rather than collapsing to only the days that have data.
|
||||||
const dataDateSet = new Set(filtered.map(r => r.calculatedAt));
|
const dataDateSet = new Set(filtered.map(r => r.calculatedAt));
|
||||||
const allDates = [...dataDateSet].sort((a, b) => new Date(a) - new Date(b));
|
const allDates = [...dataDateSet].sort((a, b) => new Date(a) - new Date(b));
|
||||||
|
|
||||||
|
// If we have real data, expand the axis to span from cutoff → today so
|
||||||
|
// empty stretches at the start/end of a range are visible.
|
||||||
let axisLabels = allDates;
|
let axisLabels = allDates;
|
||||||
if (allDates.length > 0 && RANGE_DAYS[rangeKey] !== Infinity) {
|
if (allDates.length > 0 && RANGE_DAYS[rangeKey] !== Infinity) {
|
||||||
const start = new Date(cutoff);
|
const start = new Date(cutoff);
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
const expanded = [];
|
const expanded = [];
|
||||||
|
// Step through every day in the window
|
||||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||||
expanded.push(d.toISOString().split('T')[0]);
|
expanded.push(d.toISOString().split('T')[0]);
|
||||||
}
|
}
|
||||||
@@ -101,9 +101,17 @@ function buildChartData(history, rangeKey) {
|
|||||||
function updateChart() {
|
function updateChart() {
|
||||||
if (!chartInstance) return;
|
if (!chartInstance) return;
|
||||||
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
|
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
|
||||||
|
|
||||||
|
// Always push the new labels/datasets to the chart so the x-axis
|
||||||
|
// reflects the selected time window — even when there's no data for
|
||||||
|
// the active condition. Then toggle the empty state overlay on top.
|
||||||
chartInstance.data.labels = labels;
|
chartInstance.data.labels = labels;
|
||||||
chartInstance.data.datasets = datasets;
|
chartInstance.data.datasets = datasets;
|
||||||
chartInstance.update('none');
|
chartInstance.update('none');
|
||||||
|
|
||||||
|
// Show the empty state overlay if the active condition has no points
|
||||||
|
// in this window, but leave the (empty) chart visible underneath so
|
||||||
|
// the axis communicates the selected period.
|
||||||
setEmptyState(!hasData || !activeConditionHasData);
|
setEmptyState(!hasData || !activeConditionHasData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +135,7 @@ function initPriceChart(canvas) {
|
|||||||
|
|
||||||
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
|
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
|
||||||
|
|
||||||
|
// Render the chart regardless — show empty state overlay if needed
|
||||||
setEmptyState(!hasData || !activeConditionHasData);
|
setEmptyState(!hasData || !activeConditionHasData);
|
||||||
|
|
||||||
chartInstance = new Chart(canvas.getContext('2d'), {
|
chartInstance = new Chart(canvas.getContext('2d'), {
|
||||||
@@ -193,16 +202,9 @@ function initFromCanvas(canvas) {
|
|||||||
activeCondition = "Near Mint";
|
activeCondition = "Near Mint";
|
||||||
activeRange = '1m';
|
activeRange = '1m';
|
||||||
const modal = document.getElementById('cardModal');
|
const modal = document.getElementById('cardModal');
|
||||||
|
|
||||||
modal?.querySelectorAll('.price-range-btn').forEach(b => {
|
modal?.querySelectorAll('.price-range-btn').forEach(b => {
|
||||||
b.classList.toggle('active', b.dataset.range === '1m');
|
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);
|
initPriceChart(canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,10 +225,6 @@ function setup() {
|
|||||||
document.addEventListener('shown.bs.tab', (e) => {
|
document.addEventListener('shown.bs.tab', (e) => {
|
||||||
if (!modal.contains(e.target)) return;
|
if (!modal.contains(e.target)) return;
|
||||||
const target = e.target?.getAttribute('data-bs-target');
|
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 = {
|
const conditionMap = {
|
||||||
'#nav-nm': 'Near Mint',
|
'#nav-nm': 'Near Mint',
|
||||||
'#nav-lp': 'Lightly Played',
|
'#nav-lp': 'Lightly Played',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
---
|
---
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-light p-2 rounded-squircle"
|
class="btn btn-info p-2 rounded-circle"
|
||||||
aria-label="Back to Top"
|
aria-label="Back to Top"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
id="btn-back-to-top"
|
id="btn-back-to-top"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
import BackToTop from "./BackToTop.astro"
|
import BackToTop from "./BackToTop.astro"
|
||||||
---
|
---
|
||||||
<div class="container-fluid container-sm mt-3">
|
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<div class="h5 d-none">Inventory management placeholder</div>
|
<div class="h5 d-none">Inventory management placeholder</div>
|
||||||
@@ -44,150 +43,17 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
</div>
|
|
||||||
|
<script src="src/assets/js/holofoil-init.js" is:inline></script>
|
||||||
|
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
(function () {
|
(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 ─────────────────────────────────────────────────────────
|
// ── Sort dropdown ─────────────────────────────────────────────────────────
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
const btn = e.target.closest('#sortBy [data-toggle="sort-dropdown"]');
|
const sortBy = document.getElementById('sortBy');
|
||||||
|
|
||||||
|
const btn = e.target.closest('#sortBy [data-bs-toggle="dropdown"]');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -252,6 +118,7 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
canvas.width = img.naturalWidth;
|
canvas.width = img.naturalWidth;
|
||||||
canvas.height = img.naturalHeight;
|
canvas.height = img.naturalHeight;
|
||||||
|
|
||||||
|
// Load with crossOrigin so toBlob() stays untainted
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
const clean = new Image();
|
const clean = new Image();
|
||||||
clean.crossOrigin = 'anonymous';
|
clean.crossOrigin = 'anonymous';
|
||||||
@@ -305,20 +172,6 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
}, 2000);
|
}, 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 ─────────────────────────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
const cardIndex = [];
|
const cardIndex = [];
|
||||||
let currentCardId = null;
|
let currentCardId = null;
|
||||||
@@ -406,17 +259,10 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
|
|
||||||
if (modal._reconnectChartObserver) modal._reconnectChartObserver();
|
if (modal._reconnectChartObserver) modal._reconnectChartObserver();
|
||||||
|
|
||||||
modal.querySelectorAll('[data-bs-toggle="tab"]').forEach(el => {
|
|
||||||
bootstrap.Tab.getInstance(el)?.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
modal.innerHTML = html;
|
modal.innerHTML = html;
|
||||||
|
|
||||||
if (typeof htmx !== 'undefined') htmx.process(modal);
|
if (typeof htmx !== 'undefined') htmx.process(modal);
|
||||||
initInventoryForms(modal);
|
|
||||||
updateNavButtons(modal);
|
updateNavButtons(modal);
|
||||||
initChartAfterSwap(modal);
|
initChartAfterSwap(modal);
|
||||||
switchToRequestedTab();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (document.startViewTransition && direction) {
|
if (document.startViewTransition && direction) {
|
||||||
@@ -501,14 +347,8 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
|
|
||||||
if (target._reconnectChartObserver) target._reconnectChartObserver();
|
if (target._reconnectChartObserver) target._reconnectChartObserver();
|
||||||
|
|
||||||
target.querySelectorAll('[data-bs-toggle="tab"]').forEach(el => {
|
|
||||||
bootstrap.Tab.getInstance(el)?.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
target.innerHTML = html;
|
target.innerHTML = html;
|
||||||
|
|
||||||
if (typeof htmx !== 'undefined') htmx.process(target);
|
if (typeof htmx !== 'undefined') htmx.process(target);
|
||||||
initInventoryForms(target);
|
|
||||||
|
|
||||||
const destImg = target.querySelector('img.card-image');
|
const destImg = target.querySelector('img.card-image');
|
||||||
if (destImg) {
|
if (destImg) {
|
||||||
@@ -525,7 +365,6 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
await transition.finished;
|
await transition.finished;
|
||||||
updateNavButtons(target);
|
updateNavButtons(target);
|
||||||
initChartAfterSwap(target);
|
initChartAfterSwap(target);
|
||||||
switchToRequestedTab();
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[card-modal] transition failed:', err);
|
console.error('[card-modal] transition failed:', err);
|
||||||
@@ -541,123 +380,13 @@ import BackToTop from "./BackToTop.astro"
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cardModal = document.getElementById('cardModal');
|
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', () => {
|
cardModal.addEventListener('shown.bs.modal', () => {
|
||||||
updateNavButtons(cardModal);
|
updateNavButtons(cardModal);
|
||||||
initChartAfterSwap(cardModal);
|
initChartAfterSwap(cardModal);
|
||||||
initInventoryForms(cardModal);
|
|
||||||
switchToRequestedTab();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
cardModal.addEventListener('hidden.bs.modal', () => {
|
cardModal.addEventListener('hidden.bs.modal', () => {
|
||||||
currentCardId = null;
|
currentCardId = null;
|
||||||
updateNavButtons(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>
|
</script>
|
||||||
@@ -1,33 +1,18 @@
|
|||||||
---
|
---
|
||||||
import logo from "/src/svg/logo/rat_light.svg?raw";
|
|
||||||
---
|
---
|
||||||
<footer class="footer py-5 border-top border-subtle" role="contentinfo">
|
<footer class="bd-footer py-4 py-md-5 mt-0 bg-body-tertiary">
|
||||||
<div class="container">
|
<div class="container py-4 py-md-5 px-4 px-md-3 text-body-secondary">
|
||||||
<div class="row g-4 mb-4">
|
<div class="row justify-content-end">
|
||||||
<div class="col-md-4">
|
<div class="col mb-3">
|
||||||
<a href="/" class="d-inline-block mb-3" aria-label="RAT home">
|
<a class="btn btn-outline-success rounded p-2 float-end" href="/contact">
|
||||||
<span set:html={logo} class="logo-svg d-flex" style="--logo-width: 8rem;"></span>
|
Contact Us
|
||||||
</a>
|
<svg aria-hidden="true" class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1,36 +1,12 @@
|
|||||||
---
|
---
|
||||||
import { UserButton, SignInButton, Show } from '@clerk/astro/components'
|
|
||||||
import logo from "/src/svg/logo/rat_light.svg?raw";
|
|
||||||
---
|
---
|
||||||
<nav class="navbar sticky-top bg-dark shadow" data-bs-theme="dark" aria-label="Main navigation">
|
<nav class="navbar navbar-expand sticky-top bg-dark" data-bs-theme="dark" aria-label="Main navigation">
|
||||||
<div class="container align-items-center" id="navContainer">
|
<div class="container">
|
||||||
<a class="navbar-brand" href="/">
|
<a class="navbar-brand d-flex" href="/">
|
||||||
<span set:html={logo} class="logo-svg d-flex"></span>
|
<span class="h3 d-none d-md-flex">Rigid's App Thing</span><span aria-hidden="true" class="h3 d-md-none d-flex m-auto">RAT</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="d-flex d-md-none nav-user-btn" id="navUserBtn">
|
<slot name="navItems"/>
|
||||||
<Show when="signed-in">
|
<slot name="searchInput"/>
|
||||||
<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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -1,46 +1,16 @@
|
|||||||
---
|
---
|
||||||
---
|
|
||||||
<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"
|
<div class="navbar-collapse" id="navbarNav" aria-labelledby="navbarToggler">
|
||||||
data-bs-theme="dark"
|
<ul class="navbar-nav ms-auto">
|
||||||
>
|
<li class="nav-item d-flex">
|
||||||
<div
|
<a class="nav-link btn btn-warning rounded p-2" href="/pokemon" aria-label="Cards">
|
||||||
class="offcanvas offcanvas-end"
|
<span class="d-inline-block d-md-none" aria-hidden="true">Cards</span>
|
||||||
tabindex="-1"
|
<svg aria-hidden="true" class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||||
id="navOffcanvas"
|
<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"/>
|
||||||
aria-labelledby="navOffcanvasLabel"
|
<path d="M43.5 113L352.6 30.2L468.6 462.9L159.5 545.7z"/>
|
||||||
>
|
</svg>
|
||||||
<div class="offcanvas-header">
|
</a>
|
||||||
<h5 class="offcanvas-title" id="navOffcanvasLabel">Menu</h5>
|
</li>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
</ul>
|
||||||
</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>
|
|
||||||
@@ -8,6 +8,7 @@ import { Show } from '@clerk/astro/components'
|
|||||||
const val = Number(start.value) || 0;
|
const val = Number(start.value) || 0;
|
||||||
start.value = (val + 20).toString();
|
start.value = (val + 20).toString();
|
||||||
}
|
}
|
||||||
|
// delete the triggering element
|
||||||
if (e && e.detail && e.detail.elt) {
|
if (e && e.detail && e.detail.elt) {
|
||||||
e.detail.elt.remove();
|
e.detail.elt.remove();
|
||||||
}
|
}
|
||||||
@@ -25,47 +26,21 @@ import { Show } from '@clerk/astro/components'
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Show when="signed-in">
|
<Show when="signed-in">
|
||||||
<form
|
<form class="d-flex ms-2 align-items-center gap-2" role="search" id="searchform" hx-post="/partials/cards" hx-target="#cardGrid" hx-trigger="load, submit" hx-vals='{"start":"0"}' hx-on--after-request="afterUpdate()" hx-on--before-request="beforeSearch()">
|
||||||
class="d-flex align-items-center"
|
<a class="btn btn-secondary btn-lg" data-bs-toggle="offcanvas" href="#filterBar" role="button" aria-controls="filterBar" aria-label="filter">
|
||||||
role="search"
|
<span class="d-block d-md-none filter-icon py-2">
|
||||||
id="searchform"
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg>
|
||||||
hx-post="/partials/cards"
|
</span>
|
||||||
hx-target="#cardGrid"
|
<span class="d-none d-md-block">Filters</span>
|
||||||
hx-trigger="load, submit"
|
</a>
|
||||||
hx-vals='{"start":"0"}'
|
|
||||||
hx-on--after-request="afterUpdate()"
|
|
||||||
hx-on--before-request="beforeSearch()"
|
|
||||||
>
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
{Astro.url.pathname === '/pokemon' && (
|
|
||||||
<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="start" id="start" value="0" />
|
||||||
<input type="hidden" name="sort" id="sortInput" value="" />
|
<input type="hidden" name="sort" id="sortInput" value="" />
|
||||||
<input type="hidden" name="language" id="languageInput" value="all" />
|
<input type="hidden" name="language" id="languageInput" value="all" />
|
||||||
<input type="search" name="q" id="searchInput" class="form-control search-input" placeholder="Search cards" />
|
<input type="search" name="q" class="form-control form-control-lg" placeholder="Search cards..." />
|
||||||
<button
|
<button type="submit" class="btn btn-danger btn-lg border border-danger-subtle border-start-0" aria-label="search" value="" onclick="const q = this.closest('form').querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });">
|
||||||
type="submit"
|
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M432 272C432 183.6 360.4 112 272 112C183.6 112 112 183.6 112 272C112 360.4 183.6 432 272 432C360.4 432 432 360.4 432 272zM401.1 435.1C365.7 463.2 320.8 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272C480 320.8 463.2 365.7 435.1 401.1L569 535C578.4 544.4 578.4 558.1 569 567.5C559.6 575.9 544.4 575.9 536 567.5L401.1 435.1z"/></svg>
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Show>
|
|
||||||
@@ -21,13 +21,6 @@ export const relations = defineRelations(schema, (r) => ({
|
|||||||
}),
|
}),
|
||||||
history: r.many.priceHistory(),
|
history: r.many.priceHistory(),
|
||||||
latestSales: r.many.salesHistory(),
|
latestSales: r.many.salesHistory(),
|
||||||
inventories: r.many.inventory(),
|
|
||||||
},
|
|
||||||
inventory: {
|
|
||||||
sku: r.one.skus({
|
|
||||||
from: r.inventory.skuId,
|
|
||||||
to: r.skus.skuId,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
cards: {
|
cards: {
|
||||||
prices: r.many.skus(),
|
prices: r.many.skus(),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//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";
|
import { integer, varchar, boolean, decimal, timestamp, index, pgSchema, uniqueIndex, primaryKey } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const pokeSchema = pgSchema("pokemon");
|
export const pokeSchema = pgSchema("pokemon");
|
||||||
|
|
||||||
@@ -98,7 +98,6 @@ export const skus = pokeSchema.table('skus', {
|
|||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index('idx_product_id_condition').on(table.productId, table.variant, table.condition),
|
index('idx_product_id_condition').on(table.productId, table.variant, table.condition),
|
||||||
index('idx_card_id_condition').on(table.cardId, table.condition),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const priceHistory = pokeSchema.table('price_history', {
|
export const priceHistory = pokeSchema.table('price_history', {
|
||||||
@@ -125,20 +124,6 @@ export const salesHistory = pokeSchema.table('sales_history',{
|
|||||||
primaryKey({ name: 'pk_sales_history', columns: [table.skuId, table.orderDate] })
|
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', {
|
export const processingSkus = pokeSchema.table('processing_skus', {
|
||||||
skuId: integer().primaryKey(),
|
skuId: integer().primaryKey(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
---
|
---
|
||||||
import '/src/assets/css/main.scss';
|
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;
|
const { title } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,18 +25,18 @@ const { title } = Astro.props;
|
|||||||
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-PPQMZ4PL"
|
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-PPQMZ4PL"
|
||||||
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
|
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
|
||||||
<!-- End Google Tag Manager (noscript) -->
|
<!-- End Google Tag Manager (noscript) -->
|
||||||
<NavBar slot="navbar">
|
<slot name="navbar"/>
|
||||||
<NavItems slot="navItems" />
|
|
||||||
<Search slot="searchInput" />
|
|
||||||
</NavBar>
|
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
<div class="container-fluid container-sm mt-4">
|
||||||
<slot name="page"/>
|
<slot name="page"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<slot name="footer"/>
|
<slot name="footer"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.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 src="../assets/js/main.js"></script>
|
||||||
|
|||||||
@@ -1,78 +1,17 @@
|
|||||||
import { clerkMiddleware, createRouteMatcher, clerkClient } from '@clerk/astro/server';
|
// src/middleware.ts
|
||||||
import type { MiddlewareNext } from 'astro';
|
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server';
|
||||||
import 'dotenv/config';
|
import type { AstroMiddlewareRequest, AstroMiddlewareResponse } from 'astro';
|
||||||
|
|
||||||
declare global {
|
const isProtectedRoute = createRouteMatcher([
|
||||||
namespace App {
|
'/pokemon',
|
||||||
interface Locals {
|
]);
|
||||||
canAddInventory: boolean;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isProtectedRoute = createRouteMatcher(['/pokemon']);
|
export const onRequest = clerkMiddleware((auth, context) => {
|
||||||
const isAdminRoute = createRouteMatcher(['/admin']);
|
const { isAuthenticated, redirectToSignIn } = auth()
|
||||||
|
|
||||||
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)) {
|
if (!isAuthenticated && isProtectedRoute(context.request)) {
|
||||||
return redirectToSignIn();
|
// Add custom logic to run before redirecting
|
||||||
|
|
||||||
|
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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
import Layout from '../layouts/Main.astro';
|
import Layout from '../layouts/Main.astro';
|
||||||
import Search from '../components/Search.astro';
|
import NavItems from '../components/NavItems.astro';
|
||||||
import NavBar from '../components/NavBar.astro';
|
import NavBar from '../components/NavBar.astro';
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
import pokedexList from '../data/pokedex.json';
|
import pokedexList from '../data/pokedex.json';
|
||||||
@@ -19,10 +19,12 @@ const pokemon = pokedexList.find(p => p["#"] === randomNumber);
|
|||||||
const pokemonName = pokemon?.Name || "Unknown Pokémon";
|
const pokemonName = pokemon?.Name || "Unknown Pokémon";
|
||||||
---
|
---
|
||||||
<Layout title="404 - Page Not Found">
|
<Layout title="404 - Page Not Found">
|
||||||
<div class="container-fluid container-sm mt-5" slot="page">
|
<NavBar slot="navbar">
|
||||||
<div class="row mb-4">
|
<NavItems slot="navItems" />
|
||||||
|
</NavBar>
|
||||||
|
<div class="row mb-4" slot="page">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<h1 class="mb-4">404 - Page Not Found</h1>
|
<h1 class="mb-4">404<br/>Page Not Found</h1>
|
||||||
<h4>Sorry, the page you are looking for does not exist.</h4>
|
<h4>Sorry, the page you are looking for does not exist.</h4>
|
||||||
<p class="copy-big my-4">
|
<p class="copy-big my-4">
|
||||||
Return to the <a href="/">home page</a> or search for another <a href="/pokemon">Pokémon</a>.
|
Return to the <a href="/">home page</a> or search for another <a href="/pokemon">Pokémon</a>.
|
||||||
@@ -65,7 +67,7 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
|
|||||||
>???</h3>
|
>???</h3>
|
||||||
<button
|
<button
|
||||||
id="play-again"
|
id="play-again"
|
||||||
class="btn btn-purple mt-3 opacity-0 pokemon-transition"
|
class="btn btn-primary mt-3 opacity-0 pokemon-transition"
|
||||||
style="pointer-events: none;"
|
style="pointer-events: none;"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
@@ -74,11 +76,36 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Footer slot="footer" />
|
<Footer slot="footer" />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pokemon-transition {
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-clickable:focus-visible {
|
||||||
|
outline: 3px solid #ffc107;
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pokemon-pulse {
|
||||||
|
0%, 100% { filter: brightness(0) drop-shadow(0 0 6px var(--bs-info-border-subtle)); }
|
||||||
|
50% { filter: brightness(0) drop-shadow(0 0 18px var(--bs-info)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.masked-image {
|
||||||
|
filter: brightness(0);
|
||||||
|
animation: pokemon-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const img = document.querySelector('.masked-image') as HTMLImageElement | null;
|
const img = document.querySelector('.masked-image') as HTMLImageElement | null;
|
||||||
const nameEl = document.querySelector('#pokemon-name');
|
const nameEl = document.querySelector('#pokemon-name');
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
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, '"')}">
|
|
||||||
<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);
|
|
||||||
};
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
// 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 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
---
|
---
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
import Layout from '../layouts/Main.astro';
|
import Layout from '../layouts/Main.astro';
|
||||||
|
import NavItems from '../components/NavItems.astro';
|
||||||
|
import NavBar from '../components/NavBar.astro';
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
---
|
---
|
||||||
<Layout title="Contact Us">
|
<Layout title="Contact Us">
|
||||||
<div class="container-fluid container-sm my-5" slot="page">
|
<NavBar slot="navbar">
|
||||||
<div class="row mb-4">
|
<NavItems slot="navItems" />
|
||||||
|
</NavBar>
|
||||||
|
<div class="row mb-4" slot="page">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<h1>Contact Us</h1>
|
<h1>Contact Us</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +42,7 @@ import Footer from '../components/Footer.astro';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit button -->
|
<!-- Submit button -->
|
||||||
<button type="submit" class="btn btn-purple" id="submitBtn">Submit</button>
|
<button type="submit" class="btn btn-light" id="submitBtn">Submit</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Hidden iframe absorbs the Google Forms redirect -->
|
<!-- Hidden iframe absorbs the Google Forms redirect -->
|
||||||
@@ -50,7 +54,6 @@ import Footer from '../components/Footer.astro';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Footer slot="footer" />
|
<Footer slot="footer" />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
|||||||
@@ -1,294 +0,0 @@
|
|||||||
---
|
|
||||||
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 & 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>
|
|
||||||
@@ -1,186 +1,49 @@
|
|||||||
---
|
---
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
import Layout from '../layouts/Main.astro';
|
import Layout from '../layouts/Main.astro';
|
||||||
import Search from '../components/Search.astro';
|
import NavItems from '../components/NavItems.astro';
|
||||||
import NavBar from '../components/NavBar.astro';
|
import NavBar from '../components/NavBar.astro';
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
import { Show, SignInButton } from '@clerk/astro/components'
|
import { Show, SignInButton, SignUpButton, SignOutButton } from '@clerk/astro/components'
|
||||||
import Hero from '../components/Hero.astro';
|
|
||||||
import BackToTop from '../components/BackToTop.astro';
|
|
||||||
import NavItems from '../components/NavItems.astro';
|
|
||||||
---
|
---
|
||||||
<Layout title="RAT - Realtime, Accurate and Transparent TCG Pricing Data" >
|
<Layout title="Rigid's App Thing">
|
||||||
<Hero slot="page" />
|
<NavBar slot="navbar">
|
||||||
<div slot="page">
|
<NavItems slot="navItems" />
|
||||||
<!-- ═══════════════════════════════════════════
|
</NavBar>
|
||||||
SOCIAL PROOF / STATS BAR
|
<div class="row mb-4" slot="page">
|
||||||
═══════════════════════════════════════════ -->
|
<div class="col-12">
|
||||||
<section class="stats-bar py-4 border-top border-bottom border-subtle" aria-label="Platform statistics">
|
<h1>Rigid's App Thing</h1>
|
||||||
<div class="container">
|
<p class="text-secondary">(working title)</p>
|
||||||
<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>
|
||||||
</section>
|
<div class="col-12 col-md-6 mb-2">
|
||||||
<!-- ═══════════════════════════════════════════
|
<h2 class="mt-3">Welcome!</h2>
|
||||||
CORE FEATURES
|
<p class="mt-2">
|
||||||
═══════════════════════════════════════════ -->
|
You've been selected to participate in the closed beta! This single page web application is meant to elevate condition/variant data for the Pokemon TCG. In future iterations, we will add vendor inventory/collection management features, as well.
|
||||||
<section class="py-6" aria-labelledby="features-heading">
|
</p>
|
||||||
<div class="container">
|
<p class="my-2">
|
||||||
<header class="text-center mb-5">
|
After the closed beta is complete, the app will move into a more open beta. 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!
|
||||||
<h2 id="features-heading" class="h1 fw-bold">Everything you need to collect smarter</h2>
|
</p>
|
||||||
<p class="text-body-secondary lead mt-2">Built by collectors, for collectors. No fluff.</p>
|
<Show when="signed-in">
|
||||||
</header>
|
<a href="/pokemon" class="btn btn-warning mt-2">Take me to the cards</a>
|
||||||
|
</Show>
|
||||||
<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>
|
</div>
|
||||||
</section>
|
<div class="col-12 col-md-6 d-flex justify-content-end align-items-start mt-3">
|
||||||
|
<div class="d-flex gap-3">
|
||||||
<!-- ═══════════════════════════════════════════
|
|
||||||
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">
|
<Show when="signed-out">
|
||||||
<SignInButton asChild mode="modal">
|
<SignInButton asChild mode="modal">
|
||||||
<button class="btn btn-purple btn-lg px-4">
|
<button class="btn btn-success">Sign In</button>
|
||||||
Join Now! — It's Free
|
</SignInButton>
|
||||||
</button>
|
<SignUpButton asChild mode="modal">
|
||||||
</SignInButton>
|
<button class="btn btn-dark">Request Access</button>
|
||||||
|
</SignUpButton>
|
||||||
|
</Show>
|
||||||
|
<Show when="signed-in">
|
||||||
|
<SignOutButton asChild>
|
||||||
|
<button class="btn btn-danger">Sign Out</button>
|
||||||
|
</SignOutButton>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Footer slot="footer" />
|
<Footer slot="footer" />
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
---
|
|
||||||
import Layout from '../layouts/Main.astro';
|
|
||||||
import NavItems from '../components/NavItems.astro';
|
|
||||||
import NavBar from '../components/NavBar.astro';
|
|
||||||
import Footer from '../components/Footer.astro';
|
|
||||||
|
|
||||||
---
|
|
||||||
<Layout title="Rigid's App Thing">
|
|
||||||
<NavBar slot="navbar">
|
|
||||||
<NavItems slot="navItems" />
|
|
||||||
</NavBar>
|
|
||||||
<div class="row mb-4" slot="page">
|
|
||||||
<div class="col-12">
|
|
||||||
<h1>Rigid's App Thing</h1>
|
|
||||||
<p class="text-secondary">(working title)</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<!-- src/components/FileUploader.astro -->
|
|
||||||
<form action="/api/upload" method="post" enctype="multipart/form-data">
|
|
||||||
<input type="file" name="file" accept=".csv" required />
|
|
||||||
<button type="submit">Upload CSV</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Footer slot="footer" />
|
|
||||||
</Layout>
|
|
||||||
@@ -10,9 +10,6 @@ import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
|
|||||||
|
|
||||||
import { Tooltip } from "bootstrap";
|
import { Tooltip } from "bootstrap";
|
||||||
|
|
||||||
// auth check for inventory management features
|
|
||||||
const { canAddInventory } = Astro.locals;
|
|
||||||
|
|
||||||
export const partial = true;
|
export const partial = true;
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
@@ -49,7 +46,7 @@ const calculatedAt = (() => {
|
|||||||
const dates = card.prices
|
const dates = card.prices
|
||||||
.map(p => p.calculatedAt)
|
.map(p => p.calculatedAt)
|
||||||
.filter(d => d)
|
.filter(d => d)
|
||||||
.map(d => new Date(d!));
|
.map(d => new Date(d));
|
||||||
if (!dates.length) return null;
|
if (!dates.length) return null;
|
||||||
return new Date(Math.max(...dates.map(d => d.getTime())));
|
return new Date(Math.max(...dates.map(d => d.getTime())));
|
||||||
})();
|
})();
|
||||||
@@ -164,17 +161,6 @@ const conditionAttributes = (price: any) => {
|
|||||||
}[condition];
|
}[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) => {
|
const ebaySearchUrl = (card: any) => {
|
||||||
return `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&LH_Sold=1&Graded=No&_dcat=183454`;
|
return `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&LH_Sold=1&Graded=No&_dcat=183454`;
|
||||||
};
|
};
|
||||||
@@ -182,8 +168,8 @@ const ebaySearchUrl = (card: any) => {
|
|||||||
const altSearchUrl = (card: any) => {
|
const altSearchUrl = (card: any) => {
|
||||||
return `https://alt.xyz/browse?query=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&sortBy=newest_first`;
|
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-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
|
||||||
<div class="modal-content" data-card-id={card?.cardId}>
|
<div class="modal-content" data-card-id={card?.cardId}>
|
||||||
<div class="modal-header border-0">
|
<div class="modal-header border-0">
|
||||||
@@ -215,13 +201,15 @@ const altSearchUrl = (card: any) => {
|
|||||||
data-name={card?.productName}
|
data-name={card?.productName}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`/static/cards/${card?.productId}.jpg`}
|
src={`/cards/${card?.productId}.jpg`}
|
||||||
class="card-image w-100 img-fluid rounded-4"
|
class="card-image w-100 img-fluid rounded-4"
|
||||||
alt={card?.productName}
|
alt={card?.productName}
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow, .card-image-wrap')?.setAttribute('data-default','true')"
|
onerror="this.onerror=null; this.src='/cards/default.jpg'; this.closest('.image-grow, .card-image-wrap')?.setAttribute('data-default','true')"
|
||||||
onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});"
|
onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});"
|
||||||
/>
|
/>
|
||||||
|
<div class="holo-shine"></div>
|
||||||
|
<div class="holo-glare"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="position-absolute top-50 start-0 d-inline"><FirstEditionIcon edition={card?.variant} /></span>
|
<span class="position-absolute top-50 start-0 d-inline"><FirstEditionIcon edition={card?.variant} /></span>
|
||||||
@@ -230,8 +218,8 @@ const altSearchUrl = (card: any) => {
|
|||||||
<span class="rarity-icon-large position-absolute bottom-0 end-0 d-inline"><RarityIcon rarity={card?.rarityName} /></span>
|
<span class="rarity-icon-large position-absolute bottom-0 end-0 d-inline"><RarityIcon rarity={card?.rarityName} /></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-column flex-lg-row justify-content-between mt-2">
|
<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">{card?.set?.setCode}</div>
|
||||||
<div class="text-secondary">Illus<span class="d-none d-xxl-inline">trator</span>: {card?.artist}</div>
|
<div class="text-secondary">Illus<span class="d-none d-lg-inline">trator</span>: {card?.artist}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -240,36 +228,34 @@ const altSearchUrl = (card: any) => {
|
|||||||
<ul class="nav nav-tabs nav-fill border-0 me-3 mb-2" id="myTab" role="tablist">
|
<ul class="nav nav-tabs nav-fill border-0 me-3 mb-2" id="myTab" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<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">
|
<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>
|
<span class="d-none d-xxl-inline">Near Mint</span><span class="d-xxl-none">NM</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<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">
|
<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>
|
<span class="d-none d-xxl-inline">Lightly Played</span><span class="d-xxl-none">LP</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<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">
|
<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>
|
<span class="d-none d-xxl-inline">Moderately Played</span><span class="d-xxl-none">MP</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<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">
|
<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>
|
<span class="d-none d-xxl-inline">Heavily Played</span><span class="d-xxl-none">HP</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<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">
|
<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>
|
<span class="d-none d-xxl-inline">Damaged</span><span class="d-xxl-none">DMG</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link vendor d-none" id="vendor-tab" data-bs-toggle="tab" data-bs-target="#nav-vendor" type="button" role="tab" aria-controls="nav-vendor" aria-selected="false">
|
||||||
|
<span class="d-none d-xxl-inline">Inventory</span><span class="d-xxl-none">+/-</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{canAddInventory && (
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link vendor" id="vendor-tab" data-bs-toggle="tab" data-bs-target="#nav-vendor" type="button" role="tab" aria-controls="nav-vendor" aria-selected="false">
|
|
||||||
<span class="d-none d-xxl-inline">Inventory</span><span class="d-xxl-none">+/-</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content" id="myTabContent">
|
<div class="tab-content" id="myTabContent">
|
||||||
@@ -280,7 +266,7 @@ const altSearchUrl = (card: any) => {
|
|||||||
<div class="d-flex flex-column gap-1">
|
<div class="d-flex flex-column gap-1">
|
||||||
|
|
||||||
<!-- Stat cards -->
|
<!-- Stat cards -->
|
||||||
<div class="d-flex flex-fill flex-row gap-1 flex-wrap flex-lg-nowrap">
|
<div class="d-flex flex-fill flex-row gap-1">
|
||||||
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
|
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
|
||||||
<h6 class="mb-auto">Market Price</h6>
|
<h6 class="mb-auto">Market Price</h6>
|
||||||
<p class="mb-0 mt-1">${price.marketPrice}</p>
|
<p class="mb-0 mt-1">${price.marketPrice}</p>
|
||||||
@@ -326,7 +312,7 @@ const altSearchUrl = (card: any) => {
|
|||||||
|
|
||||||
<!-- Table only — chart is outside the tab panes -->
|
<!-- Table only — chart is outside the tab panes -->
|
||||||
<div class="w-100">
|
<div class="w-100">
|
||||||
<div class="alert alert-dark rounded p-2 mb-0 table-responsive d-none">
|
<div class="alert alert-dark rounded p-2 mb-0 table-responsive">
|
||||||
<h6>Latest Verified Sales</h6>
|
<h6>Latest Verified Sales</h6>
|
||||||
<table class="table table-sm mb-0">
|
<table class="table table-sm mb-0">
|
||||||
<caption class="small">Filtered to remove mismatched language variants</caption>
|
<caption class="small">Filtered to remove mismatched language variants</caption>
|
||||||
@@ -352,204 +338,12 @@ const altSearchUrl = (card: any) => {
|
|||||||
</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="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0"></div>
|
||||||
<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 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>
|
|
||||||
</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>
|
||||||
|
|
||||||
<!-- Chart lives permanently outside tab-content so Bootstrap never hides it. -->
|
<!-- 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="d-block d-lg-flex gap-1 mt-1">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="alert alert-dark rounded p-2 mb-0">
|
<div class="alert alert-dark rounded p-2 mb-0">
|
||||||
<h6>Market Price History</h6>
|
<h6>Market Price History</h6>
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ export const prerender = false;
|
|||||||
|
|
||||||
import * as util from 'util';
|
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
|
// all the facet fields we want to use for filtering
|
||||||
const facetFields:any = {
|
const facetFields:any = {
|
||||||
@@ -92,7 +90,7 @@ const facetFilter = (facet:string) => {
|
|||||||
// primary search values (for cards)
|
// primary search values (for cards)
|
||||||
let searchArray = [{
|
let searchArray = [{
|
||||||
collection: 'cards',
|
collection: 'cards',
|
||||||
filter_by: `$skus(id:*) && sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${filterBy ? ` && ${filterBy}` : ''}`,
|
filter_by: `sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${filterBy ? ` && ${filterBy}` : ''}`,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
facet_by: '',
|
facet_by: '',
|
||||||
max_facet_values: 0,
|
max_facet_values: 0,
|
||||||
@@ -133,7 +131,6 @@ 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 "–"
|
// format price to 2 decimal places (or 0 if price >=100) and adds a $ sign, if the price is null it returns "–"
|
||||||
const formatPrice = (condition:string, skus: any) => {
|
const formatPrice = (condition:string, skus: any) => {
|
||||||
if (typeof skus === 'undefined' || skus.length === 0) return '—';
|
|
||||||
const sku:any = skus.find((price:any) => price.condition === condition);
|
const sku:any = skus.find((price:any) => price.condition === condition);
|
||||||
if (typeof sku === 'undefined' || typeof sku.marketPrice === 'undefined') return '—';
|
if (typeof sku === 'undefined' || typeof sku.marketPrice === 'undefined') return '—';
|
||||||
|
|
||||||
@@ -182,7 +179,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="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>
|
<button type="submit" form="searchform" data-bs-dismiss="offcanvas" class="btn btn-success">Apply Filters</button>
|
||||||
</div>
|
</div>
|
||||||
{facets.map((facet: any) => (
|
{facets.map((facet) => (
|
||||||
<div class="mt-2 mb-4 facet-group row align-items-center justify-content-between">
|
<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>
|
<div class="fs-5 m-0 col-auto pb-1 border-bottom border-light-subtle">{facetNames(facet.field_name)}</div>
|
||||||
{(facet.counts.length > 20) &&
|
{(facet.counts.length > 20) &&
|
||||||
@@ -203,7 +200,7 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
|||||||
</div>
|
</div>
|
||||||
<div id="sortBy" class="d-flex flex-fill align-items-center me-auto gap-2" 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">
|
<div class="dropdown">
|
||||||
<button class="btn btn-sm btn-dark dropdown-toggle" data-toggle="sort-dropdown" aria-expanded="false">Sort by</button>
|
<button class="btn btn-sm btn-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Sort by</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-dark">
|
<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: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="releaseDate:asc,number:asc" data-label="Set: Oldest to Newest">Set: Oldest to Newest</a></li>
|
||||||
@@ -215,9 +212,9 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
|||||||
</div>
|
</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>
|
<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">
|
<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-dark 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-dark 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>
|
<button type="button" class={`btn btn-dark language-btn${language === 'jp' ? ' active' : ''}`} data-lang="jp">JP</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="totalResults" class="d-flex text-secondary small d-none align-items-center" hx-swap-oob="true">
|
<div id="totalResults" class="d-flex text-secondary small d-none align-items-center" hx-swap-oob="true">
|
||||||
@@ -237,6 +234,7 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<script define:vars={{ totalHits, filters, facets }} is:inline>
|
<script define:vars={{ totalHits, filters, facets }} is:inline>
|
||||||
|
|
||||||
// Filter the facet values to make things like Set easier to find
|
// Filter the facet values to make things like Set easier to find
|
||||||
const facetfilters = document.querySelectorAll('.facet-filter');
|
const facetfilters = document.querySelectorAll('.facet-filter');
|
||||||
for (const facetfilter of facetfilters) {
|
for (const facetfilter of facetfilters) {
|
||||||
@@ -262,8 +260,7 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
|||||||
document.getElementById('searchform').dispatchEvent(new Event('submit', {bubbles:true, cancelable:true}));
|
document.getElementById('searchform').dispatchEvent(new Event('submit', {bubbles:true, cancelable:true}));
|
||||||
}
|
}
|
||||||
document.getElementById('clear-filters').addEventListener('click', (e) => clearAllFilters(e));
|
document.getElementById('clear-filters').addEventListener('click', (e) => clearAllFilters(e));
|
||||||
const clearAllBtn = document.getElementById('clear-all-filters');
|
document.getElementById('clear-all-filters').addEventListener('click', (e) => clearAllFilters(e));
|
||||||
if (clearAllBtn) clearAllBtn.addEventListener('click', (e) => clearAllFilters(e));
|
|
||||||
|
|
||||||
// Remove single facet value
|
// Remove single facet value
|
||||||
for (const li of document.querySelectorAll('.remove-filter')) {
|
for (const li of document.querySelectorAll('.remove-filter')) {
|
||||||
@@ -286,13 +283,11 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
|||||||
|
|
||||||
{pokemon.map((card:any) => (
|
{pokemon.map((card:any) => (
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{canAddInventory && (
|
<div class="inventory-button position-relative float-end shadow-filter text-center d-none">
|
||||||
<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');">
|
<div class="inventory-label pt-2">+/-</div>
|
||||||
+/–
|
</div>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div class="card-trigger position-relative" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});">
|
<div class="card-trigger position-relative" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});">
|
||||||
<div class="image-grow rounded-4 card-image" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}><img src={`/static/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 w-100" onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow')?.setAttribute('data-default','true')"/><span class="position-absolute top-50 start-0 d-inline medium-icon"><FirstEditionIcon edition={card?.variant} /></span>
|
<div class="image-grow rounded-4 card-image" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}><img src={`/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='/cards/default.jpg'; this.closest('.image-grow')?.setAttribute('data-default','true')"/><span class="position-absolute top-50 start-0 d-inline medium-icon"><FirstEditionIcon edition={card?.variant} /></span>
|
||||||
<div class="holo-shine"></div>
|
<div class="holo-shine"></div>
|
||||||
<div class="holo-glare"></div>
|
<div class="holo-glare"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,13 +300,13 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div class="fs-5 fw-semibold my-0">{card.productName}</div>
|
<div class="h5 my-0">{card.productName}</div>
|
||||||
<div class="d-flex flex-row lh-1 mt-1 justify-content-between">
|
<div class="d-flex flex-row lh-1 mt-1 justify-content-between">
|
||||||
<div class="text-body-tertiary flex-grow-1 d-none d-lg-flex fst-normal">{card.setName}</div>
|
<div class="text-secondary flex-grow-1 d-none d-lg-flex">{card.setName}</div>
|
||||||
<div class="text-body-tertiary fst-normal">{card.number}</div>
|
<div class="text-body-tertiary">{card.number}</div>
|
||||||
<span class="ps-2 small-icon"><RarityIcon rarity={card.rarityName} /></span>
|
<span class="ps-2 small-icon"><RarityIcon rarity={card.rarityName} /></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-secondary fst-italic">{card.variant}</div><span class="d-none">{card.productId}</span>
|
<div class="text-body-tertiary">{card.variant}</div><span class="d-none">{card.productId}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
})
|
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
---
|
---
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
import Layout from '../layouts/Main.astro';
|
import Layout from '../layouts/Main.astro';
|
||||||
|
import Search from '../components/Search.astro';
|
||||||
import CardGrid from "../components/CardGrid.astro";
|
import CardGrid from "../components/CardGrid.astro";
|
||||||
|
import NavBar from '../components/NavBar.astro';
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Card Search">
|
<Layout title="Card Search">
|
||||||
<CardGrid slot="page"/>
|
<NavBar slot="navbar">
|
||||||
|
<Search slot="searchInput" />
|
||||||
|
</NavBar>
|
||||||
|
<CardGrid slot="page" />
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strict",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"include": [".astro/types.d.ts", "src/**/*"],
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
"exclude": ["dist"]
|
"exclude": ["dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user