Compare commits
11 Commits
c0055ed713
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c90bf21111 | ||
|
|
3957a86f9c | ||
|
|
4992890503 | ||
|
|
0858d3eaec | ||
|
|
b3799a5318 | ||
|
|
a265aea424 | ||
|
|
f0ee662450 | ||
|
|
b91b64f32c | ||
|
|
1e219b6c26 | ||
|
|
5abd6e4b92 | ||
|
|
4990c3aa04 |
1917
package-lock.json
generated
1917
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,12 +9,12 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^9.5.4",
|
"@astrojs/node": "^10.1.4",
|
||||||
"@clerk/astro": "^3.0.1",
|
"@clerk/astro": "^3.0.1",
|
||||||
"@clerk/shared": "^4.0.0",
|
"@clerk/shared": "^4.0.0",
|
||||||
"@clerk/themes": "^2.4.55",
|
"@clerk/themes": "^2.4.55",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"astro": "^5.17.1",
|
"astro": "^6.4.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
|||||||
@@ -886,6 +886,16 @@ footer .logo-svg > svg { width: var(--logo-width, 8rem); }
|
|||||||
-------------------------------------------------- */
|
-------------------------------------------------- */
|
||||||
#gridView { row-gap: 1.5rem; }
|
#gridView { row-gap: 1.5rem; }
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.catalog-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--navbar-height, 4rem);
|
||||||
|
height: calc(100vh - var(--navbar-height, 4rem));
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.inv-grid-card {
|
.inv-grid-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
---
|
---
|
||||||
|
// auth check for inventory management features
|
||||||
|
const { canAddInventory } = Astro.locals;
|
||||||
|
const { isAdmin } = Astro.locals;
|
||||||
|
// const canAddInventory = false;
|
||||||
---
|
---
|
||||||
<button
|
<button
|
||||||
class="navbar-toggler ms-4 p-1 btn btn-purple border-0"
|
class="navbar-toggler ms-4 p-1 btn btn-purple border-0"
|
||||||
@@ -30,9 +34,16 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link py-3 border-bottom border-secondary" href="/pokemon">Browse Cards</a>
|
<a class="nav-link py-3 border-bottom border-secondary" href="/pokemon">Browse Cards</a>
|
||||||
</li>
|
</li>
|
||||||
<!--<li class="nav-item">
|
{canAddInventory && (
|
||||||
<a class="nav-link py-3" href="/dashboard">Dashboard</a>
|
<li class="nav-item">
|
||||||
</li> -->
|
<a class="nav-link py-3 border-bottom border-secondary" href="/dashboard">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link py-3" href="/admin">Admin Panel</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,47 +25,77 @@ import { Show } from '@clerk/astro/components'
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Show when="signed-in">
|
<Show when="signed-in">
|
||||||
<form
|
{Astro.url.pathname === '/dashboard' ? (
|
||||||
class="d-flex align-items-center"
|
|
||||||
role="search"
|
|
||||||
id="searchform"
|
|
||||||
hx-post="/partials/cards"
|
|
||||||
hx-target="#cardGrid"
|
|
||||||
hx-trigger="load, submit"
|
|
||||||
hx-vals='{"start":"0"}'
|
|
||||||
hx-on--after-request="afterUpdate()"
|
|
||||||
hx-on--before-request="beforeSearch()"
|
|
||||||
>
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
{Astro.url.pathname === '/pokemon' && (
|
<input
|
||||||
<a class="btn btn-purple" data-bs-toggle="offcanvas" href="#filterBar" type="button" role="button" aria-controls="filterBar" aria-label="filter">
|
type="search"
|
||||||
<span class="d-block d-md-none filter-icon py-2">
|
name="q"
|
||||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M528.8 96.3C558.6 90.8 571.2 118.9 568.9 142.2C572.3 173.4 570.8 207 553.9 230.8C513.9 283.2 459.3 315.9 414.3 364.3C414.9 418.3 419.8 459.8 423.6 511.2C427.6 552.4 388.7 586.8 346.6 570.1C303.2 550.5 259.4 527.5 230.4 493.3C217 453.1 225.9 407.5 222.2 365.3C222.2 365.3 222.1 365.1 222 365C151.4 319.6 59.3 250.9 61 158.4C59.9 121 91.8 96.1 123.8 96.5C259.3 98.5 394.1 104.4 528.8 96.3zM506.1 161.4C378.3 168.2 252 162.1 125.2 160.5C128.6 227 199 270.8 250 306.8C305.5 335.4 281.6 410.5 288.3 461.7C310.8 478.9 334.6 494.6 358.9 505.8C355.4 458 350.7 415.4 350.2 364.6C349.9 349.2 355.3 333.7 366.5 321.7C384.3 302.6 402.8 287.8 421.5 270.1C446.1 245.2 477.9 225.1 499.7 196.7C509 182.2 504.7 174.5 506 161.5z"/></svg>
|
id="inventorySearchInput"
|
||||||
</span>
|
class="form-control search-input"
|
||||||
<span class="d-none d-md-block fw-medium">Filters</span>
|
placeholder="Search your inventory"
|
||||||
</a>
|
hx-post="/partials/inventory-cards"
|
||||||
)}
|
hx-trigger="keyup[key=='Enter']"
|
||||||
<input type="hidden" name="start" id="start" value="0" />
|
hx-target="#gridView"
|
||||||
<input type="hidden" name="sort" id="sortInput" value="" />
|
hx-swap="innerHTML"
|
||||||
<input type="hidden" name="language" id="languageInput" value="all" />
|
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
|
||||||
<input type="search" name="q" id="searchInput" class="form-control search-input" placeholder="Search cards" />
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
|
id="inventorySearchBtn"
|
||||||
class="btn btn-purple border-start-0"
|
class="btn btn-purple border-start-0"
|
||||||
aria-label="search"
|
aria-label="search"
|
||||||
onclick="
|
hx-post="/partials/inventory-cards"
|
||||||
const q = this.closest('form').querySelector('[name=q]').value;
|
hx-include="#inventorySearchInput"
|
||||||
dataLayer.push({ event: 'view_search_results', search_term: q });
|
hx-target="#gridView"
|
||||||
if (window.location.pathname !== '/pokemon') {
|
hx-swap="innerHTML"
|
||||||
event.preventDefault();
|
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
|
||||||
event.stopPropagation();
|
|
||||||
sessionStorage.setItem('pendingSearch', q);
|
|
||||||
window.location.href = '/pokemon';
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
|
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
) : (
|
||||||
|
<form
|
||||||
|
class="d-flex align-items-center"
|
||||||
|
role="search"
|
||||||
|
id="searchform"
|
||||||
|
hx-post="/partials/cards"
|
||||||
|
hx-target="#cardGrid"
|
||||||
|
hx-trigger="load, submit"
|
||||||
|
hx-vals='{"start":"0"}'
|
||||||
|
hx-on--after-request="afterUpdate()"
|
||||||
|
hx-on--before-request="beforeSearch()"
|
||||||
|
>
|
||||||
|
<div class="input-group">
|
||||||
|
{Astro.url.pathname === '/pokemon' && (
|
||||||
|
<a class="btn btn-purple" data-bs-toggle="offcanvas" href="#filterBar" type="button" role="button" aria-controls="filterBar" aria-label="filter">
|
||||||
|
<span class="d-block d-md-none filter-icon py-2">
|
||||||
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M528.8 96.3C558.6 90.8 571.2 118.9 568.9 142.2C572.3 173.4 570.8 207 553.9 230.8C513.9 283.2 459.3 315.9 414.3 364.3C414.9 418.3 419.8 459.8 423.6 511.2C427.6 552.4 388.7 586.8 346.6 570.1C303.2 550.5 259.4 527.5 230.4 493.3C217 453.1 225.9 407.5 222.2 365.3C222.2 365.3 222.1 365.1 222 365C151.4 319.6 59.3 250.9 61 158.4C59.9 121 91.8 96.1 123.8 96.5C259.3 98.5 394.1 104.4 528.8 96.3zM506.1 161.4C378.3 168.2 252 162.1 125.2 160.5C128.6 227 199 270.8 250 306.8C305.5 335.4 281.6 410.5 288.3 461.7C310.8 478.9 334.6 494.6 358.9 505.8C355.4 458 350.7 415.4 350.2 364.6C349.9 349.2 355.3 333.7 366.5 321.7C384.3 302.6 402.8 287.8 421.5 270.1C446.1 245.2 477.9 225.1 499.7 196.7C509 182.2 504.7 174.5 506 161.5z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="d-none d-md-block fw-medium">Filters</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<input type="hidden" name="start" id="start" value="0" />
|
||||||
|
<input type="hidden" name="sort" id="sortInput" value="" />
|
||||||
|
<input type="hidden" name="language" id="languageInput" value="all" />
|
||||||
|
<input type="search" name="q" id="searchInput" class="form-control search-input" placeholder="Search cards" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-purple border-start-0"
|
||||||
|
aria-label="search"
|
||||||
|
onclick="
|
||||||
|
const q = this.closest('form').querySelector('[name=q]').value;
|
||||||
|
dataLayer.push({ event: 'view_search_results', search_term: q });
|
||||||
|
if (window.location.pathname !== '/pokemon') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
sessionStorage.setItem('pendingSearch', q);
|
||||||
|
window.location.href = '/pokemon';
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -137,7 +137,8 @@ export const inventory = pokeSchema.table('inventory',{
|
|||||||
createdAt: timestamp().notNull().defaultNow(),
|
createdAt: timestamp().notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index('idx_userid_skuId').on(table.userId, table.skuId)
|
index('idx_userid_skuId').on(table.userId, table.skuId),
|
||||||
|
index('idx_userid_catalogName').on(table.userId, table.catalogName)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const processingSkus = pokeSchema.table('processing_skus', {
|
export const processingSkus = pokeSchema.table('processing_skus', {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare global {
|
|||||||
namespace App {
|
namespace App {
|
||||||
interface Locals {
|
interface Locals {
|
||||||
canAddInventory: boolean;
|
canAddInventory: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,53 +27,31 @@ export const onRequest = clerkMiddleware(async (auth, context, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Inventory visibility check ──────────────────────────────────────────────
|
// ── Inventory visibility check ──────────────────────────────────────────────
|
||||||
// Resolves to true if the user belongs to the target org OR has the feature
|
|
||||||
const canAddInventory = process.env.INVENTORY_ACCESS === 'true' ||
|
const canAddInventory = process.env.INVENTORY_ACCESS === 'true' ||
|
||||||
(
|
(
|
||||||
isAuthenticated &&
|
isAuthenticated &&
|
||||||
userId &&
|
userId &&
|
||||||
(
|
(
|
||||||
!!has({ permission: "org:feature:inventory_add" }) || // Clerk feature flag
|
!!has({ permission: "org:feature:inventory_add" }) ||
|
||||||
(await getUserOrgIds(context, userId)).includes(TARGET_ORG_ID)
|
(await getUserOrgIds(context, userId)).includes(TARGET_ORG_ID)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Expose the flag to your Astro pages via locals
|
|
||||||
context.locals.canAddInventory = Boolean(canAddInventory);
|
context.locals.canAddInventory = Boolean(canAddInventory);
|
||||||
|
|
||||||
|
// ── Admin 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 ───────────────────────────────────────────
|
// ── Admin route guard ───────────────────────────────────────────
|
||||||
if (isAdminRoute(context.request)) {
|
if (isAdminRoute(context.request)) {
|
||||||
if (!isAuthenticated || !userId) {
|
if (!isAuthenticated || !userId) {
|
||||||
return redirectToSignIn();
|
return redirectToSignIn();
|
||||||
}
|
}
|
||||||
|
if (!context.locals.isAdmin) {
|
||||||
try {
|
return new Response(null, { status: 404 });
|
||||||
const client = await clerkClient(context);
|
|
||||||
const 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("/");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,4 +68,29 @@ async function getUserOrgIds(context: any, userId: string): Promise<string[]> {
|
|||||||
console.error("Failed to fetch user org memberships:", e);
|
console.error("Failed to fetch user org memberships:", e);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper: is this user an org:admin in any of ADMIN_ORG_IDS? ─────────────
|
||||||
|
async function checkIsAdmin(context: any, userId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const userOrgIds = await getUserOrgIds(context, userId);
|
||||||
|
const matchingOrgIds = userOrgIds.filter((id) => ADMIN_ORG_IDS.has(id));
|
||||||
|
if (matchingOrgIds.length === 0) return false;
|
||||||
|
|
||||||
|
const client = await clerkClient(context);
|
||||||
|
const membershipLists = await Promise.all(
|
||||||
|
matchingOrgIds.map((orgId) =>
|
||||||
|
client.organizations.getOrganizationMembershipList({ organizationId: orgId })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return membershipLists.some((list) =>
|
||||||
|
list.data.some(
|
||||||
|
(m) => m.publicUserData?.userId === userId && m.role === "org:admin"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Clerk membership check failed:", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
|
import { parse } from 'csv';
|
||||||
import { db } from '../../db/index';
|
import { db } from '../../db/index';
|
||||||
import { inventory, priceHistory } from '../../db/schema';
|
import { inventory, priceHistory } from '../../db/schema';
|
||||||
import { client } from '../../db/typesense';
|
import { client } from '../../db/typesense';
|
||||||
@@ -89,16 +90,42 @@ const getInventory = async (userId: string, cardId: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Build the Typesense document for an inventory row + its card details.
|
||||||
|
const inventoryTsDoc = (i: typeof inventory.$inferSelect, card: any) => ({
|
||||||
|
id: i.inventoryId,
|
||||||
|
userId: i.userId,
|
||||||
|
catalogName: i.catalogName,
|
||||||
|
sku_id: i.skuId.toString(),
|
||||||
|
purchasePrice: DollarToInt(i.purchasePrice),
|
||||||
|
productLineName: card?.productLineName,
|
||||||
|
rarityName: card?.rarityName,
|
||||||
|
setName: card?.set?.setName || "",
|
||||||
|
cardType: card?.cardType || "",
|
||||||
|
energyType: card?.energyType || "",
|
||||||
|
card_id: card?.cardId.toString() || "",
|
||||||
|
content: [
|
||||||
|
card?.productName,
|
||||||
|
card?.productLineName,
|
||||||
|
card?.set?.setName || "",
|
||||||
|
card?.number,
|
||||||
|
card?.rarityName,
|
||||||
|
card?.artist || ""
|
||||||
|
].join(' '),
|
||||||
|
});
|
||||||
|
|
||||||
const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
|
const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
|
||||||
// First add to database
|
// Insert one row per copy: a quantity of 5 becomes 5 separate entries of qty 1.
|
||||||
const inv = await db.insert(inventory).values({
|
const count = Math.max(1, Math.floor(quantity));
|
||||||
userId: userId,
|
const inv = await db.insert(inventory).values(
|
||||||
skuId: skuId,
|
Array.from({ length: count }, () => ({
|
||||||
catalogName: catalogName,
|
userId: userId,
|
||||||
purchasePrice: purchasePrice.toFixed(2),
|
skuId: skuId,
|
||||||
quantity: quantity,
|
catalogName: catalogName,
|
||||||
note: note,
|
purchasePrice: purchasePrice.toFixed(2),
|
||||||
}).returning();
|
quantity: 1,
|
||||||
|
note: note,
|
||||||
|
}))
|
||||||
|
).returning();
|
||||||
// Get card details from the database to add to Typesense
|
// Get card details from the database to add to Typesense
|
||||||
const card = await db.query.cards.findFirst({
|
const card = await db.query.cards.findFirst({
|
||||||
where: { cardId: cardId },
|
where: { cardId: cardId },
|
||||||
@@ -107,27 +134,7 @@ const addToInventory = async (userId: string, cardId: number, skuId: number, pur
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// And then add to Typesense for searching
|
// And then add to Typesense for searching
|
||||||
await client.collections('inventories').documents().import(inv.map(i => ({
|
await client.collections('inventories').documents().import(inv.map(i => inventoryTsDoc(i, card)));
|
||||||
id: i.inventoryId,
|
|
||||||
userId: i.userId,
|
|
||||||
catalogName: i.catalogName,
|
|
||||||
sku_id: i.skuId.toString(),
|
|
||||||
purchasePrice: DollarToInt(i.purchasePrice),
|
|
||||||
productLineName: card?.productLineName,
|
|
||||||
rarityName: card?.rarityName,
|
|
||||||
setName: card?.set?.setName || "",
|
|
||||||
cardType: card?.cardType || "",
|
|
||||||
energyType: card?.energyType || "",
|
|
||||||
card_id: card?.cardId.toString() || "",
|
|
||||||
content: [
|
|
||||||
card?.productName,
|
|
||||||
card?.productLineName,
|
|
||||||
card?.set?.setName || "",
|
|
||||||
card?.number,
|
|
||||||
card?.rarityName,
|
|
||||||
card?.artist || ""
|
|
||||||
].join(' '),
|
|
||||||
})));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding inventory to Typesense:', error);
|
console.error('Error adding inventory to Typesense:', error);
|
||||||
}
|
}
|
||||||
@@ -138,16 +145,139 @@ const removeFromInventory = async (inventoryId: string) => {
|
|||||||
await client.collections('inventories').documents(inventoryId).delete();
|
await client.collections('inventories').documents(inventoryId).delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateInventory = async (inventoryId: string, quantity: number, purchasePrice: number, note: string) => {
|
const updateInventory = async (inventoryId: string, quantity: number, purchasePrice: number, note: string, catalogName: string) => {
|
||||||
// Update the database
|
// Update the database
|
||||||
await db.update(inventory).set({
|
await db.update(inventory).set({
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
purchasePrice: purchasePrice.toFixed(2),
|
purchasePrice: purchasePrice.toFixed(2),
|
||||||
note: note,
|
note: note,
|
||||||
|
catalogName: catalogName,
|
||||||
}).where(eq(inventory.inventoryId, inventoryId));
|
}).where(eq(inventory.inventoryId, inventoryId));
|
||||||
// No need to update Typesense since we don't search by quantity or price
|
// Quantity/price/note aren't searched, but catalogName is faceted in Typesense.
|
||||||
|
try {
|
||||||
|
await client.collections('inventories').documents(inventoryId).update({ catalogName });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating inventory catalog in Typesense:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Bulk CSV import ─────────────────────────────────────────────────────────
|
||||||
|
// Expected columns (header row, case-insensitive): name, set, condition, qty,
|
||||||
|
// price, market. An optional `date` column is used as the inventory createdAt;
|
||||||
|
// rows without a (valid) date default to the current time.
|
||||||
|
const CONDITION_MAP: Record<string, string> = {
|
||||||
|
'nm': 'Near Mint', 'near mint': 'Near Mint', 'mint': 'Near Mint',
|
||||||
|
'lp': 'Lightly Played', 'lightly played': 'Lightly Played',
|
||||||
|
'mp': 'Moderately Played', 'moderately played': 'Moderately Played',
|
||||||
|
'hp': 'Heavily Played', 'heavily played': 'Heavily Played',
|
||||||
|
'dmg': 'Damaged', 'dm': 'Damaged', 'damaged': 'Damaged',
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCondition = (value?: string) => {
|
||||||
|
const key = (value || '').trim().toLowerCase();
|
||||||
|
return CONDITION_MAP[key] || 'Near Mint';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse an optional date cell into a Date, or null when empty/invalid.
|
||||||
|
const parseImportDate = (value?: string): Date | null => {
|
||||||
|
const raw = (value || '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const date = new Date(raw);
|
||||||
|
return isNaN(date.getTime()) ? null : date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseCsv = (text: string): Promise<Record<string, string>[]> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
parse(text, { columns: true, trim: true, skip_empty_lines: true, bom: true },
|
||||||
|
(err, records) => (err ? reject(err) : resolve(records)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Case-insensitive column lookup for a parsed CSV row.
|
||||||
|
const lowerKeys = (row: Record<string, string>) => {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(row)) out[k.trim().toLowerCase()] = v;
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findCardId = async (name: string, set: string): Promise<number | undefined> => {
|
||||||
|
const escapedSet = set.replace(/`/g, '');
|
||||||
|
let result = await client.collections('cards').documents().search({
|
||||||
|
q: name, query_by: 'productName', per_page: 1,
|
||||||
|
...(escapedSet ? { filter_by: `setName:\`${escapedSet}\`` } : {}),
|
||||||
|
});
|
||||||
|
// Fall back to a name-only match if the set filter found nothing.
|
||||||
|
if (!result.hits?.length && escapedSet) {
|
||||||
|
result = await client.collections('cards').documents().search({
|
||||||
|
q: name, query_by: 'productName', per_page: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.hits?.[0]?.document?.cardId as number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const importInventory = async (userId: string, rows: Record<string, string>[], catalogName: string) => {
|
||||||
|
const skipped: { row: number; name: string; reason: string }[] = [];
|
||||||
|
const tsDocs: ReturnType<typeof inventoryTsDoc>[] = [];
|
||||||
|
let imported = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const rowNumber = i + 2; // account for header row (1-indexed for humans)
|
||||||
|
const row = lowerKeys(rows[i]);
|
||||||
|
const name = (row.name || '').trim();
|
||||||
|
const set = (row.set || '').trim();
|
||||||
|
|
||||||
|
if (!name) { skipped.push({ row: rowNumber, name, reason: 'Missing card name' }); continue; }
|
||||||
|
|
||||||
|
const createdAt = parseImportDate(row.date);
|
||||||
|
if ((row.date || '').trim() && !createdAt) {
|
||||||
|
skipped.push({ row: rowNumber, name, reason: `Invalid date "${row.date.trim()}"` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const condition = normalizeCondition(row.condition);
|
||||||
|
const count = Math.max(1, parseInt(row.qty, 10) || 1);
|
||||||
|
const purchasePrice = parseFloat(String(row.price ?? '').replace(/[^0-9.]/g, '')) || 0;
|
||||||
|
|
||||||
|
const cardId = await findCardId(name, set);
|
||||||
|
if (!cardId) { skipped.push({ row: rowNumber, name, reason: 'Card not found' }); continue; }
|
||||||
|
|
||||||
|
const sku = await db.query.skus.findFirst({
|
||||||
|
where: { cardId: cardId, condition: condition },
|
||||||
|
columns: { skuId: true },
|
||||||
|
});
|
||||||
|
if (!sku?.skuId) { skipped.push({ row: rowNumber, name, reason: `No "${condition}" SKU for card` }); continue; }
|
||||||
|
|
||||||
|
// One row per copy: a qty of 5 becomes 5 separate entries of qty 1.
|
||||||
|
const inserted = await db.insert(inventory).values(
|
||||||
|
Array.from({ length: count }, () => ({
|
||||||
|
userId,
|
||||||
|
skuId: sku.skuId,
|
||||||
|
catalogName,
|
||||||
|
purchasePrice: purchasePrice.toFixed(2),
|
||||||
|
quantity: 1,
|
||||||
|
note: '',
|
||||||
|
...(createdAt ? { createdAt } : {}),
|
||||||
|
}))
|
||||||
|
).returning();
|
||||||
|
|
||||||
|
const card = await db.query.cards.findFirst({
|
||||||
|
where: { cardId: cardId },
|
||||||
|
with: { set: true },
|
||||||
|
});
|
||||||
|
for (const entry of inserted) tsDocs.push(inventoryTsDoc(entry, card));
|
||||||
|
imported += inserted.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tsDocs.length) {
|
||||||
|
try {
|
||||||
|
await client.collections('inventories').documents().import(tsDocs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing inventory to Typesense:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imported, skipped };
|
||||||
|
};
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
// Access form data from the request body
|
// Access form data from the request body
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
@@ -173,6 +303,33 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
await addToInventory(userId!, cardId, skuId, purchasePrice, quantity, note, catalogName);
|
await addToInventory(userId!, cardId, skuId, purchasePrice, quantity, note, catalogName);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'import': {
|
||||||
|
if (!userId) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Not authenticated' }), {
|
||||||
|
status: 401, headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
if (!file) {
|
||||||
|
return new Response(JSON.stringify({ error: 'No file uploaded' }), {
|
||||||
|
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const importCatalog = formData.get('catalogName')?.toString()?.trim() || 'Default';
|
||||||
|
try {
|
||||||
|
const rows = await parseCsv(await file.text());
|
||||||
|
const summary = await importInventory(userId, rows, importCatalog);
|
||||||
|
return new Response(JSON.stringify(summary), {
|
||||||
|
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing inventory CSV:', error);
|
||||||
|
return new Response(JSON.stringify({ error: 'Could not parse CSV file' }), {
|
||||||
|
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case 'remove':
|
case 'remove':
|
||||||
const inventoryId = formData.get('inventoryId')?.toString() || '';
|
const inventoryId = formData.get('inventoryId')?.toString() || '';
|
||||||
await removeFromInventory(inventoryId);
|
await removeFromInventory(inventoryId);
|
||||||
@@ -183,7 +340,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const qty = Number(formData.get('quantity')) || 1;
|
const qty = Number(formData.get('quantity')) || 1;
|
||||||
const price = Number(formData.get('purchasePrice')) || 0;
|
const price = Number(formData.get('purchasePrice')) || 0;
|
||||||
const invNote = formData.get('note')?.toString() || '';
|
const invNote = formData.get('note')?.toString() || '';
|
||||||
await updateInventory(invId, qty, price, invNote);
|
const invCatalog = formData.get('catalogName')?.toString() || 'Default';
|
||||||
|
await updateInventory(invId, qty, price, invNote, invCatalog);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../layouts/Main.astro";
|
export const prerender = false;
|
||||||
import Footer from "../components/Footer.astro";
|
import Layout from '../layouts/Main.astro';
|
||||||
import BackToTop from "../components/BackToTop.astro";
|
|
||||||
import FirstEditionIcon from "../components/FirstEditionIcon.astro";
|
|
||||||
import { db } from '../db/index';
|
import { db } from '../db/index';
|
||||||
import { inventory, skus } from '../db/schema';
|
import { inventory, skus } from '../db/schema';
|
||||||
import { sql, sum, eq } from "drizzle-orm";
|
import { sql, sum, eq } from "drizzle-orm";
|
||||||
@@ -26,207 +24,109 @@ const totalQty = summary.totalQty || 0;
|
|||||||
const totalValue = summary.totalValue || 0;
|
const totalValue = summary.totalValue || 0;
|
||||||
const totalGain = summary.totalGain || 0;
|
const totalGain = summary.totalGain || 0;
|
||||||
|
|
||||||
|
// distinct catalog names for this user, for the sidebar catalog list
|
||||||
|
const catalogRows = userId
|
||||||
|
? await db
|
||||||
|
.selectDistinct({ name: inventory.catalogName })
|
||||||
|
.from(inventory)
|
||||||
|
.where(eq(inventory.userId, userId))
|
||||||
|
: [];
|
||||||
|
const catalogs = catalogRows
|
||||||
|
.map(r => r.name)
|
||||||
|
.filter((n): n is string => !!n)
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Inventory Dashboard">
|
<Layout title="Inventory Dashboard">
|
||||||
<div class="container-fluid container-sm mt-3" slot="page">
|
<div class="container-fluid container-sm" slot="page">
|
||||||
<BackToTop />
|
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<aside class="col-12 col-md-2 border-end border-secondary bg-dark p-3 d-flex flex-column gap-3">
|
<aside
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
class="offcanvas-xl offcanvas-start col-12 col-xl-2 border-end border-secondary bg-dark p-3 d-flex flex-column gap-3 catalog-sidebar"
|
||||||
<h6 class="mb-0 text-uppercase text-secondary fw-bold ls-wide" style="letter-spacing:.08em">Catalogs</h6>
|
tabindex="-1"
|
||||||
<button
|
id="catalogSidebar"
|
||||||
class="btn btn-purple-secondary fs-7"
|
aria-labelledby="catalogSidebarLabel"
|
||||||
title="New catalog"
|
>
|
||||||
type="button"
|
<div class="offcanvas-header d-xl-none">
|
||||||
data-bs-toggle="modal"
|
<h6 class="offcanvas-title text-uppercase text-secondary fw-bold ls-wide" id="catalogSidebarLabel" style="letter-spacing:.08em">Catalogs</h6>
|
||||||
data-bs-target="#newCatalogModal"
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" data-bs-target="#catalogSidebar" aria-label="Close"></button>
|
||||||
>+ New</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul id="catalogList" class="list-group list-group-flush">
|
<div class="offcanvas-body d-xl-flex flex-column gap-3 p-0">
|
||||||
<li
|
<div class="d-none d-xl-flex justify-content-between align-items-center">
|
||||||
class="list-group-item list-group-item-action fw-semibold border-0 rounded p-2 d-flex align-items-center active"
|
<h6 class="mb-0 text-uppercase text-secondary fw-bold ls-wide" style="letter-spacing:.08em">Catalogs</h6>
|
||||||
data-catalog="all"
|
</div>
|
||||||
role="button"
|
|
||||||
style="cursor:pointer"
|
|
||||||
>
|
|
||||||
<span class="d-flex align-items-center gap-2">
|
|
||||||
View all cards
|
|
||||||
</span>
|
|
||||||
<span class="badge rounded-pill text-bg-secondary small ms-auto">{totalQty}</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
{["Case Cards", "Japanese Singles", "Bulk"].map((name) => (
|
<ul id="catalogList" class="list-group list-group-flush">
|
||||||
<li
|
<li
|
||||||
class="ms-2 list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center gap-2"
|
class="list-group-item list-group-item-action fw-semibold border-0 rounded p-2 d-flex align-items-center active"
|
||||||
data-catalog={name}
|
data-catalog="all"
|
||||||
role="button"
|
role="button"
|
||||||
style="cursor:pointer"
|
style="cursor:pointer"
|
||||||
|
hx-post="/partials/inventory-cards"
|
||||||
|
hx-vals={JSON.stringify({ catalog: "all" })}
|
||||||
|
hx-target="#gridView"
|
||||||
|
hx-swap="innerHTML"
|
||||||
>
|
>
|
||||||
{name}
|
<span class="d-flex align-items-center gap-2">
|
||||||
|
All cards
|
||||||
|
</span>
|
||||||
|
<span class="badge rounded-pill text-bg-secondary small ms-auto">{totalQty}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="mt-auto pt-3 border-top border-secondary small text-secondary">
|
{catalogs.map((name) => (
|
||||||
<div class="d-flex justify-content-between mb-1"><span>Total Cards</span><span class="text-light fw-semibold">{totalQty}</span></div>
|
<li
|
||||||
<div class="d-flex justify-content-between mb-1"><span>Market Value</span><span class="text-success fw-semibold">${totalValue.toFixed(0)}</span></div>
|
class="ms-2 list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center gap-2"
|
||||||
<div class="d-flex justify-content-between"><span>Gain/Loss</span><span class={`fw-semibold ${totalGain >= 0 ? "text-success" : "text-danger"}`}>{totalGain >= 0 ? "+" : ""}${Math.abs(totalGain).toFixed(0)}</span></div>
|
data-catalog={name}
|
||||||
|
role="button"
|
||||||
|
style="cursor:pointer"
|
||||||
|
hx-post="/partials/inventory-cards"
|
||||||
|
hx-vals={JSON.stringify({ catalog: name })}
|
||||||
|
hx-target="#gridView"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-auto pt-3 border-top border-secondary small text-secondary">
|
||||||
|
<div class="d-flex justify-content-between mb-1"><span>Total Cards</span><span class="text-light fw-semibold">{totalQty}</span></div>
|
||||||
|
<div class="d-flex justify-content-between mb-1"><span>Market Value</span><span class="text-success fw-semibold">${totalValue.toFixed(0)}</span></div>
|
||||||
|
<div class="d-flex justify-content-between"><span>Gain/Loss</span><span class={`fw-semibold ${totalGain >= 0 ? "text-success" : "text-danger"}`}>{totalGain >= 0 ? "+" : ""}${Math.abs(totalGain).toFixed(0)}</span></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="col-12 col-md-10 p-4">
|
<main class="col-12 col-xl-10 mt-4">
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-center mb-4">
|
<div class="d-flex flex-wrap flex-row gap-2 mb-4">
|
||||||
<div class="d-flex align-items-center gap-1">
|
<button
|
||||||
<button id="btnGrid" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn active" title="Images view">
|
type="button"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1.5rem" height="1.5rem" fill="currentColor"><path d="M351.6 220.4C349.7 258.3 381.1 288.3 418 286.5L527.6 281.1C593 275.5 589.7 214.9 589 162.5C593.9 90.4 588.4 39.6 502.4 44.6C391.5 42.2 352.5 36.7 354.5 164.3C353.4 183.3 352.4 202.4 351.5 220.3zM418.4 168.6L418.4 168.6C419.6 147.4 420.8 125.9 421.6 109.4C421.6 109.2 421.6 109.1 421.7 109C421.7 108.8 422 108.6 422.1 108.6C456.2 108.5 491.4 108.6 525.7 108.7C525.8 108.7 526 109 526.1 109.1L526.1 109.3C525.5 142.4 524.9 184.6 524.2 217.3L415.6 222.6C416.3 207.3 417.4 188.1 418.5 168.7zM301.4 112.5C303.3 74.7 272 44.6 235 46.4L125.4 51.8C40.8 58.8 69.2 164.2 63.1 222.4C62.4 258.3 91.2 288.3 127.5 288.3C159.4 288.3 198.3 288.3 231 288.3C298.6 286.3 297.2 220.3 298.5 168.6C299.6 149.6 300.6 130.5 301.5 112.6zM234.6 164.3C233.4 185.6 232.2 207 231.4 223.5C231.4 223.7 231.4 223.8 231.3 223.9C231.3 224 231.1 224.2 231.1 224.2L231 224.3C198.3 224.2 159.3 224.3 127.3 224.3C127.3 224.3 127.2 224.3 127.1 224.2C127 224.1 126.9 224 126.9 224L126.9 223.8C127.5 192.1 128.1 149.9 128.8 115.8L237.4 110.5C236.7 125.8 235.6 145 234.5 164.4zM63.6 404C64.5 421.9 65.5 441 66.6 460C68.2 512.2 65.9 577.1 134.1 579.7L237.6 579.7C273.9 579.7 302.7 549.7 302 513.9C301.7 498.8 301.4 480.4 301.1 461.9C301.8 409.5 305 348.9 239.7 343.3L130 338C93 336.2 61.7 366.2 63.6 404.1zM130.4 455.8C129.3 436.4 128.3 417.1 127.5 401.8L236.1 407.1C236.8 441.2 237.3 483.4 238 515C238 515.1 238 515.1 238 515.2C238 515.3 237.9 515.4 237.8 515.5C237.7 515.6 237.6 515.6 237.6 515.6C205.8 515.6 166.4 515.5 134 515.6C133.9 515.6 133.7 515.3 133.7 515.2C133.6 515.1 133.6 515 133.6 514.8C132.8 498.3 131.6 476.9 130.4 455.6L130.4 455.6zM523 578.1C560 579.9 591.3 549.8 589.4 512C588.5 494.1 587.5 475 586.4 456C585.9 381.4 580.2 328.2 490 336.3C457.9 336.4 439 336.3 415.4 336.3C379.1 336.3 350.3 366.3 351 402.1C351.3 417.2 351.6 435.6 351.9 454.1C351.2 506.5 348 567.1 413.3 572.7L523 578.1zM525.5 514.1L416.9 508.8C416.2 474.7 415.7 432.5 415 400.9L415 400.7C415 400.6 415.3 400.3 415.3 400.3C443.6 400.3 484.7 400.3 518.9 400.3C518.9 400.3 519 400.3 519.1 400.4C519.2 400.5 519.3 400.6 519.3 400.7C519.4 400.8 519.4 400.9 519.4 401.1C521.1 435.4 524 484.9 525.4 514.3z"/></svg>
|
class="btn btn-secondary d-xl-none me-auto"
|
||||||
<span class="ms-1">Grid</span>
|
data-bs-toggle="offcanvas"
|
||||||
</button>
|
data-bs-target="#catalogSidebar"
|
||||||
<button id="btnTable" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn" title="List view">
|
aria-controls="catalogSidebar"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1.5rem" height="1.5rem" fill="currentColor" style="d-inline"><path d="M102.3 211.8C110.7 214 118.9 213.7 126.2 210C132.6 208 138.3 204 142.4 198.4C184.2 169.6 148.5 98.8 98.3 108.3C39.6 119.9 49.3 205.8 102.2 211.8zM114.4 375.6C114.4 375.6 114.8 375.5 116.2 375.3L116.5 375.2C169.2 368.9 178.6 283.3 120.1 271.7C69.9 262.3 34.2 332.9 76 361.8C80.1 367.4 85.9 371.4 92.3 373.4C99 376.6 106.8 377.6 114.5 375.5zM116.8 423.5C63.5 413.9 30 495.4 84.4 522.3C130.5 544.4 183.2 485.4 150.4 446.7C147.9 440.2 143.4 434.9 137.7 431.2C132.1 426.4 124.8 423.5 116.8 423.5zM352.8 508.4C423.1 503.6 491.2 499 561.5 501.8C579.2 502.5 594.1 488.8 594.8 471.1C595.5 453.4 581.7 438.6 564.1 437.9C490.4 435 416.4 439.9 344.2 444.8C310.8 447 277.8 449.3 245.4 450.7C227.7 451.4 214 466.4 214.8 484C215.5 501.7 230.5 515.4 248.1 514.6C283.8 513 318.6 510.7 352.8 508.4zM344 344.8L344.3 344.8C412.9 343.4 479.9 342.9 548.3 346.2C566 347 580.9 333.3 581.7 315.6C582.5 298 568.8 283 551.1 282.2C482.2 278.8 412.2 279.3 343.1 280.7C310.3 281.3 277.9 281.9 245.8 281.9C228.1 281.9 213.8 296.2 213.8 313.9C213.8 331.6 228.1 345.9 245.8 345.9C278.5 345.9 311.4 345.3 343.9 344.7zM444.8 187.4C480.8 186.5 519 181.7 551.8 187.6C569.2 190.7 585.8 179.1 588.9 161.7C592 144.3 580.4 127.7 563 124.6C484.4 115.2 408.3 126.5 331 129.9C301.8 131.9 273.7 133 246 131.4C228.4 130.3 213.2 143.8 212.2 161.4C213.8 217.2 300.2 190.8 335.5 193.7C372.2 191.2 408.7 188.4 444.8 187.4z"/></svg>
|
>
|
||||||
<span class="ms-1">List</span>
|
Catalogs
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<a href="/pokemon" class="btn btn-vendor ms-auto">Add cards</a>
|
||||||
|
|
||||||
<div class="vr opacity-25 mx-1"></div>
|
|
||||||
|
|
||||||
<a href="/pokemon" class="btn btn-vendor">+ Add Card</a>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary"
|
class="btn btn-outline-secondary"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#bulkImportModal"
|
data-bs-target="#bulkImportModal"
|
||||||
>Bulk Import</button>
|
>Bulk Import</button>
|
||||||
|
|
||||||
<div class="ms-auto position-relative">
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="hidden" name="start" id="start" value="0" />
|
|
||||||
<input type="hidden" name="sort" id="sortInput" value="" />
|
|
||||||
<input type="hidden" name="language" id="languageInput" value="all" />
|
|
||||||
<input type="search" name="i" id="searchInput" class="form-control search-input" placeholder="Search your inventory" />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-purple border-start-0"
|
|
||||||
aria-label="search"
|
|
||||||
onclick="
|
|
||||||
const i = this.closest('form').querySelector('[name=i]').value;
|
|
||||||
dataLayer.push({ event: 'view_inventory_results', search_term: i });
|
|
||||||
if (window.location.pathname !== '/dashboard') {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
sessionStorage.setItem('pendingSearch', 1);
|
|
||||||
window.location.href = '/dashboard';
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="inventoryView">
|
<div id="inventoryView">
|
||||||
<div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load">
|
<div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div id="tableView" style="display:none">
|
|
||||||
<div class="inv-list-wrap">
|
|
||||||
<table class="table align-middle mb-0 inv-list-table">
|
|
||||||
<tbody id="inventoryRows">
|
|
||||||
{inventory.map(card => {
|
|
||||||
const market = nmPrice(card);
|
|
||||||
const purchase = nmPurchase(card);
|
|
||||||
const diff = market - purchase;
|
|
||||||
const pct = purchase > 0 ? (diff / purchase) * 100 : 0;
|
|
||||||
const isGain = diff >= 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr class="inv-list-row">
|
|
||||||
<td class="inv-list-cardcell">
|
|
||||||
<div class="inv-list-card">
|
|
||||||
<div
|
|
||||||
class="inv-list-thumb card-trigger"
|
|
||||||
data-card-id={card.productId}
|
|
||||||
data-bs-toggle="modal"
|
|
||||||
data-bs-target="#cardModal"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={`/cards/${card.productId}.jpg`}
|
|
||||||
alt={card.productName}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
onerror="this.onerror=null;this.src='/cards/default.jpg';"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inv-list-info">
|
|
||||||
<div
|
|
||||||
class="inv-list-name"
|
|
||||||
data-card-id={card.productId}
|
|
||||||
data-bs-toggle="modal"
|
|
||||||
data-bs-target="#cardModal"
|
|
||||||
style="cursor:pointer"
|
|
||||||
>
|
|
||||||
{card.productName}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inv-list-meta">
|
|
||||||
<div class="inv-list-setlink">{card.setName}</div>
|
|
||||||
<div>{card.rarityName}</div>
|
|
||||||
<div>{card.number}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inv-list-condition">
|
|
||||||
<span>Near Mint</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{card.variant !== "Normal" ? card.variant : "Holofoil"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inv-list-right">
|
|
||||||
<div class={`inv-list-price-line ${isGain ? "up" : "down"}`}>
|
|
||||||
<span class="inv-grid-arrow small">{isGain ? "▲" : "▼"}</span>
|
|
||||||
<span class="inv-list-price">${market.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
<div class={`inv-list-delta ${isGain ? "up" : "down"}`}>
|
|
||||||
{isGain ? "+" : "-"}${Math.abs(diff).toFixed(2)} ({isGain ? "+" : "-"}{Math.abs(pct).toFixed(2)}%)
|
|
||||||
</div>
|
|
||||||
<div class="inv-list-qty">Qty: {card.qty}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="text-secondary small mt-2 ps-1" id="rowCount"></div>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div class="modal fade" id="newCatalogModal" tabindex="-1" aria-labelledby="newCatalogLabel" aria-modal="true" role="dialog">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content bg-dark text-light border border-secondary">
|
|
||||||
<div class="modal-header border-secondary">
|
|
||||||
<h5 class="modal-title" id="newCatalogLabel">Create Catalog</h5>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<label class="form-label small text-secondary text-uppercase fw-semibold" for="catalogNameInput">Catalog Name</label>
|
|
||||||
<input id="catalogNameInput" type="text" class="form-control bg-dark-subtle text-light border-secondary" placeholder="e.g. Japanese Holos" />
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer border-secondary">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-success" id="createCatalogBtn">Create</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>-->
|
|
||||||
|
|
||||||
<div class="modal fade" id="bulkImportModal" tabindex="-1" aria-labelledby="bulkImportLabel" aria-modal="true" role="dialog">
|
<div class="modal fade" id="bulkImportModal" tabindex="-1" aria-labelledby="bulkImportLabel" aria-modal="true" role="dialog">
|
||||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||||
@@ -236,60 +136,403 @@ const totalGain = summary.totalGain || 0;
|
|||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p class="small text-secondary mb-3">
|
<p class="small text-secondary mb-2">
|
||||||
Upload a CSV exported from Collectr, TCGPlayer, or any marketplace. Columns: <code>name, set, condition, qty, price, market</code>.
|
Upload a CSV exported from Collectr, TCGPlayer, or any marketplace. Columns: <code>name, set, condition, qty, price, market</code>.
|
||||||
</p>
|
</p>
|
||||||
|
<p class="small text-secondary mb-3">
|
||||||
|
Optionally add a <code>date</code> column (e.g. <code>2026-07-16</code>) to record when each card was acquired — it’s used as the entry’s added/purchase date. Rows without a date default to today.
|
||||||
|
</p>
|
||||||
<label class="form-label small text-secondary text-uppercase fw-semibold" for="csvFileInput">Choose File</label>
|
<label class="form-label small text-secondary text-uppercase fw-semibold" for="csvFileInput">Choose File</label>
|
||||||
<input id="csvFileInput" type="file" accept=".csv" class="form-control bg-dark-subtle text-light border-secondary" />
|
<input id="csvFileInput" type="file" accept=".csv" class="form-control bg-dark-subtle text-light border-secondary" />
|
||||||
<div id="csvPreview" class="mt-3 d-none">
|
<div id="csvPreview" class="mt-3 d-none">
|
||||||
<p class="small text-secondary fw-semibold mb-1">Preview</p>
|
<p class="small text-secondary fw-semibold mb-1">Preview</p>
|
||||||
<div class="border border-secondary rounded p-2 small text-secondary" id="csvPreviewContent">—</div>
|
<div class="border border-secondary rounded p-2 small text-secondary overflow-auto" id="csvPreviewContent" style="max-height: 12rem;">—</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="csvImportResult" class="mt-3 d-none"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer border-secondary">
|
<div class="modal-footer border-secondary">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-danger" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-success" id="csvUploadBtn">Upload & Preview</button>
|
<button type="button" class="btn btn-success" id="csvUploadBtn">Upload</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!--
|
|
||||||
<div class="modal fade" id="inventoryEditModal" tabindex="-1" aria-labelledby="inventoryEditLabel" aria-modal="true" role="dialog">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content bg-dark text-light border border-secondary">
|
|
||||||
<div class="modal-header border-secondary">
|
|
||||||
<h5 class="modal-title" id="inventoryEditLabel">Edit Inventory</h5>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" id="inventoryEditBody">
|
|
||||||
<p class="text-secondary small">Select a card to edit its quantity and purchase price.</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer border-secondary">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-success">Save</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="addCardModal" tabindex="-1" aria-labelledby="addCardLabel" aria-modal="true" role="dialog">
|
<!-- Inventory detail modal (loaded from /partials/inventory-modal on open) -->
|
||||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
<div class="modal card-modal" id="inventoryModal" tabindex="-1" aria-labelledby="inventoryModalLabel" aria-hidden="true">
|
||||||
<div class="modal-content bg-dark text-light border border-secondary">
|
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
|
||||||
<div class="modal-header border-secondary">
|
<div class="modal-content p-2">Loading...</div>
|
||||||
<h5 class="modal-title" id="addCardLabel">Add Card</h5>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<input type="text" class="form-control bg-dark-subtle text-light border-secondary mb-3" placeholder="Search card name…" id="addCardSearch" />
|
|
||||||
<p class="text-secondary small">Search results will appear here. Connect to your card database API to enable live search.</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer border-secondary">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" disabled>Add Selected</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div> -->
|
</div>
|
||||||
|
|
||||||
|
<script is:inline>
|
||||||
|
(function () {
|
||||||
|
const catalogList = document.getElementById('catalogList');
|
||||||
|
const searchInput = document.getElementById('inventorySearchInput');
|
||||||
|
const searchBtn = document.getElementById('inventorySearchBtn');
|
||||||
|
|
||||||
|
if (catalogList) {
|
||||||
|
catalogList.addEventListener('click', (e) => {
|
||||||
|
const li = e.target.closest('li[data-catalog]');
|
||||||
|
if (!li) return;
|
||||||
|
catalogList.querySelectorAll('li[data-catalog]').forEach(el => el.classList.remove('active'));
|
||||||
|
li.classList.add('active');
|
||||||
|
// switching catalogs clears the search box so its contents don't misrepresent the grid
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// analytics only — the search request itself is fired declaratively via htmx attributes
|
||||||
|
function pushSearchEvent() {
|
||||||
|
if (window.dataLayer) {
|
||||||
|
window.dataLayer.push({ event: 'view_inventory_results', search_term: searchInput?.value.trim() || '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
searchBtn?.addEventListener('click', pushSearchEvent);
|
||||||
|
searchInput?.addEventListener('keyup', (e) => { if (e.key === 'Enter') pushSearchEvent(); });
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Bulk CSV import ──────────────────────────────────────────────────────
|
||||||
|
(function () {
|
||||||
|
const importModal = document.getElementById('bulkImportModal');
|
||||||
|
const fileInput = document.getElementById('csvFileInput');
|
||||||
|
const uploadBtn = document.getElementById('csvUploadBtn');
|
||||||
|
const preview = document.getElementById('csvPreview');
|
||||||
|
const previewContent = document.getElementById('csvPreviewContent');
|
||||||
|
const result = document.getElementById('csvImportResult');
|
||||||
|
if (!importModal || !fileInput || !uploadBtn) return;
|
||||||
|
|
||||||
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
|
||||||
|
let selectedFile = null;
|
||||||
|
let didImport = false;
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
selectedFile = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null;
|
||||||
|
result.classList.add('d-none');
|
||||||
|
if (!selectedFile) { preview.classList.add('d-none'); return; }
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const lines = String(reader.result || '').split(/\r?\n/).filter((l) => l.trim()).slice(0, 6);
|
||||||
|
previewContent.innerHTML = lines
|
||||||
|
.map((l, i) => `<div class="${i === 0 ? 'text-light fw-semibold' : ''}">${esc(l)}</div>`)
|
||||||
|
.join('');
|
||||||
|
preview.classList.remove('d-none');
|
||||||
|
};
|
||||||
|
reader.readAsText(selectedFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadBtn.addEventListener('click', async () => {
|
||||||
|
if (!selectedFile) { alert('Choose a CSV file first.'); return; }
|
||||||
|
const original = uploadBtn.textContent;
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
uploadBtn.textContent = 'Importing…';
|
||||||
|
result.classList.add('d-none');
|
||||||
|
try {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append('action', 'import');
|
||||||
|
body.append('file', selectedFile);
|
||||||
|
const res = await fetch('/api/inventory', { method: 'POST', body });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
result.className = 'mt-3 alert alert-danger py-2 small';
|
||||||
|
result.textContent = data.error || 'Import failed.';
|
||||||
|
result.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
didImport = data.imported > 0;
|
||||||
|
const skipped = data.skipped || [];
|
||||||
|
let html = `<div class="fw-semibold ${data.imported > 0 ? 'text-success' : ''}">Imported ${data.imported} card${data.imported === 1 ? '' : 's'}.</div>`;
|
||||||
|
if (skipped.length) {
|
||||||
|
html += `<div class="mt-1 text-warning fw-semibold">Skipped ${skipped.length}:</div>`;
|
||||||
|
html += '<ul class="mb-0 ps-3">' + skipped
|
||||||
|
.map((s) => `<li>Row ${s.row}${s.name ? ' (' + esc(s.name) + ')' : ''}: ${esc(s.reason)}</li>`)
|
||||||
|
.join('') + '</ul>';
|
||||||
|
}
|
||||||
|
result.className = 'mt-3 alert alert-dark py-2 small overflow-auto';
|
||||||
|
result.style.maxHeight = '14rem';
|
||||||
|
result.innerHTML = html;
|
||||||
|
result.classList.remove('d-none');
|
||||||
|
uploadBtn.textContent = didImport ? 'Done' : 'Upload';
|
||||||
|
} catch (e) {
|
||||||
|
result.className = 'mt-3 alert alert-danger py-2 small';
|
||||||
|
result.textContent = 'Import failed.';
|
||||||
|
result.classList.remove('d-none');
|
||||||
|
} finally {
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
if (uploadBtn.textContent === 'Importing…') uploadBtn.textContent = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh the dashboard (grid, catalogs, totals) after a successful import.
|
||||||
|
importModal.addEventListener('hidden.bs.modal', () => {
|
||||||
|
if (didImport) window.location.reload();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const modal = document.getElementById('inventoryModal');
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
const CONDITION_COLORS = {
|
||||||
|
"Near Mint": { active: 'hsla(88, 50%, 67%, 1)', muted: 'hsla(88, 50%, 67%, 0.67)' },
|
||||||
|
"Lightly Played": { active: 'hsla(66, 70%, 68%, 1)', muted: 'hsla(66, 70%, 68%, 0.67)' },
|
||||||
|
"Moderately Played": { active: 'hsla(54, 100%, 73%, 1)', muted: 'hsla(54, 100%, 73%, 0.67)' },
|
||||||
|
"Heavily Played": { active: 'hsla(46, 100%, 65%, 1)', muted: 'hsla(46, 100%, 65%, 0.67)' },
|
||||||
|
"Damaged": { active: 'hsla(36, 100%, 65%, 1)', muted: 'hsla(36, 100%, 65%, 0.67)' },
|
||||||
|
};
|
||||||
|
const PURCHASE_COLOR = 'hsla(258, 90%, 76%, 1)';
|
||||||
|
const RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '1y': 365, 'all': Infinity };
|
||||||
|
|
||||||
|
let chart = null;
|
||||||
|
let history = [];
|
||||||
|
let meta = { purchasePrice: null, purchaseDate: null, condition: 'Near Mint' };
|
||||||
|
let range = 'all';
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const [y, m, d] = dateStr.split('-');
|
||||||
|
return new Date(Number(y), Number(m) - 1, Number(d))
|
||||||
|
.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildData(rangeKey) {
|
||||||
|
const cutoff = RANGE_DAYS[rangeKey] === Infinity
|
||||||
|
? new Date(0)
|
||||||
|
: new Date(Date.now() - RANGE_DAYS[rangeKey] * 86400000);
|
||||||
|
|
||||||
|
const filtered = history.filter(r => new Date(r.calculatedAt) >= cutoff);
|
||||||
|
const showPurchase = meta.purchaseDate
|
||||||
|
&& meta.purchasePrice != null
|
||||||
|
&& new Date(meta.purchaseDate) >= cutoff;
|
||||||
|
|
||||||
|
const dateSet = new Set(filtered.map(r => r.calculatedAt));
|
||||||
|
if (showPurchase) dateSet.add(meta.purchaseDate);
|
||||||
|
const dates = [...dateSet].sort();
|
||||||
|
|
||||||
|
const priceByDate = {};
|
||||||
|
filtered.forEach(r => { priceByDate[r.calculatedAt] = Number(r.marketPrice); });
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: dates.map(formatDate),
|
||||||
|
lineData: dates.map(d => priceByDate[d] ?? null),
|
||||||
|
purchaseData: dates.map(d => (showPurchase && d === meta.purchaseDate) ? meta.purchasePrice : null),
|
||||||
|
hasData: filtered.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!chart) return;
|
||||||
|
const { labels, lineData, purchaseData } = buildData(range);
|
||||||
|
chart.data.labels = labels;
|
||||||
|
chart.data.datasets[0].data = lineData;
|
||||||
|
chart.data.datasets[1].data = purchaseData;
|
||||||
|
chart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChart(root) {
|
||||||
|
const canvas = root.querySelector('#inventoryPriceChart');
|
||||||
|
if (!canvas || typeof Chart === 'undefined') return;
|
||||||
|
if (chart) { chart.destroy(); chart = null; }
|
||||||
|
|
||||||
|
try { history = JSON.parse(canvas.dataset.history || '[]'); } catch { history = []; }
|
||||||
|
meta = {
|
||||||
|
purchasePrice: canvas.dataset.purchasePrice !== '' ? Number(canvas.dataset.purchasePrice) : null,
|
||||||
|
purchaseDate: canvas.dataset.purchaseDate || null,
|
||||||
|
condition: canvas.dataset.condition || 'Near Mint',
|
||||||
|
};
|
||||||
|
range = '6m';
|
||||||
|
root.querySelectorAll('.inv-price-range-btn').forEach(b =>
|
||||||
|
b.classList.toggle('active', b.dataset.range === '6m'));
|
||||||
|
|
||||||
|
const emptyEl = root.querySelector('#inventoryPriceEmpty');
|
||||||
|
const wrap = canvas.closest('.chart-canvas-wrap');
|
||||||
|
if (!history.length) {
|
||||||
|
emptyEl?.classList.remove('d-none');
|
||||||
|
wrap?.classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emptyEl?.classList.add('d-none');
|
||||||
|
wrap?.classList.remove('d-none');
|
||||||
|
|
||||||
|
const colors = CONDITION_COLORS[meta.condition] || CONDITION_COLORS['Near Mint'];
|
||||||
|
const { labels, lineData, purchaseData } = buildData(range);
|
||||||
|
|
||||||
|
chart = new Chart(canvas.getContext('2d'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Market Price',
|
||||||
|
data: lineData,
|
||||||
|
borderColor: colors.active,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
pointBackgroundColor: colors.active,
|
||||||
|
tension: 0.3,
|
||||||
|
fill: false,
|
||||||
|
spanGaps: true,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Purchase',
|
||||||
|
data: purchaseData,
|
||||||
|
showLine: false,
|
||||||
|
pointRadius: 6,
|
||||||
|
pointHoverRadius: 8,
|
||||||
|
pointStyle: 'rectRot',
|
||||||
|
pointBackgroundColor: PURCHASE_COLOR,
|
||||||
|
pointBorderColor: '#fff',
|
||||||
|
pointBorderWidth: 1,
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||||
|
titleColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
bodyColor: 'rgba(255, 255, 255, 0.75)',
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 10,
|
||||||
|
filter: (item) => item.parsed.y != null,
|
||||||
|
callbacks: {
|
||||||
|
labelColor: (ctx) => {
|
||||||
|
const c = ctx.dataset.label === 'Purchase' ? PURCHASE_COLOR : colors.active;
|
||||||
|
return { borderColor: c, backgroundColor: c };
|
||||||
|
},
|
||||||
|
label: (ctx) => {
|
||||||
|
if (ctx.dataset.label === 'Purchase') {
|
||||||
|
return ` Purchase: $${Number(ctx.parsed.y).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
return ` Market: $${Number(ctx.parsed.y).toFixed(2)}`;
|
||||||
|
},
|
||||||
|
afterLabel: (ctx) => {
|
||||||
|
if (ctx.dataset.label === 'Purchase' || meta.purchasePrice == null) return;
|
||||||
|
const diff = ctx.parsed.y - meta.purchasePrice;
|
||||||
|
const sign = diff >= 0 ? '+' : '−';
|
||||||
|
const pct = meta.purchasePrice > 0 ? (diff / meta.purchasePrice) * 100 : 0;
|
||||||
|
return `vs purchase: ${sign}$${Math.abs(diff).toFixed(2)} (${sign}${Math.abs(pct).toFixed(1)}%)`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||||
|
ticks: { color: 'rgba(255, 255, 255, 0.4)', maxTicksLimit: 6, maxRotation: 0 },
|
||||||
|
border: { color: 'rgba(255, 255, 255, 0.1)' },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||||
|
ticks: { color: 'rgba(255, 255, 255, 0.4)', callback: (v) => `$${Number(v).toFixed(2)}` },
|
||||||
|
border: { color: 'rgba(255, 255, 255, 0.1)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load modal content on open ──────────────────────────────────────────
|
||||||
|
modal.addEventListener('show.bs.modal', async (e) => {
|
||||||
|
const trigger = e.relatedTarget;
|
||||||
|
const inventoryId = trigger?.dataset.inventoryId;
|
||||||
|
const content = modal.querySelector('.modal-content');
|
||||||
|
if (!content) return;
|
||||||
|
if (!inventoryId) { content.innerHTML = '<div class="p-4 text-danger">Missing inventory id.</div>'; return; }
|
||||||
|
|
||||||
|
content.innerHTML = '<div class="p-5 text-center text-secondary">Loading…</div>';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/partials/inventory-modal?inventoryId=${encodeURIComponent(inventoryId)}`);
|
||||||
|
content.innerHTML = await res.text();
|
||||||
|
requestAnimationFrame(() => initChart(content));
|
||||||
|
} catch (err) {
|
||||||
|
content.innerHTML = '<div class="p-4 text-danger">Failed to load inventory entry.</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.addEventListener('hidden.bs.modal', () => {
|
||||||
|
if (chart) { chart.destroy(); chart = null; }
|
||||||
|
history = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Time-range buttons ──────────────────────────────────────────────────
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.inv-price-range-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
modal.querySelectorAll('.inv-price-range-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
range = btn.dataset.range || 'all';
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete inventory entry ──────────────────────────────────────────────
|
||||||
|
modal.addEventListener('click', async (e) => {
|
||||||
|
const delBtn = e.target.closest('[data-inventory-delete]');
|
||||||
|
if (!delBtn) return;
|
||||||
|
|
||||||
|
const form = modal.querySelector('[data-inventory-edit-form]');
|
||||||
|
const inventoryId = form?.querySelector('[name=inventoryId]')?.value;
|
||||||
|
const cardId = form?.querySelector('[name=cardId]')?.value;
|
||||||
|
if (!inventoryId) return;
|
||||||
|
if (!confirm('Are you sure you want to remove this card from your inventory?')) return;
|
||||||
|
|
||||||
|
const original = delBtn.textContent;
|
||||||
|
delBtn.disabled = true; delBtn.textContent = 'Deleting…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append('action', 'remove');
|
||||||
|
body.append('inventoryId', inventoryId);
|
||||||
|
if (cardId) body.append('cardId', cardId);
|
||||||
|
|
||||||
|
const res = await fetch('/api/inventory', { method: 'POST', body });
|
||||||
|
if (res.ok) {
|
||||||
|
// drop the matching grid tile, then close the modal
|
||||||
|
document.querySelector(`#gridView [data-inventory-id="${inventoryId}"]`)?.closest('.col')?.remove();
|
||||||
|
const instance = window.bootstrap?.Modal.getInstance(modal);
|
||||||
|
if (instance) instance.hide();
|
||||||
|
else modal.querySelector('[data-bs-dismiss=modal]')?.click();
|
||||||
|
} else {
|
||||||
|
delBtn.disabled = false; delBtn.textContent = original;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
delBtn.disabled = false; delBtn.textContent = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Save catalog + note ─────────────────────────────────────────────────
|
||||||
|
modal.addEventListener('submit', async (e) => {
|
||||||
|
const form = e.target.closest('[data-inventory-edit-form]');
|
||||||
|
if (!form) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const btn = modal.querySelector('button[type="submit"][form="inventoryEditForm"]');
|
||||||
|
const original = btn ? btn.textContent : '';
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/inventory', { method: 'POST', body: new FormData(form) });
|
||||||
|
if (res.ok) {
|
||||||
|
const instance = window.bootstrap?.Modal.getInstance(modal);
|
||||||
|
if (instance) instance.hide();
|
||||||
|
else modal.querySelector('[data-bs-dismiss=modal]')?.click();
|
||||||
|
} else if (btn) {
|
||||||
|
btn.textContent = original; btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (btn) { btn.textContent = original; btn.disabled = false; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
<Footer slot="footer" />
|
</Layout>
|
||||||
</Layout>
|
|
||||||
@@ -11,8 +11,8 @@ import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
|
|||||||
import { Tooltip } from "bootstrap";
|
import { Tooltip } from "bootstrap";
|
||||||
|
|
||||||
// auth check for inventory management features
|
// auth check for inventory management features
|
||||||
//const { canAddInventory } = Astro.locals;
|
const { canAddInventory } = Astro.locals;
|
||||||
const canAddInventory = false;
|
// const canAddInventory = false;
|
||||||
|
|
||||||
export const partial = true;
|
export const partial = true;
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
@@ -473,7 +473,7 @@ const altSearchUrl = (card: any) => {
|
|||||||
|
|
||||||
</datalist>
|
</datalist>
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
Type a name or pick an existing catalog.
|
Enter a name or pick an existing catalog.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export const prerender = false;
|
|||||||
import * as util from 'util';
|
import * as util from 'util';
|
||||||
|
|
||||||
// auth check for inventory management features
|
// auth check for inventory management features
|
||||||
//const { canAddInventory } = Astro.locals;
|
const { canAddInventory } = Astro.locals;
|
||||||
const canAddInventory = false;
|
// const canAddInventory = false;
|
||||||
|
|
||||||
// all the facet fields we want to use for filtering
|
// all the facet fields we want to use for filtering
|
||||||
const facetFields:any = {
|
const facetFields:any = {
|
||||||
|
|||||||
@@ -11,9 +11,16 @@ import { db } from '../../db';
|
|||||||
const formData = await Astro.request.formData();
|
const formData = await Astro.request.formData();
|
||||||
const query = formData.get('q')?.toString() || '';
|
const query = formData.get('q')?.toString() || '';
|
||||||
const start = Number(formData.get('start')?.toString() || '0');
|
const start = Number(formData.get('start')?.toString() || '0');
|
||||||
|
const catalog = formData.get('catalog')?.toString() || '';
|
||||||
|
|
||||||
const { userId } = Astro.locals.auth();
|
const { userId } = Astro.locals.auth();
|
||||||
|
|
||||||
|
// scope results to the selected catalog (empty / "all" => every catalog)
|
||||||
|
let filterBy = `userId:=${userId}`;
|
||||||
|
if (catalog && catalog !== 'all') {
|
||||||
|
filterBy += ` && catalogName:=\`${catalog}\``;
|
||||||
|
}
|
||||||
|
|
||||||
const InventoryDetails = async (inventoryId: string) => {
|
const InventoryDetails = async (inventoryId: string) => {
|
||||||
//console.log('inventoryid', inventoryId);
|
//console.log('inventoryid', inventoryId);
|
||||||
const details = await db.query.inventory.findFirst({
|
const details = await db.query.inventory.findFirst({
|
||||||
@@ -27,7 +34,7 @@ const InventoryDetails = async (inventoryId: string) => {
|
|||||||
// primary search values (for cards)
|
// primary search values (for cards)
|
||||||
let searchArray = [{
|
let searchArray = [{
|
||||||
collection: 'inventories',
|
collection: 'inventories',
|
||||||
filter_by: `userId:=${userId}`,
|
filter_by: filterBy,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
facet_by: '',
|
facet_by: '',
|
||||||
max_facet_values: 0,
|
max_facet_values: 0,
|
||||||
@@ -90,7 +97,7 @@ console.log(`totalHits: ${totalHits}`);
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="col equal-height-col">
|
<div class="col equal-height-col">
|
||||||
<div class="card-trigger position-relative inv-grid-media" data-card-id={card.productId} data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName} data-bs-toggle="modal" data-bs-target="#cardModal">
|
<div class="card-trigger position-relative inv-grid-media" data-inventory-id={inventory.inventoryId} data-card-id={card.productId} data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName} data-bs-toggle="modal" data-bs-target="#inventoryModal">
|
||||||
<div class="rounded-4 card-image h-100">
|
<div class="rounded-4 card-image h-100">
|
||||||
<img src={`static/cards/${card.productId}.jpg`} alt={card.productName} loading="lazy" decoding="async" class="img-fluid rounded-4 w-100" onerror="this.onerror=null;this.src='static/cards/default.jpg';this.closest('.image-grow')?.setAttribute('data-default','true')" />
|
<img src={`static/cards/${card.productId}.jpg`} alt={card.productName} loading="lazy" decoding="async" class="img-fluid rounded-4 w-100" onerror="this.onerror=null;this.src='static/cards/default.jpg';this.closest('.image-grow')?.setAttribute('data-default','true')" />
|
||||||
<span class="position-absolute top-50 start-0 d-inline medium-icon" style="z-index:4">
|
<span class="position-absolute top-50 start-0 d-inline medium-icon" style="z-index:4">
|
||||||
@@ -98,18 +105,6 @@ console.log(`totalHits: ${totalHits}`);
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-row justify-content-between my-1 align-items-center edit-bar">
|
|
||||||
<input type="number" class="form-control form-control-sm text-center" style="max-width: 33%;" value={inventory.quantity} min="1" max="999" aria-label="Quantity input" aria-describedby="button-minus button-plus">
|
|
||||||
<div class="" aria-label="Edit controls">
|
|
||||||
<button type="button" class="btn btn-sm btn-edit me-2"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" class="edit-svg"><path d="M374.4 146.5L374.4 146.5C395.3 128.4 443.5 85.4 468.5 97.6C484.4 105.9 497.8 112.8 501.3 120.1L501.4 120.2L501.5 120.3L501.6 120.5C507.6 130.2 518.4 150.1 519.6 161.6C513.5 192.1 484 217.1 461.6 240.4C425.7 208.6 404.1 187.1 369.7 150.6C371.3 149.2 372.8 147.9 374.4 146.5zM484.1 307.3C484.3 307.1 484.5 307 484.6 306.8C525.1 265.7 579.4 226.1 583.7 161.7C575.2 64.5 475.6-2.1 395.9 50.5C299.2 114.8 235.3 194.9 152 272.8C118.1 305.6 72.6 334.1 62.3 384.2C60.7 400.9 62.5 429.9 63.3 446.8C63.3 472.2 62.6 516.6 62.3 535.2C65.6 607.8 181.9 562 225.7 560.1C249.2 555.4 267.7 533 280.2 519.6C280.4 519.4 280.5 519.3 280.5 519.3C350.4 450.8 416.9 378.7 484.2 307.4zM416.2 285.7C349.4 358.2 282.1 428.8 211.2 497.7C182.1 502.9 154.5 507.3 126.6 510C128.2 471.4 125.7 426.3 125.8 391.4C138.8 367.7 172.1 340.8 194.2 320.8C239.9 279.4 276.7 234.2 323.2 194.7C356.5 230 380.6 254.2 416.1 285.7z"/></svg></button>
|
|
||||||
|
|
||||||
<button type="button" class="btn btn-sm btn-delete"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" class="delete-svg">
|
|
||||||
<filter id='shadow' color-interpolation-filters="sRGB">
|
|
||||||
<feDropShadow dx="2" dy="2" stdDeviation="2" flood-opacity="0.5"/>
|
|
||||||
</filter>
|
|
||||||
<path d="M345.1 124.8C361 125.2 374.2 126 386.3 127.8C386.2 137.1 385.4 148.3 384.6 157.9C340.9 156.9 297.8 157.6 254.5 158C254.8 149.7 252.4 136.7 255.2 129.7C279.3 123.4 313.6 125.1 345.1 124.7zM448.7 160.7C460.3 66.9 431.8 64.5 346.7 60.8C268.3 59.6 178 54.7 190.5 158.2C157.2 162.7 96.1 137.9 89.6 186.5C88.1 210.7 111.2 222.9 132.8 220.8L132.8 478.6C132.6 530.6 176.7 571.9 228.6 569.3L398.9 569.3C500.4 574.1 515.3 476.9 509.7 395.2C509.1 364.1 511.4 273.6 512.5 223.8C554.7 223.7 554.5 159.5 512.2 159.8L448.6 160.7zM448.5 224.7C446.1 301.9 445.5 378 445.8 440.3C442.6 476.1 442.9 507.5 400.1 505.2L227 505.2C211.1 506.8 196.6 494.9 196.7 478.5L196.7 222.1C280.4 222.3 366.5 219.1 448.4 224.6z"/></svg></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex flex-row mt-1">
|
<div class="d-flex flex-row mt-1">
|
||||||
<div class="text-body-tertiary flex-grow-1 small">{card.setName}</div>
|
<div class="text-body-tertiary flex-grow-1 small">{card.setName}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,4 +137,16 @@ console.log(`totalHits: ${totalHits}`);
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
})
|
})}
|
||||||
|
{start + 20 < totalHits &&
|
||||||
|
<div
|
||||||
|
hx-post="/partials/inventory-cards"
|
||||||
|
hx-trigger="revealed"
|
||||||
|
hx-vals={JSON.stringify({ start: start + 20, catalog, q: query })}
|
||||||
|
hx-target="#gridView"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
hx-on--after-request="this.remove()"
|
||||||
|
>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|||||||
247
src/pages/partials/inventory-modal.astro
Normal file
247
src/pages/partials/inventory-modal.astro
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
---
|
||||||
|
import ebay from "/vendors/ebay.svg?raw";
|
||||||
|
import SetIcon from '../../components/SetIcon.astro';
|
||||||
|
import EnergyIcon from '../../components/EnergyIcon.astro';
|
||||||
|
import RarityIcon from '../../components/RarityIcon.astro';
|
||||||
|
import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
|
||||||
|
import { db } from '../../db/index';
|
||||||
|
import { inventory } from '../../db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const partial = true;
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const { userId } = Astro.locals.auth();
|
||||||
|
const searchParams = Astro.url.searchParams;
|
||||||
|
const inventoryId = searchParams.get('inventoryId') || '';
|
||||||
|
|
||||||
|
const inv = userId && inventoryId
|
||||||
|
? await db.query.inventory.findFirst({
|
||||||
|
where: { inventoryId: inventoryId, userId: userId },
|
||||||
|
with: {
|
||||||
|
sku: {
|
||||||
|
with: {
|
||||||
|
card: { with: { set: true } },
|
||||||
|
history: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const sku = inv?.sku;
|
||||||
|
const card = sku?.card;
|
||||||
|
|
||||||
|
// Distinct catalog names for this user (datalist suggestions)
|
||||||
|
const catalogRows = userId
|
||||||
|
? await db
|
||||||
|
.selectDistinct({ name: inventory.catalogName })
|
||||||
|
.from(inventory)
|
||||||
|
.where(eq(inventory.userId, userId))
|
||||||
|
: [];
|
||||||
|
const catalogs = catalogRows.map(r => r.name).filter((n): n is string => !!n);
|
||||||
|
|
||||||
|
// ── Values from the inventory entry ──────────────────────────────────────
|
||||||
|
const quantity = inv?.quantity ?? 0;
|
||||||
|
const purchasePrice = inv?.purchasePrice != null ? Number(inv.purchasePrice) : null;
|
||||||
|
const purchaseDate = inv?.createdAt ? new Date(inv.createdAt).toISOString().split('T')[0] : null;
|
||||||
|
const marketPrice = sku?.marketPrice != null ? Number(sku.marketPrice) : null;
|
||||||
|
const condition = sku?.condition || 'Near Mint';
|
||||||
|
|
||||||
|
const gain =
|
||||||
|
purchasePrice != null && marketPrice != null
|
||||||
|
? marketPrice - purchasePrice
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const gainPercent =
|
||||||
|
purchasePrice != null &&
|
||||||
|
marketPrice != null &&
|
||||||
|
purchasePrice > 0
|
||||||
|
? ((marketPrice - purchasePrice) / purchasePrice) * 100
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// ── Price history for this SKU's condition only ──────────────────────────
|
||||||
|
const priceHistoryForChart = (sku?.history ?? [])
|
||||||
|
.map(h => ({
|
||||||
|
calculatedAt: h.calculatedAt
|
||||||
|
? new Date(h.calculatedAt).toISOString().split('T')[0]
|
||||||
|
: null,
|
||||||
|
marketPrice: h.marketPrice != null ? Number(h.marketPrice) : null,
|
||||||
|
}))
|
||||||
|
.filter(h => h.calculatedAt !== null && h.marketPrice !== null)
|
||||||
|
.sort((a, b) => (a.calculatedAt! < b.calculatedAt! ? -1 : 1));
|
||||||
|
|
||||||
|
const ebaySearchUrl = (card: any) => {
|
||||||
|
return `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&LH_Sold=1&Graded=No&_dcat=183454`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const altSearchUrl = (card: any) => {
|
||||||
|
return `https://alt.xyz/browse?query=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&sortBy=newest_first`;
|
||||||
|
};
|
||||||
|
---
|
||||||
|
{!inv && (
|
||||||
|
<Fragment>
|
||||||
|
<div class="modal-header border-0">
|
||||||
|
<div class="h5 mb-0">Inventory entry not found</div>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-secondary mb-0">This card is no longer in your inventory.</p>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
{inv && (
|
||||||
|
<Fragment>
|
||||||
|
<div class="modal-header border-0">
|
||||||
|
<div class="container-fluid row align-items-center">
|
||||||
|
<div class="h4 card-title pe-2 col-sm-12 col-md-auto mb-sm-1">{card?.productName}</div>
|
||||||
|
<div class="text-secondary col-auto">{card?.number}</div>
|
||||||
|
<div class="text-light col-auto">{card?.variant}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body pt-0">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="card mb-2 border-0">
|
||||||
|
<div class="row g-4">
|
||||||
|
|
||||||
|
<!-- Card image column -->
|
||||||
|
<div class="col-sm-12 col-md-3">
|
||||||
|
<div class="position-relative mt-1">
|
||||||
|
<div class="card-image-wrap rounded-4" data-energy={card?.energyType} data-rarity={card?.rarityName} data-variant={card?.variant} data-name={card?.productName}>
|
||||||
|
<img src={`/static/cards/${card?.productId}.jpg`}
|
||||||
|
class="card-image w-100 img-fluid rounded-4"
|
||||||
|
alt={card?.productName}
|
||||||
|
crossorigin="anonymous"
|
||||||
|
onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow, .card-image-wrap')?.setAttribute('data-default','true')"
|
||||||
|
onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="position-absolute top-50 start-0 d-inline"><FirstEditionIcon edition={card?.variant} /></span>
|
||||||
|
<span class="position-absolute bottom-0 start-0 d-inline"><SetIcon set={card?.set?.setCode} /></span>
|
||||||
|
<span class="position-absolute top-0 end-0 d-inline"><EnergyIcon energy={card?.energyType} /></span>
|
||||||
|
<span class="rarity-icon-large position-absolute bottom-0 end-0 d-inline"><RarityIcon rarity={card?.rarityName} /></span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column flex-lg-row justify-content-between mt-2">
|
||||||
|
<div class="text-secondary"><span class="d-flex d-xxl-none">{card?.set?.setCode}</span><span class="d-none d-xxl-flex">{card?.set?.setName}</span></div>
|
||||||
|
<div class="text-secondary">Illus<span class="d-none d-xxl-inline">trator</span>: {card?.artist}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inventory details + edit column -->
|
||||||
|
<div class="col-sm-12 col-md-7">
|
||||||
|
|
||||||
|
<!-- Static fields (left) + editable Note/Catalog (right), side by side -->
|
||||||
|
<div class="row gx-3 gy-2">
|
||||||
|
<!-- Left column: read-only stats -->
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<label class="form-label">Condition</label>
|
||||||
|
<div class="btn-group condition-input w-100 col-12" role="group" aria-label="Condition">
|
||||||
|
<input type="radio" class="btn-check" name="condition" id="cond-nm" value="Near Mint" autocomplete="off" checked />
|
||||||
|
<label class="btn btn-cond-nm" for="cond-nm">NM</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="condition" id="cond-lp" value="Lightly Played" autocomplete="off" disabled />
|
||||||
|
<label class="btn btn-cond-lp" for="cond-lp">LP</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="condition" id="cond-mp" value="Moderately Played" autocomplete="off" disabled />
|
||||||
|
<label class="btn btn-cond-mp" for="cond-mp">MP</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="condition" id="cond-hp" value="Heavily Played" autocomplete="off" disabled />
|
||||||
|
<label class="btn btn-cond-hp" for="cond-hp">HP</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="condition" id="cond-dmg" value="Damaged" autocomplete="off" disabled />
|
||||||
|
<label class="btn btn-cond-dmg" for="cond-dmg">DMG</label>
|
||||||
|
</div>
|
||||||
|
<p class="mb-1 my-2">Purchased: <span class="text-secondary">{inv.createdAt ? new Date(inv.createdAt).toLocaleDateString() : '—'}</span></p>
|
||||||
|
<div class="d-flex flex-wrap justify-content-center flex-row gap-2">
|
||||||
|
<div class="alert alert-dark d-flex flex-column flex-fill rounded p-2 mb-2">
|
||||||
|
<p class="mb-auto">Purchase Price</p>
|
||||||
|
<h6 class="mb-0 mt-1">{purchasePrice != null ? `$${purchasePrice.toFixed(2)}` : '—'}</h6>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-dark d-flex flex-column flex-fill rounded p-2 mb-2">
|
||||||
|
<p class="mb-auto">Market Price</p>
|
||||||
|
<h6 class="mb-0 mt-1">{marketPrice != null ? `$${marketPrice.toFixed(2)}` : '—'}</h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap justify-content-between flex-row gap-2">
|
||||||
|
<div class={`alert rounded p-2 flex-fill ${gain == null ? 'alert-dark' : gain >= 0 ? 'alert-success' : 'alert-danger'}`}>
|
||||||
|
<p class="mb-auto">Gain / Loss ($)</p>
|
||||||
|
<h6 class="mb-0 mt-1">{gain == null ? '—' : `${gain >= 0 ? '+' : '−'}$${Math.abs(gain).toFixed(2)}`}</h6>
|
||||||
|
</div>
|
||||||
|
<div class={`alert rounded p-2 flex-fill ${gainPercent == null ? 'alert-dark' : gainPercent >= 0 ? 'alert-success' : 'alert-danger'}`}>
|
||||||
|
<p class="mb-auto">Gain / Loss (%)</p>
|
||||||
|
<h6 class="mb-0 mt-1">
|
||||||
|
{ gainPercent == null ? '—' : `${gainPercent >= 0 ? '+' : '−'}${Math.abs(gainPercent).toFixed(2)}%` }
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column: editable Note + Catalog -->
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<form id="inventoryEditForm" data-inventory-edit-form novalidate>
|
||||||
|
<input type="hidden" name="action" value="update" />
|
||||||
|
<input type="hidden" name="inventoryId" value={inv?.inventoryId} />
|
||||||
|
<input type="hidden" name="cardId" value={card?.cardId} />
|
||||||
|
<input type="hidden" name="quantity" value={quantity} />
|
||||||
|
<input type="hidden" name="purchasePrice" value={purchasePrice ?? 0} />
|
||||||
|
|
||||||
|
<label for="catalogName" class="form-label">Catalog</label>
|
||||||
|
<input type="text" class="form-control" id="catalogName" name="catalogName" list="catalogSuggestions" placeholder="Default" maxlength="100" value={inv?.catalogName ?? ''} />
|
||||||
|
<datalist id="catalogSuggestions">
|
||||||
|
{catalogs.map(name => <option value={name}></option>)}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
<label for="note" class="form-label mt-2">Note</label>
|
||||||
|
<textarea class="form-control" id="note" name="note" rows="5" maxlength="255" placeholder="e.g. bought at local shop, gift, graded copy…">{inv?.note ?? ''}</textarea>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Market Price History chart for this condition -->
|
||||||
|
<div class="d-block d-lg-flex gap-1 mt-2 price-chart-container">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-dark rounded p-2 mb-0">
|
||||||
|
<h6>Market Price History <span class="small text-secondary">({condition})</span></h6>
|
||||||
|
<div id="inventoryPriceEmpty" class="d-none text-secondary text-center py-4">
|
||||||
|
No price history for this condition
|
||||||
|
</div>
|
||||||
|
<div class="position-relative chart-canvas-wrap" style="height: 220px;">
|
||||||
|
<canvas id="inventoryPriceChart" class="price-history-chart" data-history={JSON.stringify(priceHistoryForChart)} data-purchase-price={purchasePrice ?? ''} data-purchase-date={purchaseDate ?? ''} data-condition={condition}></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end mt-2" role="group" aria-label="Time range">
|
||||||
|
<button type="button" class="btn btn-dark inv-price-range-btn" data-range="1m">1M</button>
|
||||||
|
<button type="button" class="btn btn-dark inv-price-range-btn" data-range="3m">3M</button>
|
||||||
|
<button type="button" class="btn btn-dark inv-price-range-btn active" data-range="6m">6M</button>
|
||||||
|
<button type="button" class="btn btn-dark inv-price-range-btn" data-range="1y">1Y</button>
|
||||||
|
<button type="button" class="btn btn-dark inv-price-range-btn" data-range="all">All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- External links column -->
|
||||||
|
<div class="col-sm-12 col-md-2 mt-0 mt-md-4 d-flex flex-row flex-md-column">
|
||||||
|
<a class="btn btn-dark mb-2 w-100 p-2" href={`https://www.tcgplayer.com/product/${card?.productId}`} target="_blank" onclick="dataLayer.push({'event': 'tcgplayerClick', 'tcgplayerUrl': this.getAttribute('href')});"><img src="/vendors/tcgplayer.webp"> <span class="d-none d-lg-inline">TCGPlayer</span></a>
|
||||||
|
<a class="btn btn-dark mb-2 w-100 p-2" href={`${ebaySearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'ebayClick', 'ebayUrl': this.getAttribute('href')});"><span set:html={ebay} /></a>
|
||||||
|
<a class="btn btn-dark mb-2 w-100 p-2" href={`${altSearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'altClick', 'altUrl': this.getAttribute('href')});"><svg width="48" height="20.16" viewBox="0 0 48 20" fill="none"><path d="M14.2761 19.9996H18.5308L11.6934 0.0712891H7.76953L14.2761 19.9996Z" fill="#ffffff"></path><path d="M6.17778 19.9986H6.14536L3.19643 11.2305L0 19.9988L6.17768 19.9989L6.17778 19.9986Z" fill="#ffffff"></path><path d="M24.7842 0H20.6759V19.9661H34.3427V16.5426H24.7842V0Z" fill="#ffffff"></path><path d="M41.6644 3.42355H47.4981V0H31.5033V3.42355H37.5561V19.9661H41.6644V3.42355Z" fill="#ffffff"></path></svg></a>
|
||||||
|
<div class="d-flex gap-2 mt-auto align-items-end justify-content-evenly">
|
||||||
|
<button type="button" class="btn btn-outline-danger flex-fill" data-inventory-delete>Delete</button>
|
||||||
|
<button type="submit" form="inventoryEditForm" class="btn btn-success flex-fill flex-grow-1">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
Reference in New Issue
Block a user