11 Commits

13 changed files with 1667 additions and 1643 deletions

1917
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,12 +9,12 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^9.5.4",
"@astrojs/node": "^10.1.4",
"@clerk/astro": "^3.0.1",
"@clerk/shared": "^4.0.0",
"@clerk/themes": "^2.4.55",
"@popperjs/core": "^2.11.8",
"astro": "^5.17.1",
"astro": "^6.4.8",
"bootstrap": "^5.3.8",
"chalk": "^5.6.2",
"chart.js": "^4.5.1",

View File

@@ -886,6 +886,16 @@ footer .logo-svg > svg { width: var(--logo-width, 8rem); }
-------------------------------------------------- */
#gridView { row-gap: 1.5rem; }
@media (min-width: 1200px) {
.catalog-sidebar {
position: sticky;
top: var(--navbar-height, 4rem);
height: calc(100vh - var(--navbar-height, 4rem));
overflow-y: auto;
z-index: 1;
}
}
.inv-grid-card {
position: relative;
height: 100%;

View File

@@ -1,4 +1,8 @@
---
// auth check for inventory management features
const { canAddInventory } = Astro.locals;
const { isAdmin } = Astro.locals;
// const canAddInventory = false;
---
<button
class="navbar-toggler ms-4 p-1 btn btn-purple border-0"
@@ -30,9 +34,16 @@
<li class="nav-item">
<a class="nav-link py-3 border-bottom border-secondary" href="/pokemon">Browse Cards</a>
</li>
<!--<li class="nav-item">
<a class="nav-link py-3" href="/dashboard">Dashboard</a>
</li> -->
{canAddInventory && (
<li class="nav-item">
<a class="nav-link py-3 border-bottom border-secondary" href="/dashboard">Dashboard</a>
</li>
)}
{isAdmin && (
<li class="nav-item">
<a class="nav-link py-3" href="/admin">Admin Panel</a>
</li>
)}
</ul>
</div>
</div>

View File

@@ -25,47 +25,77 @@ import { Show } from '@clerk/astro/components'
</script>
<Show when="signed-in">
<form
class="d-flex align-items-center"
role="search"
id="searchform"
hx-post="/partials/cards"
hx-target="#cardGrid"
hx-trigger="load, submit"
hx-vals='{"start":"0"}'
hx-on--after-request="afterUpdate()"
hx-on--before-request="beforeSearch()"
>
{Astro.url.pathname === '/dashboard' ? (
<div class="input-group">
{Astro.url.pathname === '/pokemon' && (
<a class="btn btn-purple" data-bs-toggle="offcanvas" href="#filterBar" type="button" role="button" aria-controls="filterBar" aria-label="filter">
<span class="d-block d-md-none filter-icon py-2">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M528.8 96.3C558.6 90.8 571.2 118.9 568.9 142.2C572.3 173.4 570.8 207 553.9 230.8C513.9 283.2 459.3 315.9 414.3 364.3C414.9 418.3 419.8 459.8 423.6 511.2C427.6 552.4 388.7 586.8 346.6 570.1C303.2 550.5 259.4 527.5 230.4 493.3C217 453.1 225.9 407.5 222.2 365.3C222.2 365.3 222.1 365.1 222 365C151.4 319.6 59.3 250.9 61 158.4C59.9 121 91.8 96.1 123.8 96.5C259.3 98.5 394.1 104.4 528.8 96.3zM506.1 161.4C378.3 168.2 252 162.1 125.2 160.5C128.6 227 199 270.8 250 306.8C305.5 335.4 281.6 410.5 288.3 461.7C310.8 478.9 334.6 494.6 358.9 505.8C355.4 458 350.7 415.4 350.2 364.6C349.9 349.2 355.3 333.7 366.5 321.7C384.3 302.6 402.8 287.8 421.5 270.1C446.1 245.2 477.9 225.1 499.7 196.7C509 182.2 504.7 174.5 506 161.5z"/></svg>
</span>
<span class="d-none d-md-block fw-medium">Filters</span>
</a>
)}
<input type="hidden" name="start" id="start" value="0" />
<input type="hidden" name="sort" id="sortInput" value="" />
<input type="hidden" name="language" id="languageInput" value="all" />
<input type="search" name="q" id="searchInput" class="form-control search-input" placeholder="Search cards" />
<input
type="search"
name="q"
id="inventorySearchInput"
class="form-control search-input"
placeholder="Search your inventory"
hx-post="/partials/inventory-cards"
hx-trigger="keyup[key=='Enter']"
hx-target="#gridView"
hx-swap="innerHTML"
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
/>
<button
type="submit"
type="button"
id="inventorySearchBtn"
class="btn btn-purple border-start-0"
aria-label="search"
onclick="
const q = this.closest('form').querySelector('[name=q]').value;
dataLayer.push({ event: 'view_search_results', search_term: q });
if (window.location.pathname !== '/pokemon') {
event.preventDefault();
event.stopPropagation();
sessionStorage.setItem('pendingSearch', q);
window.location.href = '/pokemon';
}
"
hx-post="/partials/inventory-cards"
hx-include="#inventorySearchInput"
hx-target="#gridView"
hx-swap="innerHTML"
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
>
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
</button>
</div>
</form>
) : (
<form
class="d-flex align-items-center"
role="search"
id="searchform"
hx-post="/partials/cards"
hx-target="#cardGrid"
hx-trigger="load, submit"
hx-vals='{"start":"0"}'
hx-on--after-request="afterUpdate()"
hx-on--before-request="beforeSearch()"
>
<div class="input-group">
{Astro.url.pathname === '/pokemon' && (
<a class="btn btn-purple" data-bs-toggle="offcanvas" href="#filterBar" type="button" role="button" aria-controls="filterBar" aria-label="filter">
<span class="d-block d-md-none filter-icon py-2">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M528.8 96.3C558.6 90.8 571.2 118.9 568.9 142.2C572.3 173.4 570.8 207 553.9 230.8C513.9 283.2 459.3 315.9 414.3 364.3C414.9 418.3 419.8 459.8 423.6 511.2C427.6 552.4 388.7 586.8 346.6 570.1C303.2 550.5 259.4 527.5 230.4 493.3C217 453.1 225.9 407.5 222.2 365.3C222.2 365.3 222.1 365.1 222 365C151.4 319.6 59.3 250.9 61 158.4C59.9 121 91.8 96.1 123.8 96.5C259.3 98.5 394.1 104.4 528.8 96.3zM506.1 161.4C378.3 168.2 252 162.1 125.2 160.5C128.6 227 199 270.8 250 306.8C305.5 335.4 281.6 410.5 288.3 461.7C310.8 478.9 334.6 494.6 358.9 505.8C355.4 458 350.7 415.4 350.2 364.6C349.9 349.2 355.3 333.7 366.5 321.7C384.3 302.6 402.8 287.8 421.5 270.1C446.1 245.2 477.9 225.1 499.7 196.7C509 182.2 504.7 174.5 506 161.5z"/></svg>
</span>
<span class="d-none d-md-block fw-medium">Filters</span>
</a>
)}
<input type="hidden" name="start" id="start" value="0" />
<input type="hidden" name="sort" id="sortInput" value="" />
<input type="hidden" name="language" id="languageInput" value="all" />
<input type="search" name="q" id="searchInput" class="form-control search-input" placeholder="Search cards" />
<button
type="submit"
class="btn btn-purple border-start-0"
aria-label="search"
onclick="
const q = this.closest('form').querySelector('[name=q]').value;
dataLayer.push({ event: 'view_search_results', search_term: q });
if (window.location.pathname !== '/pokemon') {
event.preventDefault();
event.stopPropagation();
sessionStorage.setItem('pendingSearch', q);
window.location.href = '/pokemon';
}
"
>
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
</button>
</div>
</form>
)}
</Show>

View File

@@ -137,7 +137,8 @@ export const inventory = pokeSchema.table('inventory',{
createdAt: timestamp().notNull().defaultNow(),
},
(table) => [
index('idx_userid_skuId').on(table.userId, table.skuId)
index('idx_userid_skuId').on(table.userId, table.skuId),
index('idx_userid_catalogName').on(table.userId, table.catalogName)
]);
export const processingSkus = pokeSchema.table('processing_skus', {

View File

@@ -5,6 +5,7 @@ declare global {
namespace App {
interface Locals {
canAddInventory: boolean;
isAdmin: boolean;
}
}
}
@@ -26,53 +27,31 @@ export const onRequest = clerkMiddleware(async (auth, context, next) => {
}
// ── Inventory visibility check ──────────────────────────────────────────────
// Resolves to true if the user belongs to the target org OR has the feature
const canAddInventory = process.env.INVENTORY_ACCESS === 'true' ||
(
isAuthenticated &&
userId &&
(
!!has({ permission: "org:feature:inventory_add" }) || // Clerk feature flag
!!has({ permission: "org:feature:inventory_add" }) ||
(await getUserOrgIds(context, userId)).includes(TARGET_ORG_ID)
)
);
// Expose the flag to your Astro pages via locals
context.locals.canAddInventory = Boolean(canAddInventory);
// ── Admin check ──────────────────────────────────────────────────────────
// Computed on every request (not just /admin routes) so pages can use
// Astro.locals.isAdmin to conditionally show admin-only UI anywhere.
context.locals.isAdmin = isAuthenticated && userId
? await checkIsAdmin(context, userId)
: false;
// ── Admin route guard ───────────────────────────────────────────
if (isAdminRoute(context.request)) {
if (!isAuthenticated || !userId) {
return redirectToSignIn();
}
try {
const client = await clerkClient(context);
const userOrgIds = await getUserOrgIds(context, userId);
const matchingOrgIds = userOrgIds.filter((id) => ADMIN_ORG_IDS.has(id));
if (matchingOrgIds.length === 0) {
return new Response(null, { status: 404 });
}
const membershipLists = await Promise.all(
matchingOrgIds.map((orgId) =>
client.organizations.getOrganizationMembershipList({ organizationId: orgId })
)
);
const isAdmin = membershipLists.some((list) =>
list.data.some(
(m) => m.publicUserData?.userId === userId && m.role === "org:admin"
)
);
if (!isAdmin) {
return new Response(null, { status: 404 });
}
} catch (e) {
console.error("Clerk membership check failed:", e);
return context.redirect("/");
if (!context.locals.isAdmin) {
return new Response(null, { status: 404 });
}
}
@@ -89,4 +68,29 @@ async function getUserOrgIds(context: any, userId: string): Promise<string[]> {
console.error("Failed to fetch user org memberships:", e);
return [];
}
}
// ── Helper: is this user an org:admin in any of ADMIN_ORG_IDS? ─────────────
async function checkIsAdmin(context: any, userId: string): Promise<boolean> {
try {
const userOrgIds = await getUserOrgIds(context, userId);
const matchingOrgIds = userOrgIds.filter((id) => ADMIN_ORG_IDS.has(id));
if (matchingOrgIds.length === 0) return false;
const client = await clerkClient(context);
const membershipLists = await Promise.all(
matchingOrgIds.map((orgId) =>
client.organizations.getOrganizationMembershipList({ organizationId: orgId })
)
);
return membershipLists.some((list) =>
list.data.some(
(m) => m.publicUserData?.userId === userId && m.role === "org:admin"
)
);
} catch (e) {
console.error("Clerk membership check failed:", e);
return false;
}
}

View File

@@ -1,4 +1,5 @@
import type { APIRoute } from 'astro';
import { parse } from 'csv';
import { db } from '../../db/index';
import { inventory, priceHistory } from '../../db/schema';
import { client } from '../../db/typesense';
@@ -89,16 +90,42 @@ const getInventory = async (userId: string, cardId: number) => {
}
// Build the Typesense document for an inventory row + its card details.
const inventoryTsDoc = (i: typeof inventory.$inferSelect, card: any) => ({
id: i.inventoryId,
userId: i.userId,
catalogName: i.catalogName,
sku_id: i.skuId.toString(),
purchasePrice: DollarToInt(i.purchasePrice),
productLineName: card?.productLineName,
rarityName: card?.rarityName,
setName: card?.set?.setName || "",
cardType: card?.cardType || "",
energyType: card?.energyType || "",
card_id: card?.cardId.toString() || "",
content: [
card?.productName,
card?.productLineName,
card?.set?.setName || "",
card?.number,
card?.rarityName,
card?.artist || ""
].join(' '),
});
const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
// First add to database
const inv = await db.insert(inventory).values({
userId: userId,
skuId: skuId,
catalogName: catalogName,
purchasePrice: purchasePrice.toFixed(2),
quantity: quantity,
note: note,
}).returning();
// Insert one row per copy: a quantity of 5 becomes 5 separate entries of qty 1.
const count = Math.max(1, Math.floor(quantity));
const inv = await db.insert(inventory).values(
Array.from({ length: count }, () => ({
userId: userId,
skuId: skuId,
catalogName: catalogName,
purchasePrice: purchasePrice.toFixed(2),
quantity: 1,
note: note,
}))
).returning();
// Get card details from the database to add to Typesense
const card = await db.query.cards.findFirst({
where: { cardId: cardId },
@@ -107,27 +134,7 @@ const addToInventory = async (userId: string, cardId: number, skuId: number, pur
try {
// And then add to Typesense for searching
await client.collections('inventories').documents().import(inv.map(i => ({
id: i.inventoryId,
userId: i.userId,
catalogName: i.catalogName,
sku_id: i.skuId.toString(),
purchasePrice: DollarToInt(i.purchasePrice),
productLineName: card?.productLineName,
rarityName: card?.rarityName,
setName: card?.set?.setName || "",
cardType: card?.cardType || "",
energyType: card?.energyType || "",
card_id: card?.cardId.toString() || "",
content: [
card?.productName,
card?.productLineName,
card?.set?.setName || "",
card?.number,
card?.rarityName,
card?.artist || ""
].join(' '),
})));
await client.collections('inventories').documents().import(inv.map(i => inventoryTsDoc(i, card)));
} catch (error) {
console.error('Error adding inventory to Typesense:', error);
}
@@ -138,16 +145,139 @@ const removeFromInventory = async (inventoryId: string) => {
await client.collections('inventories').documents(inventoryId).delete();
}
const updateInventory = async (inventoryId: string, quantity: number, purchasePrice: number, note: string) => {
const updateInventory = async (inventoryId: string, quantity: number, purchasePrice: number, note: string, catalogName: string) => {
// Update the database
await db.update(inventory).set({
quantity: quantity,
purchasePrice: purchasePrice.toFixed(2),
note: note,
catalogName: catalogName,
}).where(eq(inventory.inventoryId, inventoryId));
// No need to update Typesense since we don't search by quantity or price
// Quantity/price/note aren't searched, but catalogName is faceted in Typesense.
try {
await client.collections('inventories').documents(inventoryId).update({ catalogName });
} catch (error) {
console.error('Error updating inventory catalog in Typesense:', error);
}
}
// ── Bulk CSV import ─────────────────────────────────────────────────────────
// Expected columns (header row, case-insensitive): name, set, condition, qty,
// price, market. An optional `date` column is used as the inventory createdAt;
// rows without a (valid) date default to the current time.
const CONDITION_MAP: Record<string, string> = {
'nm': 'Near Mint', 'near mint': 'Near Mint', 'mint': 'Near Mint',
'lp': 'Lightly Played', 'lightly played': 'Lightly Played',
'mp': 'Moderately Played', 'moderately played': 'Moderately Played',
'hp': 'Heavily Played', 'heavily played': 'Heavily Played',
'dmg': 'Damaged', 'dm': 'Damaged', 'damaged': 'Damaged',
};
const normalizeCondition = (value?: string) => {
const key = (value || '').trim().toLowerCase();
return CONDITION_MAP[key] || 'Near Mint';
};
// Parse an optional date cell into a Date, or null when empty/invalid.
const parseImportDate = (value?: string): Date | null => {
const raw = (value || '').trim();
if (!raw) return null;
const date = new Date(raw);
return isNaN(date.getTime()) ? null : date;
};
const parseCsv = (text: string): Promise<Record<string, string>[]> =>
new Promise((resolve, reject) => {
parse(text, { columns: true, trim: true, skip_empty_lines: true, bom: true },
(err, records) => (err ? reject(err) : resolve(records)));
});
// Case-insensitive column lookup for a parsed CSV row.
const lowerKeys = (row: Record<string, string>) => {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(row)) out[k.trim().toLowerCase()] = v;
return out;
};
const findCardId = async (name: string, set: string): Promise<number | undefined> => {
const escapedSet = set.replace(/`/g, '');
let result = await client.collections('cards').documents().search({
q: name, query_by: 'productName', per_page: 1,
...(escapedSet ? { filter_by: `setName:\`${escapedSet}\`` } : {}),
});
// Fall back to a name-only match if the set filter found nothing.
if (!result.hits?.length && escapedSet) {
result = await client.collections('cards').documents().search({
q: name, query_by: 'productName', per_page: 1,
});
}
return result.hits?.[0]?.document?.cardId as number | undefined;
};
const importInventory = async (userId: string, rows: Record<string, string>[], catalogName: string) => {
const skipped: { row: number; name: string; reason: string }[] = [];
const tsDocs: ReturnType<typeof inventoryTsDoc>[] = [];
let imported = 0;
for (let i = 0; i < rows.length; i++) {
const rowNumber = i + 2; // account for header row (1-indexed for humans)
const row = lowerKeys(rows[i]);
const name = (row.name || '').trim();
const set = (row.set || '').trim();
if (!name) { skipped.push({ row: rowNumber, name, reason: 'Missing card name' }); continue; }
const createdAt = parseImportDate(row.date);
if ((row.date || '').trim() && !createdAt) {
skipped.push({ row: rowNumber, name, reason: `Invalid date "${row.date.trim()}"` });
continue;
}
const condition = normalizeCondition(row.condition);
const count = Math.max(1, parseInt(row.qty, 10) || 1);
const purchasePrice = parseFloat(String(row.price ?? '').replace(/[^0-9.]/g, '')) || 0;
const cardId = await findCardId(name, set);
if (!cardId) { skipped.push({ row: rowNumber, name, reason: 'Card not found' }); continue; }
const sku = await db.query.skus.findFirst({
where: { cardId: cardId, condition: condition },
columns: { skuId: true },
});
if (!sku?.skuId) { skipped.push({ row: rowNumber, name, reason: `No "${condition}" SKU for card` }); continue; }
// One row per copy: a qty of 5 becomes 5 separate entries of qty 1.
const inserted = await db.insert(inventory).values(
Array.from({ length: count }, () => ({
userId,
skuId: sku.skuId,
catalogName,
purchasePrice: purchasePrice.toFixed(2),
quantity: 1,
note: '',
...(createdAt ? { createdAt } : {}),
}))
).returning();
const card = await db.query.cards.findFirst({
where: { cardId: cardId },
with: { set: true },
});
for (const entry of inserted) tsDocs.push(inventoryTsDoc(entry, card));
imported += inserted.length;
}
if (tsDocs.length) {
try {
await client.collections('inventories').documents().import(tsDocs);
} catch (error) {
console.error('Error importing inventory to Typesense:', error);
}
}
return { imported, skipped };
};
export const POST: APIRoute = async ({ request, locals }) => {
// Access form data from the request body
const formData = await request.formData();
@@ -173,6 +303,33 @@ export const POST: APIRoute = async ({ request, locals }) => {
await addToInventory(userId!, cardId, skuId, purchasePrice, quantity, note, catalogName);
break;
case 'import': {
if (!userId) {
return new Response(JSON.stringify({ error: 'Not authenticated' }), {
status: 401, headers: { 'Content-Type': 'application/json' },
});
}
const file = formData.get('file') as File | null;
if (!file) {
return new Response(JSON.stringify({ error: 'No file uploaded' }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});
}
const importCatalog = formData.get('catalogName')?.toString()?.trim() || 'Default';
try {
const rows = await parseCsv(await file.text());
const summary = await importInventory(userId, rows, importCatalog);
return new Response(JSON.stringify(summary), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Error importing inventory CSV:', error);
return new Response(JSON.stringify({ error: 'Could not parse CSV file' }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});
}
}
case 'remove':
const inventoryId = formData.get('inventoryId')?.toString() || '';
await removeFromInventory(inventoryId);
@@ -183,7 +340,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
const qty = Number(formData.get('quantity')) || 1;
const price = Number(formData.get('purchasePrice')) || 0;
const invNote = formData.get('note')?.toString() || '';
await updateInventory(invId, qty, price, invNote);
const invCatalog = formData.get('catalogName')?.toString() || 'Default';
await updateInventory(invId, qty, price, invNote, invCatalog);
break;
default:

View File

@@ -1,8 +1,6 @@
---
import Layout from "../layouts/Main.astro";
import Footer from "../components/Footer.astro";
import BackToTop from "../components/BackToTop.astro";
import FirstEditionIcon from "../components/FirstEditionIcon.astro";
export const prerender = false;
import Layout from '../layouts/Main.astro';
import { db } from '../db/index';
import { inventory, skus } from '../db/schema';
import { sql, sum, eq } from "drizzle-orm";
@@ -26,207 +24,109 @@ const totalQty = summary.totalQty || 0;
const totalValue = summary.totalValue || 0;
const totalGain = summary.totalGain || 0;
// distinct catalog names for this user, for the sidebar catalog list
const catalogRows = userId
? await db
.selectDistinct({ name: inventory.catalogName })
.from(inventory)
.where(eq(inventory.userId, userId))
: [];
const catalogs = catalogRows
.map(r => r.name)
.filter((n): n is string => !!n)
.sort((a, b) => a.localeCompare(b));
---
<Layout title="Inventory Dashboard">
<div class="container-fluid container-sm mt-3" slot="page">
<BackToTop />
<div class="container-fluid container-sm" slot="page">
<div class="row mb-4">
<aside class="col-12 col-md-2 border-end border-secondary bg-dark p-3 d-flex flex-column gap-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-uppercase text-secondary fw-bold ls-wide" style="letter-spacing:.08em">Catalogs</h6>
<button
class="btn btn-purple-secondary fs-7"
title="New catalog"
type="button"
data-bs-toggle="modal"
data-bs-target="#newCatalogModal"
>+ New</button>
<aside
class="offcanvas-xl offcanvas-start col-12 col-xl-2 border-end border-secondary bg-dark p-3 d-flex flex-column gap-3 catalog-sidebar"
tabindex="-1"
id="catalogSidebar"
aria-labelledby="catalogSidebarLabel"
>
<div class="offcanvas-header d-xl-none">
<h6 class="offcanvas-title text-uppercase text-secondary fw-bold ls-wide" id="catalogSidebarLabel" style="letter-spacing:.08em">Catalogs</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" data-bs-target="#catalogSidebar" aria-label="Close"></button>
</div>
<ul id="catalogList" class="list-group list-group-flush">
<li
class="list-group-item list-group-item-action fw-semibold border-0 rounded p-2 d-flex align-items-center active"
data-catalog="all"
role="button"
style="cursor:pointer"
>
<span class="d-flex align-items-center gap-2">
View all cards
</span>
<span class="badge rounded-pill text-bg-secondary small ms-auto">{totalQty}</span>
</li>
<div class="offcanvas-body d-xl-flex flex-column gap-3 p-0">
<div class="d-none d-xl-flex justify-content-between align-items-center">
<h6 class="mb-0 text-uppercase text-secondary fw-bold ls-wide" style="letter-spacing:.08em">Catalogs</h6>
</div>
{["Case Cards", "Japanese Singles", "Bulk"].map((name) => (
<ul id="catalogList" class="list-group list-group-flush">
<li
class="ms-2 list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center gap-2"
data-catalog={name}
class="list-group-item list-group-item-action fw-semibold border-0 rounded p-2 d-flex align-items-center active"
data-catalog="all"
role="button"
style="cursor:pointer"
hx-post="/partials/inventory-cards"
hx-vals={JSON.stringify({ catalog: "all" })}
hx-target="#gridView"
hx-swap="innerHTML"
>
{name}
<span class="d-flex align-items-center gap-2">
All cards
</span>
<span class="badge rounded-pill text-bg-secondary small ms-auto">{totalQty}</span>
</li>
))}
</ul>
<div class="mt-auto pt-3 border-top border-secondary small text-secondary">
<div class="d-flex justify-content-between mb-1"><span>Total Cards</span><span class="text-light fw-semibold">{totalQty}</span></div>
<div class="d-flex justify-content-between mb-1"><span>Market Value</span><span class="text-success fw-semibold">${totalValue.toFixed(0)}</span></div>
<div class="d-flex justify-content-between"><span>Gain/Loss</span><span class={`fw-semibold ${totalGain >= 0 ? "text-success" : "text-danger"}`}>{totalGain >= 0 ? "+" : ""}${Math.abs(totalGain).toFixed(0)}</span></div>
{catalogs.map((name) => (
<li
class="ms-2 list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center gap-2"
data-catalog={name}
role="button"
style="cursor:pointer"
hx-post="/partials/inventory-cards"
hx-vals={JSON.stringify({ catalog: name })}
hx-target="#gridView"
hx-swap="innerHTML"
>
{name}
</li>
))}
</ul>
<div class="mt-auto pt-3 border-top border-secondary small text-secondary">
<div class="d-flex justify-content-between mb-1"><span>Total Cards</span><span class="text-light fw-semibold">{totalQty}</span></div>
<div class="d-flex justify-content-between mb-1"><span>Market Value</span><span class="text-success fw-semibold">${totalValue.toFixed(0)}</span></div>
<div class="d-flex justify-content-between"><span>Gain/Loss</span><span class={`fw-semibold ${totalGain >= 0 ? "text-success" : "text-danger"}`}>{totalGain >= 0 ? "+" : ""}${Math.abs(totalGain).toFixed(0)}</span></div>
</div>
</div>
</aside>
<main class="col-12 col-md-10 p-4">
<div class="d-flex flex-wrap gap-2 align-items-center mb-4">
<div class="d-flex align-items-center gap-1">
<button id="btnGrid" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn active" title="Images view">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1.5rem" height="1.5rem" fill="currentColor"><path d="M351.6 220.4C349.7 258.3 381.1 288.3 418 286.5L527.6 281.1C593 275.5 589.7 214.9 589 162.5C593.9 90.4 588.4 39.6 502.4 44.6C391.5 42.2 352.5 36.7 354.5 164.3C353.4 183.3 352.4 202.4 351.5 220.3zM418.4 168.6L418.4 168.6C419.6 147.4 420.8 125.9 421.6 109.4C421.6 109.2 421.6 109.1 421.7 109C421.7 108.8 422 108.6 422.1 108.6C456.2 108.5 491.4 108.6 525.7 108.7C525.8 108.7 526 109 526.1 109.1L526.1 109.3C525.5 142.4 524.9 184.6 524.2 217.3L415.6 222.6C416.3 207.3 417.4 188.1 418.5 168.7zM301.4 112.5C303.3 74.7 272 44.6 235 46.4L125.4 51.8C40.8 58.8 69.2 164.2 63.1 222.4C62.4 258.3 91.2 288.3 127.5 288.3C159.4 288.3 198.3 288.3 231 288.3C298.6 286.3 297.2 220.3 298.5 168.6C299.6 149.6 300.6 130.5 301.5 112.6zM234.6 164.3C233.4 185.6 232.2 207 231.4 223.5C231.4 223.7 231.4 223.8 231.3 223.9C231.3 224 231.1 224.2 231.1 224.2L231 224.3C198.3 224.2 159.3 224.3 127.3 224.3C127.3 224.3 127.2 224.3 127.1 224.2C127 224.1 126.9 224 126.9 224L126.9 223.8C127.5 192.1 128.1 149.9 128.8 115.8L237.4 110.5C236.7 125.8 235.6 145 234.5 164.4zM63.6 404C64.5 421.9 65.5 441 66.6 460C68.2 512.2 65.9 577.1 134.1 579.7L237.6 579.7C273.9 579.7 302.7 549.7 302 513.9C301.7 498.8 301.4 480.4 301.1 461.9C301.8 409.5 305 348.9 239.7 343.3L130 338C93 336.2 61.7 366.2 63.6 404.1zM130.4 455.8C129.3 436.4 128.3 417.1 127.5 401.8L236.1 407.1C236.8 441.2 237.3 483.4 238 515C238 515.1 238 515.1 238 515.2C238 515.3 237.9 515.4 237.8 515.5C237.7 515.6 237.6 515.6 237.6 515.6C205.8 515.6 166.4 515.5 134 515.6C133.9 515.6 133.7 515.3 133.7 515.2C133.6 515.1 133.6 515 133.6 514.8C132.8 498.3 131.6 476.9 130.4 455.6L130.4 455.6zM523 578.1C560 579.9 591.3 549.8 589.4 512C588.5 494.1 587.5 475 586.4 456C585.9 381.4 580.2 328.2 490 336.3C457.9 336.4 439 336.3 415.4 336.3C379.1 336.3 350.3 366.3 351 402.1C351.3 417.2 351.6 435.6 351.9 454.1C351.2 506.5 348 567.1 413.3 572.7L523 578.1zM525.5 514.1L416.9 508.8C416.2 474.7 415.7 432.5 415 400.9L415 400.7C415 400.6 415.3 400.3 415.3 400.3C443.6 400.3 484.7 400.3 518.9 400.3C518.9 400.3 519 400.3 519.1 400.4C519.2 400.5 519.3 400.6 519.3 400.7C519.4 400.8 519.4 400.9 519.4 401.1C521.1 435.4 524 484.9 525.4 514.3z"/></svg>
<span class="ms-1">Grid</span>
</button>
<button id="btnTable" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn" title="List view">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1.5rem" height="1.5rem" fill="currentColor" style="d-inline"><path d="M102.3 211.8C110.7 214 118.9 213.7 126.2 210C132.6 208 138.3 204 142.4 198.4C184.2 169.6 148.5 98.8 98.3 108.3C39.6 119.9 49.3 205.8 102.2 211.8zM114.4 375.6C114.4 375.6 114.8 375.5 116.2 375.3L116.5 375.2C169.2 368.9 178.6 283.3 120.1 271.7C69.9 262.3 34.2 332.9 76 361.8C80.1 367.4 85.9 371.4 92.3 373.4C99 376.6 106.8 377.6 114.5 375.5zM116.8 423.5C63.5 413.9 30 495.4 84.4 522.3C130.5 544.4 183.2 485.4 150.4 446.7C147.9 440.2 143.4 434.9 137.7 431.2C132.1 426.4 124.8 423.5 116.8 423.5zM352.8 508.4C423.1 503.6 491.2 499 561.5 501.8C579.2 502.5 594.1 488.8 594.8 471.1C595.5 453.4 581.7 438.6 564.1 437.9C490.4 435 416.4 439.9 344.2 444.8C310.8 447 277.8 449.3 245.4 450.7C227.7 451.4 214 466.4 214.8 484C215.5 501.7 230.5 515.4 248.1 514.6C283.8 513 318.6 510.7 352.8 508.4zM344 344.8L344.3 344.8C412.9 343.4 479.9 342.9 548.3 346.2C566 347 580.9 333.3 581.7 315.6C582.5 298 568.8 283 551.1 282.2C482.2 278.8 412.2 279.3 343.1 280.7C310.3 281.3 277.9 281.9 245.8 281.9C228.1 281.9 213.8 296.2 213.8 313.9C213.8 331.6 228.1 345.9 245.8 345.9C278.5 345.9 311.4 345.3 343.9 344.7zM444.8 187.4C480.8 186.5 519 181.7 551.8 187.6C569.2 190.7 585.8 179.1 588.9 161.7C592 144.3 580.4 127.7 563 124.6C484.4 115.2 408.3 126.5 331 129.9C301.8 131.9 273.7 133 246 131.4C228.4 130.3 213.2 143.8 212.2 161.4C213.8 217.2 300.2 190.8 335.5 193.7C372.2 191.2 408.7 188.4 444.8 187.4z"/></svg>
<span class="ms-1">List</span>
</button>
</div>
<div class="vr opacity-25 mx-1"></div>
<a href="/pokemon" class="btn btn-vendor">+ Add Card</a>
<main class="col-12 col-xl-10 mt-4">
<div class="d-flex flex-wrap flex-row gap-2 mb-4">
<button
type="button"
class="btn btn-secondary d-xl-none me-auto"
data-bs-toggle="offcanvas"
data-bs-target="#catalogSidebar"
aria-controls="catalogSidebar"
>
Catalogs
</button>
<a href="/pokemon" class="btn btn-vendor ms-auto">Add cards</a>
<button
type="button"
class="btn btn-secondary"
class="btn btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#bulkImportModal"
>Bulk Import</button>
<div class="ms-auto position-relative">
<div class="input-group">
<input type="hidden" name="start" id="start" value="0" />
<input type="hidden" name="sort" id="sortInput" value="" />
<input type="hidden" name="language" id="languageInput" value="all" />
<input type="search" name="i" id="searchInput" class="form-control search-input" placeholder="Search your inventory" />
<button
type="submit"
class="btn btn-purple border-start-0"
aria-label="search"
onclick="
const i = this.closest('form').querySelector('[name=i]').value;
dataLayer.push({ event: 'view_inventory_results', search_term: i });
if (window.location.pathname !== '/dashboard') {
event.preventDefault();
event.stopPropagation();
sessionStorage.setItem('pendingSearch', 1);
window.location.href = '/dashboard';
}
"
>
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
</button>
</div>
</div>
</div>
<div id="inventoryView">
<div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load">
<div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load">
</div>
<!-- <div id="tableView" style="display:none">
<div class="inv-list-wrap">
<table class="table align-middle mb-0 inv-list-table">
<tbody id="inventoryRows">
{inventory.map(card => {
const market = nmPrice(card);
const purchase = nmPurchase(card);
const diff = market - purchase;
const pct = purchase > 0 ? (diff / purchase) * 100 : 0;
const isGain = diff >= 0;
return (
<tr class="inv-list-row">
<td class="inv-list-cardcell">
<div class="inv-list-card">
<div
class="inv-list-thumb card-trigger"
data-card-id={card.productId}
data-bs-toggle="modal"
data-bs-target="#cardModal"
>
<img
src={`/cards/${card.productId}.jpg`}
alt={card.productName}
loading="lazy"
decoding="async"
onerror="this.onerror=null;this.src='/cards/default.jpg';"
/>
</div>
<div class="inv-list-info">
<div
class="inv-list-name"
data-card-id={card.productId}
data-bs-toggle="modal"
data-bs-target="#cardModal"
style="cursor:pointer"
>
{card.productName}
</div>
<div class="inv-list-meta">
<div class="inv-list-setlink">{card.setName}</div>
<div>{card.rarityName}</div>
<div>{card.number}</div>
</div>
<div class="inv-list-condition">
<span>Near Mint</span>
<span>•</span>
<span>{card.variant !== "Normal" ? card.variant : "Holofoil"}</span>
</div>
</div>
<div class="inv-list-right">
<div class={`inv-list-price-line ${isGain ? "up" : "down"}`}>
<span class="inv-grid-arrow small">{isGain ? "▲" : "▼"}</span>
<span class="inv-list-price">${market.toFixed(2)}</span>
</div>
<div class={`inv-list-delta ${isGain ? "up" : "down"}`}>
{isGain ? "+" : "-"}${Math.abs(diff).toFixed(2)} ({isGain ? "+" : "-"}{Math.abs(pct).toFixed(2)}%)
</div>
<div class="inv-list-qty">Qty: {card.qty}</div>
</div>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div class="text-secondary small mt-2 ps-1" id="rowCount"></div>
</div> -->
</div>
</main>
</div>
<!-- <div class="modal fade" id="newCatalogModal" tabindex="-1" aria-labelledby="newCatalogLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="newCatalogLabel">Create Catalog</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<label class="form-label small text-secondary text-uppercase fw-semibold" for="catalogNameInput">Catalog Name</label>
<input id="catalogNameInput" type="text" class="form-control bg-dark-subtle text-light border-secondary" placeholder="e.g. Japanese Holos" />
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="createCatalogBtn">Create</button>
</div>
</div>
</div>
</div>-->
<div class="modal fade" id="bulkImportModal" tabindex="-1" aria-labelledby="bulkImportLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-lg modal-dialog-centered">
@@ -236,60 +136,403 @@ const totalGain = summary.totalGain || 0;
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="small text-secondary mb-3">
<p class="small text-secondary mb-2">
Upload a CSV exported from Collectr, TCGPlayer, or any marketplace. Columns: <code>name, set, condition, qty, price, market</code>.
</p>
<p class="small text-secondary mb-3">
Optionally add a <code>date</code> column (e.g. <code>2026-07-16</code>) to record when each card was acquired &mdash; it&rsquo;s used as the entry&rsquo;s added/purchase date. Rows without a date default to today.
</p>
<label class="form-label small text-secondary text-uppercase fw-semibold" for="csvFileInput">Choose File</label>
<input id="csvFileInput" type="file" accept=".csv" class="form-control bg-dark-subtle text-light border-secondary" />
<div id="csvPreview" class="mt-3 d-none">
<p class="small text-secondary fw-semibold mb-1">Preview</p>
<div class="border border-secondary rounded p-2 small text-secondary" id="csvPreviewContent">—</div>
<div class="border border-secondary rounded p-2 small text-secondary overflow-auto" id="csvPreviewContent" style="max-height: 12rem;">—</div>
</div>
<div id="csvImportResult" class="mt-3 d-none"></div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="csvUploadBtn">Upload &amp; Preview</button>
</div>
</div>
</div>
</div>
<!--
<div class="modal fade" id="inventoryEditModal" tabindex="-1" aria-labelledby="inventoryEditLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="inventoryEditLabel">Edit Inventory</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="inventoryEditBody">
<p class="text-secondary small">Select a card to edit its quantity and purchase price.</p>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success">Save</button>
<button type="button" class="btn btn-outline-danger" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="csvUploadBtn">Upload</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="addCardModal" tabindex="-1" aria-labelledby="addCardLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="addCardLabel">Add Card</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="text" class="form-control bg-dark-subtle text-light border-secondary mb-3" placeholder="Search card name…" id="addCardSearch" />
<p class="text-secondary small">Search results will appear here. Connect to your card database API to enable live search.</p>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" disabled>Add Selected</button>
</div>
</div>
<!-- Inventory detail modal (loaded from /partials/inventory-modal on open) -->
<div class="modal card-modal" id="inventoryModal" tabindex="-1" aria-labelledby="inventoryModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
<div class="modal-content p-2">Loading...</div>
</div>
</div> -->
</div>
<script is:inline>
(function () {
const catalogList = document.getElementById('catalogList');
const searchInput = document.getElementById('inventorySearchInput');
const searchBtn = document.getElementById('inventorySearchBtn');
if (catalogList) {
catalogList.addEventListener('click', (e) => {
const li = e.target.closest('li[data-catalog]');
if (!li) return;
catalogList.querySelectorAll('li[data-catalog]').forEach(el => el.classList.remove('active'));
li.classList.add('active');
// switching catalogs clears the search box so its contents don't misrepresent the grid
if (searchInput) searchInput.value = '';
});
}
// analytics only — the search request itself is fired declaratively via htmx attributes
function pushSearchEvent() {
if (window.dataLayer) {
window.dataLayer.push({ event: 'view_inventory_results', search_term: searchInput?.value.trim() || '' });
}
}
searchBtn?.addEventListener('click', pushSearchEvent);
searchInput?.addEventListener('keyup', (e) => { if (e.key === 'Enter') pushSearchEvent(); });
})();
// ── Bulk CSV import ──────────────────────────────────────────────────────
(function () {
const importModal = document.getElementById('bulkImportModal');
const fileInput = document.getElementById('csvFileInput');
const uploadBtn = document.getElementById('csvUploadBtn');
const preview = document.getElementById('csvPreview');
const previewContent = document.getElementById('csvPreviewContent');
const result = document.getElementById('csvImportResult');
if (!importModal || !fileInput || !uploadBtn) return;
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
let selectedFile = null;
let didImport = false;
fileInput.addEventListener('change', () => {
selectedFile = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null;
result.classList.add('d-none');
if (!selectedFile) { preview.classList.add('d-none'); return; }
const reader = new FileReader();
reader.onload = () => {
const lines = String(reader.result || '').split(/\r?\n/).filter((l) => l.trim()).slice(0, 6);
previewContent.innerHTML = lines
.map((l, i) => `<div class="${i === 0 ? 'text-light fw-semibold' : ''}">${esc(l)}</div>`)
.join('');
preview.classList.remove('d-none');
};
reader.readAsText(selectedFile);
});
uploadBtn.addEventListener('click', async () => {
if (!selectedFile) { alert('Choose a CSV file first.'); return; }
const original = uploadBtn.textContent;
uploadBtn.disabled = true;
uploadBtn.textContent = 'Importing…';
result.classList.add('d-none');
try {
const body = new FormData();
body.append('action', 'import');
body.append('file', selectedFile);
const res = await fetch('/api/inventory', { method: 'POST', body });
const data = await res.json();
if (!res.ok) {
result.className = 'mt-3 alert alert-danger py-2 small';
result.textContent = data.error || 'Import failed.';
result.classList.remove('d-none');
return;
}
didImport = data.imported > 0;
const skipped = data.skipped || [];
let html = `<div class="fw-semibold ${data.imported > 0 ? 'text-success' : ''}">Imported ${data.imported} card${data.imported === 1 ? '' : 's'}.</div>`;
if (skipped.length) {
html += `<div class="mt-1 text-warning fw-semibold">Skipped ${skipped.length}:</div>`;
html += '<ul class="mb-0 ps-3">' + skipped
.map((s) => `<li>Row ${s.row}${s.name ? ' (' + esc(s.name) + ')' : ''}: ${esc(s.reason)}</li>`)
.join('') + '</ul>';
}
result.className = 'mt-3 alert alert-dark py-2 small overflow-auto';
result.style.maxHeight = '14rem';
result.innerHTML = html;
result.classList.remove('d-none');
uploadBtn.textContent = didImport ? 'Done' : 'Upload';
} catch (e) {
result.className = 'mt-3 alert alert-danger py-2 small';
result.textContent = 'Import failed.';
result.classList.remove('d-none');
} finally {
uploadBtn.disabled = false;
if (uploadBtn.textContent === 'Importing…') uploadBtn.textContent = original;
}
});
// Refresh the dashboard (grid, catalogs, totals) after a successful import.
importModal.addEventListener('hidden.bs.modal', () => {
if (didImport) window.location.reload();
});
})();
(function () {
const modal = document.getElementById('inventoryModal');
if (!modal) return;
const CONDITION_COLORS = {
"Near Mint": { active: 'hsla(88, 50%, 67%, 1)', muted: 'hsla(88, 50%, 67%, 0.67)' },
"Lightly Played": { active: 'hsla(66, 70%, 68%, 1)', muted: 'hsla(66, 70%, 68%, 0.67)' },
"Moderately Played": { active: 'hsla(54, 100%, 73%, 1)', muted: 'hsla(54, 100%, 73%, 0.67)' },
"Heavily Played": { active: 'hsla(46, 100%, 65%, 1)', muted: 'hsla(46, 100%, 65%, 0.67)' },
"Damaged": { active: 'hsla(36, 100%, 65%, 1)', muted: 'hsla(36, 100%, 65%, 0.67)' },
};
const PURCHASE_COLOR = 'hsla(258, 90%, 76%, 1)';
const RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '1y': 365, 'all': Infinity };
let chart = null;
let history = [];
let meta = { purchasePrice: null, purchaseDate: null, condition: 'Near Mint' };
let range = 'all';
function formatDate(dateStr) {
const [y, m, d] = dateStr.split('-');
return new Date(Number(y), Number(m) - 1, Number(d))
.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' });
}
function buildData(rangeKey) {
const cutoff = RANGE_DAYS[rangeKey] === Infinity
? new Date(0)
: new Date(Date.now() - RANGE_DAYS[rangeKey] * 86400000);
const filtered = history.filter(r => new Date(r.calculatedAt) >= cutoff);
const showPurchase = meta.purchaseDate
&& meta.purchasePrice != null
&& new Date(meta.purchaseDate) >= cutoff;
const dateSet = new Set(filtered.map(r => r.calculatedAt));
if (showPurchase) dateSet.add(meta.purchaseDate);
const dates = [...dateSet].sort();
const priceByDate = {};
filtered.forEach(r => { priceByDate[r.calculatedAt] = Number(r.marketPrice); });
return {
labels: dates.map(formatDate),
lineData: dates.map(d => priceByDate[d] ?? null),
purchaseData: dates.map(d => (showPurchase && d === meta.purchaseDate) ? meta.purchasePrice : null),
hasData: filtered.length > 0,
};
}
function render() {
if (!chart) return;
const { labels, lineData, purchaseData } = buildData(range);
chart.data.labels = labels;
chart.data.datasets[0].data = lineData;
chart.data.datasets[1].data = purchaseData;
chart.update('none');
}
function initChart(root) {
const canvas = root.querySelector('#inventoryPriceChart');
if (!canvas || typeof Chart === 'undefined') return;
if (chart) { chart.destroy(); chart = null; }
try { history = JSON.parse(canvas.dataset.history || '[]'); } catch { history = []; }
meta = {
purchasePrice: canvas.dataset.purchasePrice !== '' ? Number(canvas.dataset.purchasePrice) : null,
purchaseDate: canvas.dataset.purchaseDate || null,
condition: canvas.dataset.condition || 'Near Mint',
};
range = '6m';
root.querySelectorAll('.inv-price-range-btn').forEach(b =>
b.classList.toggle('active', b.dataset.range === '6m'));
const emptyEl = root.querySelector('#inventoryPriceEmpty');
const wrap = canvas.closest('.chart-canvas-wrap');
if (!history.length) {
emptyEl?.classList.remove('d-none');
wrap?.classList.add('d-none');
return;
}
emptyEl?.classList.add('d-none');
wrap?.classList.remove('d-none');
const colors = CONDITION_COLORS[meta.condition] || CONDITION_COLORS['Near Mint'];
const { labels, lineData, purchaseData } = buildData(range);
chart = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
labels,
datasets: [
{
label: 'Market Price',
data: lineData,
borderColor: colors.active,
borderWidth: 2,
pointRadius: 2,
pointHoverRadius: 5,
pointBackgroundColor: colors.active,
tension: 0.3,
fill: false,
spanGaps: true,
order: 1,
},
{
label: 'Purchase',
data: purchaseData,
showLine: false,
pointRadius: 6,
pointHoverRadius: 8,
pointStyle: 'rectRot',
pointBackgroundColor: PURCHASE_COLOR,
pointBorderColor: '#fff',
pointBorderWidth: 1,
order: 0,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.85)',
titleColor: 'rgba(255, 255, 255, 0.9)',
bodyColor: 'rgba(255, 255, 255, 0.75)',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
padding: 10,
filter: (item) => item.parsed.y != null,
callbacks: {
labelColor: (ctx) => {
const c = ctx.dataset.label === 'Purchase' ? PURCHASE_COLOR : colors.active;
return { borderColor: c, backgroundColor: c };
},
label: (ctx) => {
if (ctx.dataset.label === 'Purchase') {
return ` Purchase: $${Number(ctx.parsed.y).toFixed(2)}`;
}
return ` Market: $${Number(ctx.parsed.y).toFixed(2)}`;
},
afterLabel: (ctx) => {
if (ctx.dataset.label === 'Purchase' || meta.purchasePrice == null) return;
const diff = ctx.parsed.y - meta.purchasePrice;
const sign = diff >= 0 ? '+' : '';
const pct = meta.purchasePrice > 0 ? (diff / meta.purchasePrice) * 100 : 0;
return `vs purchase: ${sign}$${Math.abs(diff).toFixed(2)} (${sign}${Math.abs(pct).toFixed(1)}%)`;
},
},
},
},
scales: {
x: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { color: 'rgba(255, 255, 255, 0.4)', maxTicksLimit: 6, maxRotation: 0 },
border: { color: 'rgba(255, 255, 255, 0.1)' },
},
y: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { color: 'rgba(255, 255, 255, 0.4)', callback: (v) => `$${Number(v).toFixed(2)}` },
border: { color: 'rgba(255, 255, 255, 0.1)' },
},
},
},
});
}
// ── Load modal content on open ──────────────────────────────────────────
modal.addEventListener('show.bs.modal', async (e) => {
const trigger = e.relatedTarget;
const inventoryId = trigger?.dataset.inventoryId;
const content = modal.querySelector('.modal-content');
if (!content) return;
if (!inventoryId) { content.innerHTML = '<div class="p-4 text-danger">Missing inventory id.</div>'; return; }
content.innerHTML = '<div class="p-5 text-center text-secondary">Loading…</div>';
try {
const res = await fetch(`/partials/inventory-modal?inventoryId=${encodeURIComponent(inventoryId)}`);
content.innerHTML = await res.text();
requestAnimationFrame(() => initChart(content));
} catch (err) {
content.innerHTML = '<div class="p-4 text-danger">Failed to load inventory entry.</div>';
}
});
modal.addEventListener('hidden.bs.modal', () => {
if (chart) { chart.destroy(); chart = null; }
history = [];
});
// ── Time-range buttons ──────────────────────────────────────────────────
modal.addEventListener('click', (e) => {
const btn = e.target.closest('.inv-price-range-btn');
if (!btn) return;
modal.querySelectorAll('.inv-price-range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
range = btn.dataset.range || 'all';
render();
});
// ── Delete inventory entry ──────────────────────────────────────────────
modal.addEventListener('click', async (e) => {
const delBtn = e.target.closest('[data-inventory-delete]');
if (!delBtn) return;
const form = modal.querySelector('[data-inventory-edit-form]');
const inventoryId = form?.querySelector('[name=inventoryId]')?.value;
const cardId = form?.querySelector('[name=cardId]')?.value;
if (!inventoryId) return;
if (!confirm('Are you sure you want to remove this card from your inventory?')) return;
const original = delBtn.textContent;
delBtn.disabled = true; delBtn.textContent = 'Deleting…';
try {
const body = new FormData();
body.append('action', 'remove');
body.append('inventoryId', inventoryId);
if (cardId) body.append('cardId', cardId);
const res = await fetch('/api/inventory', { method: 'POST', body });
if (res.ok) {
// drop the matching grid tile, then close the modal
document.querySelector(`#gridView [data-inventory-id="${inventoryId}"]`)?.closest('.col')?.remove();
const instance = window.bootstrap?.Modal.getInstance(modal);
if (instance) instance.hide();
else modal.querySelector('[data-bs-dismiss=modal]')?.click();
} else {
delBtn.disabled = false; delBtn.textContent = original;
}
} catch {
delBtn.disabled = false; delBtn.textContent = original;
}
});
// ── Save catalog + note ─────────────────────────────────────────────────
modal.addEventListener('submit', async (e) => {
const form = e.target.closest('[data-inventory-edit-form]');
if (!form) return;
e.preventDefault();
const btn = modal.querySelector('button[type="submit"][form="inventoryEditForm"]');
const original = btn ? btn.textContent : '';
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
try {
const res = await fetch('/api/inventory', { method: 'POST', body: new FormData(form) });
if (res.ok) {
const instance = window.bootstrap?.Modal.getInstance(modal);
if (instance) instance.hide();
else modal.querySelector('[data-bs-dismiss=modal]')?.click();
} else if (btn) {
btn.textContent = original; btn.disabled = false;
}
} catch {
if (btn) { btn.textContent = original; btn.disabled = false; }
}
});
})();
</script>
</div>
<Footer slot="footer" />
</Layout>
</Layout>

View File

@@ -11,8 +11,8 @@ import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
import { Tooltip } from "bootstrap";
// auth check for inventory management features
//const { canAddInventory } = Astro.locals;
const canAddInventory = false;
const { canAddInventory } = Astro.locals;
// const canAddInventory = false;
export const partial = true;
export const prerender = false;
@@ -473,7 +473,7 @@ const altSearchUrl = (card: any) => {
</datalist>
<div class="form-text">
Type a name or pick an existing catalog.
Enter a name or pick an existing catalog.
</div>
</div>
<div class="col-12">

View File

@@ -7,8 +7,8 @@ export const prerender = false;
import * as util from 'util';
// auth check for inventory management features
//const { canAddInventory } = Astro.locals;
const canAddInventory = false;
const { canAddInventory } = Astro.locals;
// const canAddInventory = false;
// all the facet fields we want to use for filtering
const facetFields:any = {

View File

@@ -11,9 +11,16 @@ import { db } from '../../db';
const formData = await Astro.request.formData();
const query = formData.get('q')?.toString() || '';
const start = Number(formData.get('start')?.toString() || '0');
const catalog = formData.get('catalog')?.toString() || '';
const { userId } = Astro.locals.auth();
// scope results to the selected catalog (empty / "all" => every catalog)
let filterBy = `userId:=${userId}`;
if (catalog && catalog !== 'all') {
filterBy += ` && catalogName:=\`${catalog}\``;
}
const InventoryDetails = async (inventoryId: string) => {
//console.log('inventoryid', inventoryId);
const details = await db.query.inventory.findFirst({
@@ -27,7 +34,7 @@ const InventoryDetails = async (inventoryId: string) => {
// primary search values (for cards)
let searchArray = [{
collection: 'inventories',
filter_by: `userId:=${userId}`,
filter_by: filterBy,
per_page: 20,
facet_by: '',
max_facet_values: 0,
@@ -90,7 +97,7 @@ console.log(`totalHits: ${totalHits}`);
return (
<div class="col equal-height-col">
<div class="card-trigger position-relative inv-grid-media" data-card-id={card.productId} data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName} data-bs-toggle="modal" data-bs-target="#cardModal">
<div class="card-trigger position-relative inv-grid-media" data-inventory-id={inventory.inventoryId} data-card-id={card.productId} data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName} data-bs-toggle="modal" data-bs-target="#inventoryModal">
<div class="rounded-4 card-image h-100">
<img src={`static/cards/${card.productId}.jpg`} alt={card.productName} loading="lazy" decoding="async" class="img-fluid rounded-4 w-100" onerror="this.onerror=null;this.src='static/cards/default.jpg';this.closest('.image-grow')?.setAttribute('data-default','true')" />
<span class="position-absolute top-50 start-0 d-inline medium-icon" style="z-index:4">
@@ -98,18 +105,6 @@ console.log(`totalHits: ${totalHits}`);
</span>
</div>
</div>
<div class="d-flex flex-row justify-content-between my-1 align-items-center edit-bar">
<input type="number" class="form-control form-control-sm text-center" style="max-width: 33%;" value={inventory.quantity} min="1" max="999" aria-label="Quantity input" aria-describedby="button-minus button-plus">
<div class="" aria-label="Edit controls">
<button type="button" class="btn btn-sm btn-edit me-2"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" class="edit-svg"><path d="M374.4 146.5L374.4 146.5C395.3 128.4 443.5 85.4 468.5 97.6C484.4 105.9 497.8 112.8 501.3 120.1L501.4 120.2L501.5 120.3L501.6 120.5C507.6 130.2 518.4 150.1 519.6 161.6C513.5 192.1 484 217.1 461.6 240.4C425.7 208.6 404.1 187.1 369.7 150.6C371.3 149.2 372.8 147.9 374.4 146.5zM484.1 307.3C484.3 307.1 484.5 307 484.6 306.8C525.1 265.7 579.4 226.1 583.7 161.7C575.2 64.5 475.6-2.1 395.9 50.5C299.2 114.8 235.3 194.9 152 272.8C118.1 305.6 72.6 334.1 62.3 384.2C60.7 400.9 62.5 429.9 63.3 446.8C63.3 472.2 62.6 516.6 62.3 535.2C65.6 607.8 181.9 562 225.7 560.1C249.2 555.4 267.7 533 280.2 519.6C280.4 519.4 280.5 519.3 280.5 519.3C350.4 450.8 416.9 378.7 484.2 307.4zM416.2 285.7C349.4 358.2 282.1 428.8 211.2 497.7C182.1 502.9 154.5 507.3 126.6 510C128.2 471.4 125.7 426.3 125.8 391.4C138.8 367.7 172.1 340.8 194.2 320.8C239.9 279.4 276.7 234.2 323.2 194.7C356.5 230 380.6 254.2 416.1 285.7z"/></svg></button>
<button type="button" class="btn btn-sm btn-delete"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" class="delete-svg">
<filter id='shadow' color-interpolation-filters="sRGB">
<feDropShadow dx="2" dy="2" stdDeviation="2" flood-opacity="0.5"/>
</filter>
<path d="M345.1 124.8C361 125.2 374.2 126 386.3 127.8C386.2 137.1 385.4 148.3 384.6 157.9C340.9 156.9 297.8 157.6 254.5 158C254.8 149.7 252.4 136.7 255.2 129.7C279.3 123.4 313.6 125.1 345.1 124.7zM448.7 160.7C460.3 66.9 431.8 64.5 346.7 60.8C268.3 59.6 178 54.7 190.5 158.2C157.2 162.7 96.1 137.9 89.6 186.5C88.1 210.7 111.2 222.9 132.8 220.8L132.8 478.6C132.6 530.6 176.7 571.9 228.6 569.3L398.9 569.3C500.4 574.1 515.3 476.9 509.7 395.2C509.1 364.1 511.4 273.6 512.5 223.8C554.7 223.7 554.5 159.5 512.2 159.8L448.6 160.7zM448.5 224.7C446.1 301.9 445.5 378 445.8 440.3C442.6 476.1 442.9 507.5 400.1 505.2L227 505.2C211.1 506.8 196.6 494.9 196.7 478.5L196.7 222.1C280.4 222.3 366.5 219.1 448.4 224.6z"/></svg></button>
</div>
</div>
<div class="d-flex flex-row mt-1">
<div class="text-body-tertiary flex-grow-1 small">{card.setName}</div>
</div>
@@ -142,4 +137,16 @@ console.log(`totalHits: ${totalHits}`);
</div>
);
})
})}
{start + 20 < totalHits &&
<div
hx-post="/partials/inventory-cards"
hx-trigger="revealed"
hx-vals={JSON.stringify({ start: start + 20, catalog, q: query })}
hx-target="#gridView"
hx-swap="beforeend"
hx-on--after-request="this.remove()"
>
Loading...
</div>
}

View File

@@ -0,0 +1,247 @@
---
import ebay from "/vendors/ebay.svg?raw";
import SetIcon from '../../components/SetIcon.astro';
import EnergyIcon from '../../components/EnergyIcon.astro';
import RarityIcon from '../../components/RarityIcon.astro';
import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
import { db } from '../../db/index';
import { inventory } from '../../db/schema';
import { eq } from 'drizzle-orm';
export const partial = true;
export const prerender = false;
const { userId } = Astro.locals.auth();
const searchParams = Astro.url.searchParams;
const inventoryId = searchParams.get('inventoryId') || '';
const inv = userId && inventoryId
? await db.query.inventory.findFirst({
where: { inventoryId: inventoryId, userId: userId },
with: {
sku: {
with: {
card: { with: { set: true } },
history: true,
},
},
},
})
: null;
const sku = inv?.sku;
const card = sku?.card;
// Distinct catalog names for this user (datalist suggestions)
const catalogRows = userId
? await db
.selectDistinct({ name: inventory.catalogName })
.from(inventory)
.where(eq(inventory.userId, userId))
: [];
const catalogs = catalogRows.map(r => r.name).filter((n): n is string => !!n);
// ── Values from the inventory entry ──────────────────────────────────────
const quantity = inv?.quantity ?? 0;
const purchasePrice = inv?.purchasePrice != null ? Number(inv.purchasePrice) : null;
const purchaseDate = inv?.createdAt ? new Date(inv.createdAt).toISOString().split('T')[0] : null;
const marketPrice = sku?.marketPrice != null ? Number(sku.marketPrice) : null;
const condition = sku?.condition || 'Near Mint';
const gain =
purchasePrice != null && marketPrice != null
? marketPrice - purchasePrice
: null;
const gainPercent =
purchasePrice != null &&
marketPrice != null &&
purchasePrice > 0
? ((marketPrice - purchasePrice) / purchasePrice) * 100
: null;
// ── Price history for this SKU's condition only ──────────────────────────
const priceHistoryForChart = (sku?.history ?? [])
.map(h => ({
calculatedAt: h.calculatedAt
? new Date(h.calculatedAt).toISOString().split('T')[0]
: null,
marketPrice: h.marketPrice != null ? Number(h.marketPrice) : null,
}))
.filter(h => h.calculatedAt !== null && h.marketPrice !== null)
.sort((a, b) => (a.calculatedAt! < b.calculatedAt! ? -1 : 1));
const ebaySearchUrl = (card: any) => {
return `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&LH_Sold=1&Graded=No&_dcat=183454`;
};
const altSearchUrl = (card: any) => {
return `https://alt.xyz/browse?query=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&sortBy=newest_first`;
};
---
{!inv && (
<Fragment>
<div class="modal-header border-0">
<div class="h5 mb-0">Inventory entry not found</div>
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-secondary mb-0">This card is no longer in your inventory.</p>
</div>
</Fragment>
)}
{inv && (
<Fragment>
<div class="modal-header border-0">
<div class="container-fluid row align-items-center">
<div class="h4 card-title pe-2 col-sm-12 col-md-auto mb-sm-1">{card?.productName}</div>
<div class="text-secondary col-auto">{card?.number}</div>
<div class="text-light col-auto">{card?.variant}</div>
</div>
<div class="d-flex gap-2 align-items-center">
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="modal"></button>
</div>
</div>
<div class="modal-body pt-0">
<div class="container-fluid">
<div class="card mb-2 border-0">
<div class="row g-4">
<!-- Card image column -->
<div class="col-sm-12 col-md-3">
<div class="position-relative mt-1">
<div class="card-image-wrap rounded-4" data-energy={card?.energyType} data-rarity={card?.rarityName} data-variant={card?.variant} data-name={card?.productName}>
<img src={`/static/cards/${card?.productId}.jpg`}
class="card-image w-100 img-fluid rounded-4"
alt={card?.productName}
crossorigin="anonymous"
onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow, .card-image-wrap')?.setAttribute('data-default','true')"
onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});"
/>
</div>
<span class="position-absolute top-50 start-0 d-inline"><FirstEditionIcon edition={card?.variant} /></span>
<span class="position-absolute bottom-0 start-0 d-inline"><SetIcon set={card?.set?.setCode} /></span>
<span class="position-absolute top-0 end-0 d-inline"><EnergyIcon energy={card?.energyType} /></span>
<span class="rarity-icon-large position-absolute bottom-0 end-0 d-inline"><RarityIcon rarity={card?.rarityName} /></span>
</div>
<div class="d-flex flex-column flex-lg-row justify-content-between mt-2">
<div class="text-secondary"><span class="d-flex d-xxl-none">{card?.set?.setCode}</span><span class="d-none d-xxl-flex">{card?.set?.setName}</span></div>
<div class="text-secondary">Illus<span class="d-none d-xxl-inline">trator</span>: {card?.artist}</div>
</div>
</div>
<!-- Inventory details + edit column -->
<div class="col-sm-12 col-md-7">
<!-- Static fields (left) + editable Note/Catalog (right), side by side -->
<div class="row gx-3 gy-2">
<!-- Left column: read-only stats -->
<div class="col-12 col-lg-6">
<label class="form-label">Condition</label>
<div class="btn-group condition-input w-100 col-12" role="group" aria-label="Condition">
<input type="radio" class="btn-check" name="condition" id="cond-nm" value="Near Mint" autocomplete="off" checked />
<label class="btn btn-cond-nm" for="cond-nm">NM</label>
<input type="radio" class="btn-check" name="condition" id="cond-lp" value="Lightly Played" autocomplete="off" disabled />
<label class="btn btn-cond-lp" for="cond-lp">LP</label>
<input type="radio" class="btn-check" name="condition" id="cond-mp" value="Moderately Played" autocomplete="off" disabled />
<label class="btn btn-cond-mp" for="cond-mp">MP</label>
<input type="radio" class="btn-check" name="condition" id="cond-hp" value="Heavily Played" autocomplete="off" disabled />
<label class="btn btn-cond-hp" for="cond-hp">HP</label>
<input type="radio" class="btn-check" name="condition" id="cond-dmg" value="Damaged" autocomplete="off" disabled />
<label class="btn btn-cond-dmg" for="cond-dmg">DMG</label>
</div>
<p class="mb-1 my-2">Purchased: <span class="text-secondary">{inv.createdAt ? new Date(inv.createdAt).toLocaleDateString() : '—'}</span></p>
<div class="d-flex flex-wrap justify-content-center flex-row gap-2">
<div class="alert alert-dark d-flex flex-column flex-fill rounded p-2 mb-2">
<p class="mb-auto">Purchase Price</p>
<h6 class="mb-0 mt-1">{purchasePrice != null ? `$${purchasePrice.toFixed(2)}` : '—'}</h6>
</div>
<div class="alert alert-dark d-flex flex-column flex-fill rounded p-2 mb-2">
<p class="mb-auto">Market Price</p>
<h6 class="mb-0 mt-1">{marketPrice != null ? `$${marketPrice.toFixed(2)}` : '—'}</h6>
</div>
</div>
<div class="d-flex flex-wrap justify-content-between flex-row gap-2">
<div class={`alert rounded p-2 flex-fill ${gain == null ? 'alert-dark' : gain >= 0 ? 'alert-success' : 'alert-danger'}`}>
<p class="mb-auto">Gain / Loss ($)</p>
<h6 class="mb-0 mt-1">{gain == null ? '—' : `${gain >= 0 ? '+' : ''}$${Math.abs(gain).toFixed(2)}`}</h6>
</div>
<div class={`alert rounded p-2 flex-fill ${gainPercent == null ? 'alert-dark' : gainPercent >= 0 ? 'alert-success' : 'alert-danger'}`}>
<p class="mb-auto">Gain / Loss (%)</p>
<h6 class="mb-0 mt-1">
{ gainPercent == null ? '—' : `${gainPercent >= 0 ? '+' : ''}${Math.abs(gainPercent).toFixed(2)}%` }
</h6>
</div>
</div>
</div>
<!-- Right column: editable Note + Catalog -->
<div class="col-12 col-lg-6">
<form id="inventoryEditForm" data-inventory-edit-form novalidate>
<input type="hidden" name="action" value="update" />
<input type="hidden" name="inventoryId" value={inv?.inventoryId} />
<input type="hidden" name="cardId" value={card?.cardId} />
<input type="hidden" name="quantity" value={quantity} />
<input type="hidden" name="purchasePrice" value={purchasePrice ?? 0} />
<label for="catalogName" class="form-label">Catalog</label>
<input type="text" class="form-control" id="catalogName" name="catalogName" list="catalogSuggestions" placeholder="Default" maxlength="100" value={inv?.catalogName ?? ''} />
<datalist id="catalogSuggestions">
{catalogs.map(name => <option value={name}></option>)}
</datalist>
<label for="note" class="form-label mt-2">Note</label>
<textarea class="form-control" id="note" name="note" rows="5" maxlength="255" placeholder="e.g. bought at local shop, gift, graded copy…">{inv?.note ?? ''}</textarea>
</form>
</div>
</div>
<!-- Market Price History chart for this condition -->
<div class="d-block d-lg-flex gap-1 mt-2 price-chart-container">
<div class="col-12">
<div class="alert alert-dark rounded p-2 mb-0">
<h6>Market Price History <span class="small text-secondary">({condition})</span></h6>
<div id="inventoryPriceEmpty" class="d-none text-secondary text-center py-4">
No price history for this condition
</div>
<div class="position-relative chart-canvas-wrap" style="height: 220px;">
<canvas id="inventoryPriceChart" class="price-history-chart" data-history={JSON.stringify(priceHistoryForChart)} data-purchase-price={purchasePrice ?? ''} data-purchase-date={purchaseDate ?? ''} data-condition={condition}></canvas>
</div>
<div class="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end mt-2" role="group" aria-label="Time range">
<button type="button" class="btn btn-dark inv-price-range-btn" data-range="1m">1M</button>
<button type="button" class="btn btn-dark inv-price-range-btn" data-range="3m">3M</button>
<button type="button" class="btn btn-dark inv-price-range-btn active" data-range="6m">6M</button>
<button type="button" class="btn btn-dark inv-price-range-btn" data-range="1y">1Y</button>
<button type="button" class="btn btn-dark inv-price-range-btn" data-range="all">All</button>
</div>
</div>
</div>
</div>
</div>
<!-- External links column -->
<div class="col-sm-12 col-md-2 mt-0 mt-md-4 d-flex flex-row flex-md-column">
<a class="btn btn-dark mb-2 w-100 p-2" href={`https://www.tcgplayer.com/product/${card?.productId}`} target="_blank" onclick="dataLayer.push({'event': 'tcgplayerClick', 'tcgplayerUrl': this.getAttribute('href')});"><img src="/vendors/tcgplayer.webp"> <span class="d-none d-lg-inline">TCGPlayer</span></a>
<a class="btn btn-dark mb-2 w-100 p-2" href={`${ebaySearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'ebayClick', 'ebayUrl': this.getAttribute('href')});"><span set:html={ebay} /></a>
<a class="btn btn-dark mb-2 w-100 p-2" href={`${altSearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'altClick', 'altUrl': this.getAttribute('href')});"><svg width="48" height="20.16" viewBox="0 0 48 20" fill="none"><path d="M14.2761 19.9996H18.5308L11.6934 0.0712891H7.76953L14.2761 19.9996Z" fill="#ffffff"></path><path d="M6.17778 19.9986H6.14536L3.19643 11.2305L0 19.9988L6.17768 19.9989L6.17778 19.9986Z" fill="#ffffff"></path><path d="M24.7842 0H20.6759V19.9661H34.3427V16.5426H24.7842V0Z" fill="#ffffff"></path><path d="M41.6644 3.42355H47.4981V0H31.5033V3.42355H37.5561V19.9661H41.6644V3.42355Z" fill="#ffffff"></path></svg></a>
<div class="d-flex gap-2 mt-auto align-items-end justify-content-evenly">
<button type="button" class="btn btn-outline-danger flex-fill" data-inventory-delete>Delete</button>
<button type="submit" form="inventoryEditForm" class="btn btn-success flex-fill flex-grow-1">Save</button>
</div>
</div>
</div>
</div>
</div>
</div>
</Fragment>
)}