[feat] inventory modal initiated

This commit is contained in:
Thad Miller
2026-07-09 18:33:16 -04:00
parent 4990c3aa04
commit 5abd6e4b92
5 changed files with 496 additions and 6 deletions

View File

@@ -138,14 +138,20 @@ const removeFromInventory = async (inventoryId: string) => {
await client.collections('inventories').documents(inventoryId).delete();
}
const updateInventory = async (inventoryId: string, quantity: number, purchasePrice: number, note: string) => {
const updateInventory = async (inventoryId: string, quantity: number, purchasePrice: number, note: string, catalogName: string) => {
// Update the database
await db.update(inventory).set({
quantity: quantity,
purchasePrice: purchasePrice.toFixed(2),
note: note,
catalogName: catalogName,
}).where(eq(inventory.inventoryId, inventoryId));
// No need to update Typesense since we don't search by quantity or price
// Quantity/price/note aren't searched, but catalogName is faceted in Typesense.
try {
await client.collections('inventories').documents(inventoryId).update({ catalogName });
} catch (error) {
console.error('Error updating inventory catalog in Typesense:', error);
}
}
export const POST: APIRoute = async ({ request, locals }) => {
@@ -183,7 +189,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
const qty = Number(formData.get('quantity')) || 1;
const price = Number(formData.get('purchasePrice')) || 0;
const invNote = formData.get('note')?.toString() || '';
await updateInventory(invId, qty, price, invNote);
const invCatalog = formData.get('catalogName')?.toString() || 'Default';
await updateInventory(invId, qty, price, invNote, invCatalog);
break;
default:

View File

@@ -253,6 +253,13 @@ const totalGain = summary.totalGain || 0;
</div>
</div>
</div>
<!-- Inventory detail modal (loaded from /partials/inventory-modal on open) -->
<div class="modal card-modal" id="inventoryModal" tabindex="-1" aria-labelledby="inventoryModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
<div class="modal-content p-2">Loading...</div>
</div>
</div>
<!--
<div class="modal fade" id="inventoryEditModal" tabindex="-1" aria-labelledby="inventoryEditLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-dialog-centered">
@@ -290,6 +297,237 @@ const totalGain = summary.totalGain || 0;
</div>
</div>
</div> -->
<script is:inline>
(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 = 'all';
root.querySelectorAll('.inv-price-range-btn').forEach(b =>
b.classList.toggle('active', b.dataset.range === 'all'));
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();
});
// ── Save catalog + note ─────────────────────────────────────────────────
modal.addEventListener('submit', async (e) => {
const form = e.target.closest('[data-inventory-edit-form]');
if (!form) return;
e.preventDefault();
const btn = form.querySelector('button[type="submit"]');
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) {
if (btn) { btn.textContent = 'Saved ✓'; btn.classList.remove('btn-success'); btn.classList.add('btn-outline-success'); }
setTimeout(() => {
if (btn) { btn.textContent = original; btn.disabled = false; btn.classList.add('btn-success'); btn.classList.remove('btn-outline-success'); }
}, 1500);
} else if (btn) {
btn.textContent = original; btn.disabled = false;
}
} catch {
if (btn) { btn.textContent = original; btn.disabled = false; }
}
});
})();
</script>
</div>
<Footer slot="footer" />
</Layout>

View File

@@ -11,8 +11,8 @@ import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
import { Tooltip } from "bootstrap";
// auth check for inventory management features
//const { canAddInventory } = Astro.locals;
const canAddInventory = false;
const { canAddInventory } = Astro.locals;
// const canAddInventory = false;
export const partial = true;
export const prerender = false;

View File

@@ -90,7 +90,7 @@ console.log(`totalHits: ${totalHits}`);
return (
<div class="col equal-height-col">
<div class="card-trigger position-relative inv-grid-media" data-card-id={card.productId} data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName} data-bs-toggle="modal" data-bs-target="#cardModal">
<div class="card-trigger position-relative inv-grid-media" data-inventory-id={inventory.inventoryId} data-card-id={card.productId} data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName} data-bs-toggle="modal" data-bs-target="#inventoryModal">
<div class="rounded-4 card-image h-100">
<img src={`static/cards/${card.productId}.jpg`} alt={card.productName} loading="lazy" decoding="async" class="img-fluid rounded-4 w-100" onerror="this.onerror=null;this.src='static/cards/default.jpg';this.closest('.image-grow')?.setAttribute('data-default','true')" />
<span class="position-absolute top-50 start-0 d-inline medium-icon" style="z-index:4">

View File

@@ -0,0 +1,245 @@
---
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;
// ── 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 from the inventory entry -->
<div class="d-flex flex-fill flex-row gap-1 flex-wrap flex-lg-nowrap">
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Quantity</h6>
<p class="mb-0 mt-1">{quantity}</p>
</div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Purchase Price</h6>
<p class="mb-0 mt-1">{purchasePrice != null ? `$${purchasePrice.toFixed(2)}` : '—'}</p>
</div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Condition</h6>
<p class="mb-0 mt-1">{condition}</p>
</div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Market Price</h6>
<p class="mb-0 mt-1">{marketPrice != null ? `$${marketPrice.toFixed(2)}` : '—'}</p>
</div>
<div class={`alert rounded p-2 flex-fill d-flex flex-column mb-0 ${gain == null ? 'alert-dark' : gain >= 0 ? 'alert-success' : 'alert-danger'}`}>
<h6 class="mb-auto">Gain / Loss</h6>
<p class="mb-0 mt-1">{gain == null ? '—' : `${gain >= 0 ? '+' : ''}$${Math.abs(gain).toFixed(2)}`}</p>
</div>
</div>
<!-- Editable fields: Catalog + Note -->
<form data-inventory-edit-form class="mt-3" 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} />
<div class="row gx-3 gy-2">
<div class="col-12 col-lg-5">
<label for="catalogName" class="form-label">Catalog</label>
<input
type="text"
class="form-control"
id="catalogName"
name="catalogName"
list="catalogSuggestions"
placeholder="Default"
autocomplete="off"
maxlength="100"
value={inv?.catalogName ?? ''}
/>
<datalist id="catalogSuggestions">
{catalogs.map(name => <option value={name}></option>)}
</datalist>
</div>
<div class="col-12 col-lg-7">
<label for="note" class="form-label">Note</label>
<textarea
class="form-control"
id="note"
name="note"
rows="2"
maxlength="255"
placeholder="e.g. bought at local shop, gift, graded copy…"
>{inv?.note ?? ''}</textarea>
</div>
<div class="col-12 d-flex justify-content-end pt-1">
<button type="submit" class="btn btn-success">Save</button>
</div>
</div>
</form>
<!-- Market Price History chart for this condition -->
<div class="d-block d-lg-flex gap-1 mt-3 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" 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 active" data-range="all">All</button>
</div>
</div>
</div>
</div>
</div>
<!-- External links column -->
<div class="col-sm-12 col-md-2 mt-0 mt-md-5 d-flex flex-row flex-md-column">
<a class="btn btn-dark mb-2 w-100 p-2" href={`https://www.tcgplayer.com/product/${card?.productId}`} target="_blank" onclick="dataLayer.push({'event': 'tcgplayerClick', 'tcgplayerUrl': this.getAttribute('href')});"><img src="/vendors/tcgplayer.webp"> <span class="d-none d-lg-inline">TCGPlayer</span></a>
<a class="btn btn-dark mb-2 w-100 p-2" href={`${ebaySearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'ebayClick', 'ebayUrl': this.getAttribute('href')});"><span set:html={ebay} /></a>
<a class="btn btn-dark mb-2 w-100 p-2" href={`${altSearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'altClick', 'altUrl': this.getAttribute('href')});"><svg width="48" height="20.16" viewBox="0 0 48 20" fill="none"><path d="M14.2761 19.9996H18.5308L11.6934 0.0712891H7.76953L14.2761 19.9996Z" fill="#ffffff"></path><path d="M6.17778 19.9986H6.14536L3.19643 11.2305L0 19.9988L6.17768 19.9989L6.17778 19.9986Z" fill="#ffffff"></path><path d="M24.7842 0H20.6759V19.9661H34.3427V16.5426H24.7842V0Z" fill="#ffffff"></path><path d="M41.6644 3.42355H47.4981V0H31.5033V3.42355H37.5561V19.9661H41.6644V3.42355Z" fill="#ffffff"></path></svg></a>
</div>
</div>
</div>
</div>
</div>
</Fragment>
)}