[feat] inventory modal initiated
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
245
src/pages/partials/inventory-modal.astro
Normal file
245
src/pages/partials/inventory-modal.astro
Normal 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>
|
||||
)}
|
||||
Reference in New Issue
Block a user