setting up inventory dashboard
This commit is contained in:
@@ -418,22 +418,24 @@ $tiers: (
|
||||
}
|
||||
|
||||
.inventory-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: -2rem;
|
||||
margin-right: -0.25rem;
|
||||
border-radius: 0.33rem;
|
||||
margin-bottom: -2.25rem;
|
||||
margin-right: -0.5rem;
|
||||
z-index: 2;
|
||||
background-color: hsl(262, 47%, 55%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.inventory-label {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
.inventory-button:hover {
|
||||
background-color: hsl(262, 39%, 40%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#inventoryForm .btn-check:checked + .nav-link {
|
||||
outline: 2px solid rgba(0, 0, 0, 0.4);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
#inventoryForm .nav-link { cursor: pointer; }
|
||||
|
||||
.fs-7 {
|
||||
font-size: 0.9rem !important;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ function setEmptyState(isEmpty) {
|
||||
canvasWrapper.classList.toggle('d-none', isEmpty);
|
||||
}
|
||||
|
||||
function setChartVisible(visible) {
|
||||
const modal = document.getElementById('cardModal');
|
||||
const chartWrapper = modal?.querySelector('#priceHistoryChart')?.closest('.alert');
|
||||
if (chartWrapper) chartWrapper.classList.toggle('d-none', !visible);
|
||||
}
|
||||
|
||||
function buildChartData(history, rangeKey) {
|
||||
const cutoff = RANGE_DAYS[rangeKey] === Infinity
|
||||
? new Date(0)
|
||||
@@ -39,20 +45,14 @@ function buildChartData(history, rangeKey) {
|
||||
|
||||
const filtered = history.filter(r => new Date(r.calculatedAt) >= cutoff);
|
||||
|
||||
// Always build the full date axis for the selected window, even if sparse.
|
||||
// Generate one label per day in the range so the x-axis reflects the
|
||||
// chosen period rather than collapsing to only the days that have data.
|
||||
const dataDateSet = new Set(filtered.map(r => r.calculatedAt));
|
||||
const allDates = [...dataDateSet].sort((a, b) => new Date(a) - new Date(b));
|
||||
|
||||
// If we have real data, expand the axis to span from cutoff → today so
|
||||
// empty stretches at the start/end of a range are visible.
|
||||
let axisLabels = allDates;
|
||||
if (allDates.length > 0 && RANGE_DAYS[rangeKey] !== Infinity) {
|
||||
const start = new Date(cutoff);
|
||||
const end = new Date();
|
||||
const expanded = [];
|
||||
// Step through every day in the window
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
expanded.push(d.toISOString().split('T')[0]);
|
||||
}
|
||||
@@ -101,17 +101,9 @@ function buildChartData(history, rangeKey) {
|
||||
function updateChart() {
|
||||
if (!chartInstance) return;
|
||||
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
|
||||
|
||||
// Always push the new labels/datasets to the chart so the x-axis
|
||||
// reflects the selected time window — even when there's no data for
|
||||
// the active condition. Then toggle the empty state overlay on top.
|
||||
chartInstance.data.labels = labels;
|
||||
chartInstance.data.datasets = datasets;
|
||||
chartInstance.update('none');
|
||||
|
||||
// Show the empty state overlay if the active condition has no points
|
||||
// in this window, but leave the (empty) chart visible underneath so
|
||||
// the axis communicates the selected period.
|
||||
setEmptyState(!hasData || !activeConditionHasData);
|
||||
}
|
||||
|
||||
@@ -135,7 +127,6 @@ function initPriceChart(canvas) {
|
||||
|
||||
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
|
||||
|
||||
// Render the chart regardless — show empty state overlay if needed
|
||||
setEmptyState(!hasData || !activeConditionHasData);
|
||||
|
||||
chartInstance = new Chart(canvas.getContext('2d'), {
|
||||
@@ -202,9 +193,16 @@ function initFromCanvas(canvas) {
|
||||
activeCondition = "Near Mint";
|
||||
activeRange = '1m';
|
||||
const modal = document.getElementById('cardModal');
|
||||
|
||||
modal?.querySelectorAll('.price-range-btn').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.range === '1m');
|
||||
});
|
||||
|
||||
// Hide chart if the vendor tab is already active when the modal opens
|
||||
// (e.g. opened via the inventory button)
|
||||
const activeTab = modal?.querySelector('.nav-link.active')?.getAttribute('data-bs-target');
|
||||
setChartVisible(activeTab !== '#nav-vendor');
|
||||
|
||||
initPriceChart(canvas);
|
||||
}
|
||||
|
||||
@@ -225,6 +223,10 @@ function setup() {
|
||||
document.addEventListener('shown.bs.tab', (e) => {
|
||||
if (!modal.contains(e.target)) return;
|
||||
const target = e.target?.getAttribute('data-bs-target');
|
||||
|
||||
// Hide the chart when the vendor tab is active, show it for all others
|
||||
setChartVisible(target !== '#nav-vendor');
|
||||
|
||||
const conditionMap = {
|
||||
'#nav-nm': 'Near Mint',
|
||||
'#nav-lp': 'Lightly Played',
|
||||
|
||||
@@ -118,7 +118,6 @@ import BackToTop from "./BackToTop.astro"
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
|
||||
// Load with crossOrigin so toBlob() stays untainted
|
||||
await new Promise((resolve) => {
|
||||
const clean = new Image();
|
||||
clean.crossOrigin = 'anonymous';
|
||||
@@ -172,6 +171,19 @@ import BackToTop from "./BackToTop.astro"
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// ── Tab switching helper ──────────────────────────────────────────────────
|
||||
// Called after every modal swap. Checks sessionStorage for a tab request
|
||||
// set by the inventory button click, activates it once, then clears it.
|
||||
function switchToRequestedTab() {
|
||||
const tab = sessionStorage.getItem('openModalTab');
|
||||
if (!tab) return;
|
||||
sessionStorage.removeItem('openModalTab');
|
||||
requestAnimationFrame(() => {
|
||||
const tabEl = document.querySelector(`#cardModal [data-bs-target="#${tab}"]`);
|
||||
if (tabEl) bootstrap.Tab.getOrCreateInstance(tabEl).show();
|
||||
});
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
const cardIndex = [];
|
||||
let currentCardId = null;
|
||||
@@ -263,6 +275,7 @@ import BackToTop from "./BackToTop.astro"
|
||||
if (typeof htmx !== 'undefined') htmx.process(modal);
|
||||
updateNavButtons(modal);
|
||||
initChartAfterSwap(modal);
|
||||
switchToRequestedTab();
|
||||
};
|
||||
|
||||
if (document.startViewTransition && direction) {
|
||||
@@ -365,6 +378,7 @@ import BackToTop from "./BackToTop.astro"
|
||||
await transition.finished;
|
||||
updateNavButtons(target);
|
||||
initChartAfterSwap(target);
|
||||
switchToRequestedTab();
|
||||
|
||||
} catch (err) {
|
||||
console.error('[card-modal] transition failed:', err);
|
||||
@@ -383,10 +397,12 @@ import BackToTop from "./BackToTop.astro"
|
||||
cardModal.addEventListener('shown.bs.modal', () => {
|
||||
updateNavButtons(cardModal);
|
||||
initChartAfterSwap(cardModal);
|
||||
switchToRequestedTab();
|
||||
});
|
||||
cardModal.addEventListener('hidden.bs.modal', () => {
|
||||
currentCardId = null;
|
||||
updateNavButtons(null);
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
@@ -226,31 +226,31 @@ const altSearchUrl = (card: any) => {
|
||||
<ul class="nav nav-tabs nav-fill border-0 me-3 mb-2" id="myTab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link nm active" id="nm-tab" data-bs-toggle="tab" data-bs-target="#nav-nm" type="button" role="tab" aria-controls="nav-nm" aria-selected="true">
|
||||
<span class="d-none d-xxl-inline">Near Mint</span><span class="d-xxl-none">NM</span>
|
||||
<span class="d-none">Near Mint</span><span class="d-inline">NM</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link lp" id="lp-tab" data-bs-toggle="tab" data-bs-target="#nav-lp" type="button" role="tab" aria-controls="nav-lp" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Lightly Played</span><span class="d-xxl-none">LP</span>
|
||||
<span class="d-none">Lightly Played</span><span class="d-inline">LP</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link mp" id="mp-tab" data-bs-toggle="tab" data-bs-target="#nav-mp" type="button" role="tab" aria-controls="nav-mp" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Moderately Played</span><span class="d-xxl-none">MP</span>
|
||||
<span class="d-none">Moderately Played</span><span class="d-inline">MP</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link hp" id="hp-tab" data-bs-toggle="tab" data-bs-target="#nav-hp" type="button" role="tab" aria-controls="nav-hp" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Heavily Played</span><span class="d-xxl-none">HP</span>
|
||||
<span class="d-none">Heavily Played</span><span class="d-inline">HP</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link dmg" id="dmg-tab" data-bs-toggle="tab" data-bs-target="#nav-dmg" type="button" role="tab" aria-controls="nav-dmg" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Damaged</span><span class="d-xxl-none">DMG</span>
|
||||
<span class="d-none">Damaged</span><span class="d-inline">DMG</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link vendor d-none" id="vendor-tab" data-bs-toggle="tab" data-bs-target="#nav-vendor" type="button" role="tab" aria-controls="nav-vendor" aria-selected="false">
|
||||
<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>
|
||||
@@ -337,7 +337,159 @@ const altSearchUrl = (card: any) => {
|
||||
);
|
||||
})}
|
||||
|
||||
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0"></div>
|
||||
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0">
|
||||
<style>
|
||||
:root {
|
||||
--c-nm: 156, 204, 102;
|
||||
--c-lp: 211, 225, 86;
|
||||
--c-mp: 255, 238, 87;
|
||||
--c-hp: 255, 201, 41;
|
||||
--c-dmg: 255, 167, 36;
|
||||
}
|
||||
|
||||
.btn-check:checked + .btn-cond-nm { background: rgba(var(--c-nm), 1); border-color: rgba(var(--c-nm), 1); color: #2d4a10; }
|
||||
.btn-check:checked + .btn-cond-lp { background: rgba(var(--c-lp), 1); border-color: rgba(var(--c-lp), 1); color: #3a4310; }
|
||||
.btn-check:checked + .btn-cond-mp { background: rgba(var(--c-mp), 1); border-color: rgba(var(--c-mp), 1); color: #44420a; }
|
||||
.btn-check:checked + .btn-cond-hp { background: rgba(var(--c-hp), 1); border-color: rgba(var(--c-hp), 1); color: #4a3608; }
|
||||
.btn-check:checked + .btn-cond-dmg { background: rgba(var(--c-dmg), 1); border-color: rgba(var(--c-dmg), 1); color: #4a2c08; }
|
||||
|
||||
.btn-cond-nm, .btn-cond-lp, .btn-cond-mp, .btn-cond-hp, .btn-cond-dmg {
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
color: var(--bs-body-color);
|
||||
background: transparent;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
.btn-cond-nm:hover { background: rgba(var(--c-nm), 0.2); border-color: rgba(var(--c-nm), 0.6); }
|
||||
.btn-cond-lp:hover { background: rgba(var(--c-lp), 0.2); border-color: rgba(var(--c-lp), 0.6); }
|
||||
.btn-cond-mp:hover { background: rgba(var(--c-mp), 0.2); border-color: rgba(var(--c-mp), 0.6); }
|
||||
.btn-cond-hp:hover { background: rgba(var(--c-hp), 0.2); border-color: rgba(var(--c-hp), 0.6); }
|
||||
.btn-cond-dmg:hover { background: rgba(var(--c-dmg), 0.2); border-color: rgba(var(--c-dmg), 0.6); }
|
||||
|
||||
.price-toggle .btn { font-size: 0.75rem; padding: 0.25rem 0.6rem; line-height: 1; }
|
||||
</style>
|
||||
|
||||
<form id="inventoryForm" novalidate>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-4">
|
||||
<label for="quantity" class="form-label fw-medium">Quantity</label>
|
||||
<input type="number" class="form-control" id="quantity" name="quantity"
|
||||
min="1" step="1" value="1" required>
|
||||
<div class="invalid-feedback">Required.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-8">
|
||||
<label class="form-label fw-medium">Condition</label>
|
||||
<div class="btn-group w-100" 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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<label class="btn btn-cond-dmg" for="cond-dmg">DMG</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label for="purchasePrice" class="form-label fw-medium mb-0">Purchase price</label>
|
||||
<div class="btn-group btn-group-sm price-toggle" role="group" aria-label="Price mode">
|
||||
<input type="radio" class="btn-check" name="priceMode" id="mode-dollar" value="dollar" autocomplete="off" checked>
|
||||
<label class="btn btn-outline-secondary" for="mode-dollar">$ amount</label>
|
||||
<input type="radio" class="btn-check" name="priceMode" id="mode-percent" value="percent" autocomplete="off">
|
||||
<label class="btn btn-outline-secondary" for="mode-percent">% of market</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text" id="pricePrefix">$</span>
|
||||
<input type="number" class="form-control" id="purchasePrice" name="purchasePrice"
|
||||
min="0" step="0.01" placeholder="0.00"
|
||||
aria-describedby="pricePrefix priceSuffix priceHint" required>
|
||||
<span class="input-group-text d-none" id="priceSuffix">%</span>
|
||||
</div>
|
||||
<div class="form-text" id="priceHint">Enter the amount you paid.</div>
|
||||
<div class="invalid-feedback">Enter a purchase price.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="note" class="form-label fw-medium">
|
||||
Note
|
||||
<span class="text-body-tertiary fw-normal ms-1 small">optional</span>
|
||||
</label>
|
||||
<textarea class="form-control" id="note" name="note"
|
||||
rows="2" maxlength="255"
|
||||
placeholder="e.g. bought at local shop, gift, graded copy…"></textarea>
|
||||
<div class="form-text text-end" id="noteCount">0 / 255</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-success flex-fill">Save to inventory</button>
|
||||
<button type="reset" class="btn btn-outline-secondary">Reset</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const priceInput = document.getElementById('purchasePrice');
|
||||
const pricePrefix = document.getElementById('pricePrefix');
|
||||
const priceSuffix = document.getElementById('priceSuffix');
|
||||
const priceHint = document.getElementById('priceHint');
|
||||
const note = document.getElementById('note');
|
||||
const noteCount = document.getElementById('noteCount');
|
||||
|
||||
document.querySelectorAll('input[name="priceMode"]').forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
const isPct = radio.value === 'percent';
|
||||
pricePrefix.classList.toggle('d-none', isPct);
|
||||
priceSuffix.classList.toggle('d-none', !isPct);
|
||||
priceInput.step = isPct ? '1' : '0.01';
|
||||
priceInput.max = isPct ? '100' : '';
|
||||
priceInput.placeholder = isPct ? '100' : '0.00';
|
||||
priceInput.value = '';
|
||||
priceHint.textContent = isPct
|
||||
? 'Percentage of the current market price you paid (e.g. 80 = 80%).'
|
||||
: 'Enter the amount you paid.';
|
||||
});
|
||||
});
|
||||
|
||||
note.addEventListener('input', () => {
|
||||
noteCount.textContent = `${note.value.length} / 255`;
|
||||
});
|
||||
|
||||
document.getElementById('inventoryForm').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
if (!form.checkValidity()) { form.classList.add('was-validated'); return; }
|
||||
form.classList.remove('was-validated');
|
||||
// your save logic here — form data available via new FormData(form)
|
||||
});
|
||||
|
||||
document.getElementById('inventoryForm').addEventListener('reset', () => {
|
||||
document.getElementById('inventoryForm').classList.remove('was-validated');
|
||||
noteCount.textContent = '0 / 255';
|
||||
pricePrefix.classList.remove('d-none');
|
||||
priceSuffix.classList.add('d-none');
|
||||
priceInput.step = '0.01';
|
||||
priceInput.max = '';
|
||||
priceInput.placeholder = '0.00';
|
||||
priceHint.textContent = 'Enter the amount you paid.';
|
||||
document.getElementById('mode-dollar').checked = true;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart lives permanently outside tab-content so Bootstrap never hides it. -->
|
||||
|
||||
@@ -283,9 +283,9 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
||||
|
||||
{pokemon.map((card:any) => (
|
||||
<div class="col">
|
||||
<div class="inventory-button position-relative float-end shadow-filter text-center d-none">
|
||||
<div class="inventory-label pt-2">+/-</div>
|
||||
</div>
|
||||
<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={`/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='/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>
|
||||
|
||||
Reference in New Issue
Block a user