style tweaks to both form and existing inventory, added createdAt and modified purchasePrice (for % of market)

This commit is contained in:
Zach Harding
2026-04-05 16:09:52 -04:00
parent 87235ab37a
commit 404355304c
8 changed files with 1914 additions and 95 deletions

View File

@@ -1,58 +1,84 @@
import type { APIRoute } from 'astro';
import { db } from '../../db/index';
import { inventory } from '../../db/schema';
import { inventory, skus, cards } from '../../db/schema';
import { client } from '../../db/typesense';
import { eq } from 'drizzle-orm';
import { eq, and } from 'drizzle-orm';
const GainLoss = (purchasePrice:any, marketPrice:any) => {
const GainLoss = (purchasePrice: any, marketPrice: any) => {
if (!purchasePrice || !marketPrice) return '<div class="fs-5 fw-semibold">N/A</div>';
const pp = Number(purchasePrice);
const mp = Number(marketPrice);
if (pp === mp) return '<div class="fs-5 fw-semibold text-warning">-</div>';
if (pp > mp) return `<div class="fs-5 fw-semibold text-critical">-$${pp-mp}</div>`;
return `<div class="fs-5 fw-semibold text-success">+$${mp-pp}</div>`;
if (pp > mp) return `<div class="fs-5 fw-semibold text-critical">-$${(pp - mp).toFixed(2)}</div>`;
return `<div class="fs-6 fw-semibold text-success">+$${(mp - pp).toFixed(2)}</div>`;
}
const getInventory = async (userId: string, cardId: number) => {
const getInventory = async (userId:string, cardId:number) => {
const inventories = await db.query.inventory.findMany({
where: { userId:userId, cardId:cardId, },
with: { card: true, sku: true, }
});
const inventories = await db
.select({
inventoryId: inventory.inventoryId,
cardId: inventory.cardId,
condition: inventory.condition,
quantity: inventory.quantity,
purchasePrice: inventory.purchasePrice,
note: inventory.note,
marketPrice: skus.marketPrice,
createdAt: inventory.createdAt,
})
.from(inventory)
.leftJoin(
cards,
eq(inventory.cardId, cards.cardId)
)
.leftJoin(
skus,
and(
eq(cards.productId, skus.productId),
eq(inventory.condition, skus.condition)
)
)
.where(and(
eq(inventory.userId, userId),
eq(inventory.cardId, cardId)
));
const invHtml = inventories.map(inv => {
const marketPrice = inv.marketPrice ? Number(inv.marketPrice).toFixed(2) : null;
const marketPriceDisplay = marketPrice ? `$${marketPrice}` : '—';
const purchasePriceDisplay = inv.purchasePrice ? `$${Number(inv.purchasePrice).toFixed(2)}` : '—';
return `
<article class="border rounded-4 p-2 bg-body-tertiary inventory-entry-card"
<article class="alert alert-dark rounded-4 inventory-entry-card"
data-inventory-id="${inv.inventoryId}"
data-card-id="${inv.cardId}"
data-purchase-price="${inv.purchasePrice}"
data-note="${(inv.note || '').replace(/"/g, '&quot;')}">
<div class="d-flex flex-column gap-2">
<div class="d-flex flex-column">
<!-- Top row -->
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="d-flex justify-content-between gap-3">
<div class="min-w-0 flex-grow-1">
<div class="fw-semibold fs-5 text-body mb-1">${inv.condition}</div>
<div class="fw-semibold fs-6 text-body mb-1">${inv.condition}</div>
</div>
<div class="fs-7 text-secondary">Added: ${inv.createdAt ? new Date(inv.createdAt).toLocaleDateString() : '—'}</div>
</div>
<!-- Middle row -->
<div class="row g-2">
<div class="col-4">
<div class="small text-secondary">Purchase price</div>
<div class="fs-5 fw-semibold">$${inv.purchasePrice}</div>
<div class="fs-6 fw-semibold">${purchasePriceDisplay}</div>
</div>
<div class="col-4">
<div class="small text-secondary">Market price</div>
<div class="fs-5 text-success">$${inv.sku?.marketPrice}</div>
<div class="fs-6 text-success">${marketPriceDisplay}</div>
</div>
<div class="col-4">
<div class="small text-secondary">Gain / loss</div>
${GainLoss(inv.purchasePrice, inv.sku?.marketPrice)}
${GainLoss(inv.purchasePrice, marketPrice)}
</div>
</div>
<!-- Bottom row -->
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap mt-2">
<div class="d-flex align-items-center gap-2">
<span class="small text-secondary">Qty</span>
<div class="btn-group" role="group" aria-label="Quantity controls">
@@ -63,7 +89,7 @@ const getInventory = async (userId:string, cardId:number) => {
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<button type="button" class="btn btn-sm btn-outline-secondary" data-inv-action="update">Edit</button>
<button type="button" class="btn btn-sm btn-outline-danger" data-inv-action="remove">Remove</button>
<button type="button" class="btn btn-sm btn-outline-danger" data-inv-action="remove" onclick="if(!confirm('Are you sure you want to remove this card from your inventory?')) event.stopImmediatePropagation();">Remove</button>
</div>
</div>
</div>
@@ -77,12 +103,10 @@ const getInventory = async (userId:string, cardId:number) => {
headers: { 'Content-Type': 'text/html' },
}
);
}
const addToInventory = async (userId:string, cardId:number, condition:string, purchasePrice:number, quantity:number, note:string, catalogName:string) => {
// First add to database
const addToInventory = async (userId: string, cardId: number, condition: string, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
const inv = await db.insert(inventory).values({
userId: userId,
cardId: cardId,
@@ -92,7 +116,6 @@ const addToInventory = async (userId:string, cardId:number, condition:string, pu
quantity: quantity,
note: note,
}).returning();
// And then add to Typesense
await client.collections('inventories').documents().import(inv.map(i => ({
id: i.inventoryId,
userId: i.userId,
@@ -101,23 +124,20 @@ const addToInventory = async (userId:string, cardId:number, condition:string, pu
})));
}
const removeFromInventory = async (inventoryId:string) => {
await db.delete(inventory).where(eq( inventory.inventoryId, inventoryId ));
const removeFromInventory = async (inventoryId: string) => {
await db.delete(inventory).where(eq(inventory.inventoryId, inventoryId));
await client.collections('inventories').documents(inventoryId).delete();
}
const updateInventory = async (inventoryId:string, quantity:number, purchasePrice:number, note:string) => {
// Update in database
const updateInventory = async (inventoryId: string, quantity: number, purchasePrice: number, note: string) => {
await db.update(inventory).set({
quantity: quantity,
purchasePrice: purchasePrice,
note: note,
}).where(eq( inventory.inventoryId, inventoryId ));
// There is no need to update Typesense for these fields as they are not indexed
}).where(eq(inventory.inventoryId, inventoryId));
}
export const POST: APIRoute = async ({ request, locals }) => {
// Access form data from the request body
const formData = await request.formData();
const action = formData.get('action');
const cardId = Number(formData.get('cardId')) || 0;
@@ -132,7 +152,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
const note = formData.get('note')?.toString() || '';
const catalogName = formData.get('catalogName')?.toString() || 'Default';
await addToInventory(userId!, cardId, condition, purchasePrice, quantity, note, catalogName);
//return await getInventory(cardId);
break;
case 'remove':
@@ -149,12 +168,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
break;
default:
// No action = list inventory for this card
return getInventory(userId!, cardId);
}
// Always return current inventory after a mutation
return getInventory(userId!, cardId);
};

View File

@@ -1,6 +1,4 @@
---
import { Show } from '@clerk/astro/components';
import ebay from "/vendors/ebay.svg?raw";
import SetIcon from '../../components/SetIcon.astro';
import EnergyIcon from '../../components/EnergyIcon.astro';
@@ -12,6 +10,18 @@ import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
import { Tooltip } from "bootstrap";
import { clerkClient } from '@clerk/astro/server';
const { userId, has } = Astro.locals.auth();
const TARGET_ORG_ID = 'org_3Baav9czkRLLlC7g89oJWqRRulK';
let hasAccess = has({ feature: 'inventory_add' });
if (!hasAccess && userId) {
const memberships = await clerkClient(Astro).users.getOrganizationMembershipList({ userId });
hasAccess = memberships.data.some(m => m.organization.id === TARGET_ORG_ID);
}
export const partial = true;
export const prerender = false;
@@ -163,6 +173,14 @@ const conditionAttributes = (price: any) => {
}[condition];
};
// ── Build a market price lookup keyed by condition for use in JS ──────────
const marketPriceByCondition: Record<string, number> = {};
for (const price of card?.prices ?? []) {
if (price.condition && price.marketPrice != null) {
marketPriceByCondition[price.condition] = Number(price.marketPrice);
}
}
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`;
};
@@ -251,13 +269,13 @@ const altSearchUrl = (card: any) => {
<span class="d-none">Damaged</span><span class="d-inline">DMG</span>
</button>
</li>
{hasAccess && (
<li class="nav-item" role="presentation">
<button class="nav-link vendor" id="vendor-tab" data-bs-toggle="tab" data-bs-target="#nav-vendor" type="button" role="tab" aria-controls="nav-vendor" aria-selected="false">
<span class="d-none d-xxl-inline">Inventory</span><span class="d-xxl-none">+/-</span>
</button>
</li>
)}
</ul>
<div class="tab-content" id="myTabContent">
@@ -314,7 +332,7 @@ const altSearchUrl = (card: any) => {
<!-- Table only — chart is outside the tab panes -->
<div class="w-100">
<div class="alert alert-dark rounded p-2 mb-0 table-responsive">
<div class="alert alert-dark rounded p-2 mb-0 table-responsive d-none">
<h6>Latest Verified Sales</h6>
<table class="table table-sm mb-0">
<caption class="small">Filtered to remove mismatched language variants</caption>
@@ -340,16 +358,16 @@ const altSearchUrl = (card: any) => {
</div>
);
})}
{hasAccess && (
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0">
<div class="row g-3">
<div class="col-12 col-md-6">
<h5 class="mt-1 mb-2">Add {card?.productName} to inventory</h5>
<h6 class="mt-1 mb-2">Add {card?.productName} to inventory</h6>
<form id="inventoryForm" data-inventory-form novalidate>
<div class="row g-3">
<div class="row gx-3 gy-1">
<div class="col-4">
<label for="quantity" class="form-label fw-medium">Quantity</label>
<label for="quantity" class="form-label">Quantity</label>
<input
type="number"
class="form-control mt-1"
@@ -365,7 +383,7 @@ const altSearchUrl = (card: any) => {
<div class="col-8">
<div class="d-flex justify-content-between align-items-start gap-2 flex-wrap">
<label for="purchasePrice" class="form-label fw-medium">
<label for="purchasePrice" class="form-label">
Purchase price
</label>
@@ -414,7 +432,7 @@ const altSearchUrl = (card: any) => {
</div>
<div class="col-12">
<label class="form-label fw-medium">Condition</label>
<label class="form-label">Condition</label>
<div class="btn-group condition-input w-100" role="group" aria-label="Condition">
<input
type="radio"
@@ -469,9 +487,8 @@ const altSearchUrl = (card: any) => {
</div>
</div>
<div class="col-12">
<label for="catalogName" class="form-label fw-medium">
<label for="catalogName" class="form-label">
Catalog
<span class="text-body-tertiary fw-normal ms-1 small">optional</span>
</label>
<input
type="text"
@@ -491,9 +508,8 @@ const altSearchUrl = (card: any) => {
</div>
</div>
<div class="col-12">
<label for="note" class="form-label fw-medium">
<label for="note" class="form-label">
Note
<span class="text-body-tertiary fw-normal ms-1 small">optional</span>
</label>
<textarea
class="form-control"
@@ -515,10 +531,10 @@ const altSearchUrl = (card: any) => {
</div>
<div class="col-12 col-md-6">
<h5 class="mt-1 mb-2">Inventory entries for {card?.productName}</h5>
<h6 class="mt-1 mb-2">Inventory entries for {card?.productName}</h6>
<!-- Empty state -->
<div class="alert alert-dark border-0 rounded-4 d-none" id="inventoryEmptyState">
<div class="alert alert-dark rounded-4 d-none" id="inventoryEmptyState">
<div class="fw-medium mb-1">No inventory entries yet</div>
<div class="text-secondary small">
Once you add copies of this card, they'll show up here.
@@ -526,17 +542,17 @@ const altSearchUrl = (card: any) => {
</div>
<!-- Inventory list -->
<div class="d-flex flex-column gap-3" id="inventoryEntryList" data-card-id={cardId}>
<div class="d-flex flex-column gap-3" id="inventoryEntryList" data-card-id={cardId} data-market-prices={JSON.stringify(marketPriceByCondition)}>
<span>Loading...</span>
</div>
</div>
</div>
</div>
)}
</div>
<!-- Chart lives permanently outside tab-content so Bootstrap never hides it. -->
<div class="d-block d-lg-flex gap-1 mt-1">
<div class="d-block d-lg-flex gap-1 mt-1 price-chart-container">
<div class="col-12">
<div class="alert alert-dark rounded p-2 mb-0">
<h6>Market Price History</h6>

View File

@@ -1,7 +1,16 @@
---
import { client } from '../../db/typesense';
import { clerkClient } from '@clerk/astro/server';
import { Show } from '@clerk/astro/components';
const { userId, has } = Astro.locals.auth();
const TARGET_ORG_ID = 'org_3Baav9czkRLLlC7g89oJWqRRulK';
let hasAccess = has({ feature: 'inventory_add' });
if (!hasAccess && userId) {
const memberships = await clerkClient(Astro).users.getOrganizationMembershipList({ userId });
hasAccess = memberships.data.some(m => m.organization.id === TARGET_ORG_ID);
}
import RarityIcon from '../../components/RarityIcon.astro';
import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
@@ -286,11 +295,11 @@ const facets = searchResults.results.slice(1).map((result: any) => {
{pokemon.map((card:any) => (
<div class="col">
{hasAccess && (
<button type="button" class="btn btn-sm inventory-button position-relative float-end shadow-filter text-center p-2" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="event.stopPropagation(); sessionStorage.setItem('openModalTab', 'nav-vendor');">
<b>+/</b>
</button>
)}
<div class="card-trigger position-relative" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});">
<div class="image-grow rounded-4 card-image" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}><img src={`/static/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 w-100" onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow')?.setAttribute('data-default','true')"/><span class="position-absolute top-50 start-0 d-inline medium-icon"><FirstEditionIcon edition={card?.variant} /></span>
<div class="holo-shine"></div>