2026-02-17 13:27:48 -05:00
|
|
|
---
|
2026-03-01 20:04:35 -05:00
|
|
|
import BackToTop from "./BackToTop.astro"
|
2026-02-17 13:27:48 -05:00
|
|
|
---
|
2026-04-09 11:34:37 -04:00
|
|
|
<div class="container-fluid container-sm mt-3">
|
2026-02-28 20:47:32 -05:00
|
|
|
<div class="row mb-4">
|
2026-03-11 15:21:43 -04:00
|
|
|
<div class="col-md-2">
|
2026-02-28 20:47:32 -05:00
|
|
|
<div class="h5 d-none">Inventory management placeholder</div>
|
2026-03-04 17:02:17 -05:00
|
|
|
<div class="offcanvas offcanvas-start" data-bs-backdrop="static" tabindex="-1" id="filterBar" aria-labelledby="filterBarLabel">
|
2026-02-28 20:47:32 -05:00
|
|
|
<div class="offcanvas-header">
|
2026-03-04 17:02:17 -05:00
|
|
|
<h5 class="offcanvas-title" id="filterBarLabel">Filter by:</h5>
|
2026-02-28 20:47:32 -05:00
|
|
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="offcanvas-body px-3 pt-0">
|
2026-02-26 15:51:00 -05:00
|
|
|
<div id="facetContainer"></div>
|
2026-02-17 13:07:29 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-26 18:38:07 -05:00
|
|
|
</div>
|
2026-03-05 22:59:16 -05:00
|
|
|
<div class="col-sm-12 col-md-10 mt-0">
|
2026-03-17 11:27:16 -04:00
|
|
|
<div class="d-flex flex-column gap-1 flex-md-row align-items-center mb-3 w-100 justify-content-start">
|
2026-03-16 14:39:55 -04:00
|
|
|
<div id="sortBy"></div>
|
|
|
|
|
<div id="totalResults"></div>
|
2026-03-11 15:21:43 -04:00
|
|
|
<div id="activeFilters"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="cardGrid" aria-live="polite" class="row g-xxl-3 g-2 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5"></div>
|
|
|
|
|
<div id="notfound" aria-live="polite"></div>
|
2026-02-28 20:47:32 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-11 15:21:43 -04:00
|
|
|
|
|
|
|
|
<div class="modal card-modal" id="cardModal" tabindex="-1" aria-labelledby="cardModalLabel" aria-hidden="true">
|
2026-02-28 20:47:32 -05:00
|
|
|
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
|
2026-03-11 15:21:43 -04:00
|
|
|
<div class="modal-content p-2">Loading...</div>
|
2026-02-28 20:47:32 -05:00
|
|
|
</div>
|
2026-03-01 20:04:35 -05:00
|
|
|
</div>
|
2026-03-11 15:21:43 -04:00
|
|
|
|
|
|
|
|
<button id="modalPrevBtn" class="modal-nav-btn modal-nav-prev d-none" aria-label="Previous card">
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
|
|
|
|
|
<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
<button id="modalNextBtn" class="modal-nav-btn modal-nav-next d-none" aria-label="Next card">
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
|
|
|
|
|
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<BackToTop />
|
2026-04-09 11:34:37 -04:00
|
|
|
</div>
|
2026-03-18 13:31:56 -04:00
|
|
|
|
2026-03-11 15:21:43 -04:00
|
|
|
<script is:inline>
|
|
|
|
|
(function () {
|
|
|
|
|
|
2026-04-05 16:09:52 -04:00
|
|
|
// ── Price mode helpers ────────────────────────────────────────────────────
|
|
|
|
|
// marketPriceByCondition is injected into the modal HTML via a data attribute
|
|
|
|
|
// on #inventoryEntryList: data-market-prices='{"Near Mint":6.00,...}'
|
|
|
|
|
// See card-modal.astro for where this is set.
|
|
|
|
|
|
|
|
|
|
function getMarketPrices(form) {
|
|
|
|
|
const listEl = form.closest('.tab-pane')?.querySelector('#inventoryEntryList')
|
|
|
|
|
?? document.getElementById('inventoryEntryList');
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(listEl?.dataset.marketPrices || '{}');
|
|
|
|
|
} catch {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyPriceModeUI(form, mode) {
|
|
|
|
|
const priceInput = form.querySelector('#purchasePrice');
|
|
|
|
|
const pricePrefix = form.querySelector('#pricePrefix');
|
|
|
|
|
const priceSuffix = form.querySelector('#priceSuffix');
|
|
|
|
|
const priceHint = form.querySelector('#priceHint');
|
|
|
|
|
if (!priceInput) return;
|
|
|
|
|
|
|
|
|
|
const isPct = mode === '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 ? '0' : '0.00';
|
|
|
|
|
priceInput.classList.toggle('rounded-end', !isPct);
|
|
|
|
|
priceInput.classList.toggle('rounded-start', isPct);
|
|
|
|
|
|
|
|
|
|
if (priceHint && !isPct) priceHint.textContent = 'Enter the purchase price.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updatePriceHint(form) {
|
|
|
|
|
const priceInput = form.querySelector('#purchasePrice');
|
|
|
|
|
const priceHint = form.querySelector('#priceHint');
|
|
|
|
|
if (!priceInput || !priceHint) return;
|
|
|
|
|
|
|
|
|
|
const mode = form.querySelector('input[name="priceMode"]:checked')?.value ?? 'dollar';
|
|
|
|
|
if (mode !== 'percent') { priceHint.textContent = 'Enter the purchase price.'; return; }
|
|
|
|
|
|
|
|
|
|
const condition = form.querySelector('input[name="condition"]:checked')?.value ?? 'Near Mint';
|
|
|
|
|
const prices = getMarketPrices(form);
|
|
|
|
|
const marketPrice = prices[condition] ?? 0;
|
|
|
|
|
const pct = parseFloat(priceInput.value) || 0;
|
|
|
|
|
const resolved = ((pct / 100) * marketPrice).toFixed(2);
|
|
|
|
|
priceHint.textContent = marketPrice
|
|
|
|
|
? `= $${resolved} (${pct}% of $${marketPrice.toFixed(2)} market)`
|
|
|
|
|
: 'No market price available for this condition.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveFormPrice(form) {
|
|
|
|
|
// Returns a FormData ready to POST; % is converted to $ in-place.
|
|
|
|
|
const data = new FormData(form);
|
|
|
|
|
const mode = data.get('priceMode');
|
|
|
|
|
if (mode === 'percent') {
|
|
|
|
|
const condition = data.get('condition');
|
|
|
|
|
const prices = getMarketPrices(form);
|
|
|
|
|
const marketPrice = prices[condition] ?? 0;
|
|
|
|
|
const pct = parseFloat(data.get('purchasePrice')) || 0;
|
|
|
|
|
data.set('purchasePrice', ((pct / 100) * marketPrice).toFixed(2));
|
|
|
|
|
}
|
|
|
|
|
data.delete('priceMode'); // UI-only field
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Empty state helper ────────────────────────────────────────────────────
|
|
|
|
|
function syncEmptyState(invList) {
|
|
|
|
|
const emptyState = document.getElementById('inventoryEmptyState');
|
|
|
|
|
if (!emptyState) return;
|
|
|
|
|
const hasEntries = invList.querySelector('[data-inventory-id]') !== null;
|
|
|
|
|
emptyState.classList.toggle('d-none', hasEntries);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Inventory form init (binding price-mode UI events) ───────────────────
|
2026-03-25 08:42:17 -04:00
|
|
|
function initInventoryForms(root = document) {
|
2026-04-03 22:10:41 -04:00
|
|
|
// Fetch inventory entries for this card
|
|
|
|
|
const invList = root.querySelector('#inventoryEntryList') || document.getElementById('inventoryEntryList');
|
|
|
|
|
if (invList && !invList.dataset.inventoryFetched) {
|
|
|
|
|
invList.dataset.inventoryFetched = 'true';
|
|
|
|
|
const cardId = invList.dataset.cardId;
|
|
|
|
|
if (cardId) {
|
|
|
|
|
const body = new FormData();
|
|
|
|
|
body.append('cardId', cardId);
|
|
|
|
|
fetch('/api/inventory', { method: 'POST', body })
|
|
|
|
|
.then(r => r.text())
|
2026-04-05 16:09:52 -04:00
|
|
|
.then(html => {
|
|
|
|
|
invList.innerHTML = html || '';
|
|
|
|
|
syncEmptyState(invList);
|
|
|
|
|
})
|
2026-04-03 22:10:41 -04:00
|
|
|
.catch(() => { invList.innerHTML = '<span class="text-danger">Failed to load inventory</span>'; });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 08:42:17 -04:00
|
|
|
const forms = root.querySelectorAll('[data-inventory-form]');
|
|
|
|
|
|
|
|
|
|
forms.forEach((form) => {
|
|
|
|
|
if (form.dataset.inventoryBound === 'true') return;
|
|
|
|
|
form.dataset.inventoryBound = 'true';
|
|
|
|
|
|
2026-04-05 16:09:52 -04:00
|
|
|
const priceInput = form.querySelector('#purchasePrice');
|
|
|
|
|
const modeInputs = form.querySelectorAll('input[name="priceMode"]');
|
|
|
|
|
const condInputs = form.querySelectorAll('input[name="condition"]');
|
2026-03-25 08:42:17 -04:00
|
|
|
|
2026-04-05 16:09:52 -04:00
|
|
|
// Set initial UI state
|
|
|
|
|
const checkedMode = form.querySelector('input[name="priceMode"]:checked')?.value ?? 'dollar';
|
|
|
|
|
applyPriceModeUI(form, checkedMode);
|
2026-03-25 08:42:17 -04:00
|
|
|
|
2026-04-05 16:09:52 -04:00
|
|
|
// Mode toggle
|
|
|
|
|
modeInputs.forEach((input) => {
|
|
|
|
|
input.addEventListener('change', () => {
|
|
|
|
|
if (priceInput) priceInput.value = ''; // clear stale value on mode switch
|
|
|
|
|
applyPriceModeUI(form, input.value);
|
|
|
|
|
updatePriceHint(form);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-03-25 08:42:17 -04:00
|
|
|
|
2026-04-05 16:09:52 -04:00
|
|
|
// Condition change updates the hint when in % mode
|
|
|
|
|
condInputs.forEach((input) => {
|
|
|
|
|
input.addEventListener('change', () => updatePriceHint(form));
|
|
|
|
|
});
|
2026-03-25 08:42:17 -04:00
|
|
|
|
2026-04-05 16:09:52 -04:00
|
|
|
// Live hint as user types
|
|
|
|
|
priceInput?.addEventListener('input', () => updatePriceHint(form));
|
2026-03-25 08:42:17 -04:00
|
|
|
|
2026-04-05 16:09:52 -04:00
|
|
|
// Reset — restore to $ mode
|
|
|
|
|
form.addEventListener('reset', () => {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
applyPriceModeUI(form, 'dollar');
|
|
|
|
|
updatePriceHint(form);
|
|
|
|
|
}, 0);
|
2026-03-25 08:42:17 -04:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 14:39:55 -04:00
|
|
|
// ── Sort dropdown ─────────────────────────────────────────────────────────
|
|
|
|
|
document.addEventListener('click', (e) => {
|
2026-04-09 14:58:15 -04:00
|
|
|
const btn = e.target.closest('#sortBy [data-toggle="sort-dropdown"]');
|
2026-03-16 14:39:55 -04:00
|
|
|
if (btn) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
const menu = btn.nextElementSibling;
|
|
|
|
|
menu.classList.toggle('show');
|
|
|
|
|
btn.setAttribute('aria-expanded', menu.classList.contains('show'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const opt = e.target.closest('#sortBy .sort-option');
|
|
|
|
|
if (opt) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const menu = opt.closest('.dropdown-menu');
|
|
|
|
|
const btn2 = menu?.previousElementSibling;
|
|
|
|
|
menu?.classList.remove('show');
|
|
|
|
|
if (btn2) btn2.setAttribute('aria-expanded', 'false');
|
|
|
|
|
|
|
|
|
|
const sortInput = document.getElementById('sortInput');
|
|
|
|
|
if (sortInput) sortInput.value = opt.dataset.sort;
|
|
|
|
|
document.getElementById('sortLabel').textContent = opt.dataset.label;
|
|
|
|
|
document.querySelectorAll('.sort-option').forEach(o => o.classList.remove('active'));
|
|
|
|
|
opt.classList.add('active');
|
|
|
|
|
|
|
|
|
|
const start = document.getElementById('start');
|
|
|
|
|
if (start) start.value = '0';
|
|
|
|
|
document.getElementById('searchform').dispatchEvent(
|
|
|
|
|
new Event('submit', { bubbles: true, cancelable: true })
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const menu = document.querySelector('#sortBy .dropdown-menu.show');
|
|
|
|
|
if (menu) {
|
|
|
|
|
menu.classList.remove('show');
|
|
|
|
|
const btn3 = menu.previousElementSibling;
|
|
|
|
|
if (btn3) btn3.setAttribute('aria-expanded', 'false');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-17 11:27:16 -04:00
|
|
|
// ── Language toggle ───────────────────────────────────────────────────────
|
|
|
|
|
document.addEventListener('click', (e) => {
|
|
|
|
|
const btn = e.target.closest('.language-btn');
|
|
|
|
|
if (!btn) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
const input = document.getElementById('languageInput');
|
|
|
|
|
if (input) input.value = btn.dataset.lang;
|
|
|
|
|
|
|
|
|
|
const start = document.getElementById('start');
|
|
|
|
|
if (start) start.value = '0';
|
|
|
|
|
|
|
|
|
|
document.getElementById('searchform').dispatchEvent(
|
|
|
|
|
new Event('submit', { bubbles: true, cancelable: true })
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-16 11:05:10 -04:00
|
|
|
// ── Global helpers ────────────────────────────────────────────────────────
|
2026-03-12 13:40:12 -04:00
|
|
|
window.copyImage = async function(img) {
|
2026-04-05 13:11:46 -04:00
|
|
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
|
|
|
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
|
|
|
|
2026-03-12 13:40:12 -04:00
|
|
|
try {
|
|
|
|
|
const canvas = document.createElement("canvas");
|
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
|
canvas.width = img.naturalWidth;
|
|
|
|
|
canvas.height = img.naturalHeight;
|
2026-03-18 13:31:56 -04:00
|
|
|
|
|
|
|
|
await new Promise((resolve) => {
|
|
|
|
|
const clean = new Image();
|
|
|
|
|
clean.crossOrigin = 'anonymous';
|
|
|
|
|
clean.onload = () => { ctx.drawImage(clean, 0, 0); resolve(); };
|
|
|
|
|
clean.onerror = () => { ctx.drawImage(img, 0, 0); resolve(); };
|
|
|
|
|
clean.src = img.src;
|
|
|
|
|
});
|
2026-03-12 13:40:12 -04:00
|
|
|
|
2026-04-05 13:11:46 -04:00
|
|
|
const blob = await new Promise((resolve, reject) => {
|
|
|
|
|
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (isIOS) {
|
|
|
|
|
const file = new File([blob], 'card.png', { type: 'image/png' });
|
|
|
|
|
await navigator.share({ files: [file] });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 13:40:12 -04:00
|
|
|
if (navigator.clipboard && navigator.clipboard.write) {
|
|
|
|
|
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
|
|
|
|
showCopyToast('📋 Image copied!', '#198754');
|
|
|
|
|
} else {
|
|
|
|
|
const url = img.src;
|
|
|
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
|
|
|
await navigator.clipboard.writeText(url);
|
|
|
|
|
showCopyToast('📋 Image URL copied!', '#198754');
|
|
|
|
|
} else {
|
|
|
|
|
const input = document.createElement('input');
|
|
|
|
|
input.value = url;
|
|
|
|
|
document.body.appendChild(input);
|
|
|
|
|
input.select();
|
|
|
|
|
document.execCommand('copy');
|
|
|
|
|
document.body.removeChild(input);
|
|
|
|
|
showCopyToast('📋 Image URL copied!', '#198754');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
2026-04-05 13:11:46 -04:00
|
|
|
if (err.name === 'AbortError') return;
|
2026-03-12 13:40:12 -04:00
|
|
|
console.error('Failed:', err);
|
|
|
|
|
showCopyToast('❌ Copy failed', '#dc3545');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function showCopyToast(message, color) {
|
|
|
|
|
const toast = document.createElement('div');
|
|
|
|
|
toast.textContent = message;
|
|
|
|
|
toast.style.cssText = `
|
|
|
|
|
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
|
|
|
|
background: ${color}; color: white; padding: 10px 20px;
|
|
|
|
|
border-radius: 8px; font-size: 14px; z-index: 9999;
|
|
|
|
|
opacity: 0; transition: opacity 0.2s ease;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
`;
|
|
|
|
|
document.body.appendChild(toast);
|
|
|
|
|
requestAnimationFrame(() => toast.style.opacity = '1');
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
toast.style.opacity = '0';
|
|
|
|
|
toast.addEventListener('transitionend', () => toast.remove());
|
|
|
|
|
}, 2000);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 08:41:21 -04:00
|
|
|
// ── Tab switching helper ──────────────────────────────────────────────────
|
|
|
|
|
function switchToRequestedTab() {
|
|
|
|
|
const tab = sessionStorage.getItem('openModalTab');
|
|
|
|
|
if (!tab) return;
|
|
|
|
|
sessionStorage.removeItem('openModalTab');
|
|
|
|
|
requestAnimationFrame(() => {
|
2026-03-25 08:42:17 -04:00
|
|
|
try {
|
|
|
|
|
const tabEl = document.querySelector(`#cardModal [data-bs-target="#${tab}"]`);
|
|
|
|
|
if (tabEl) bootstrap.Tab.getOrCreateInstance(tabEl).show();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
}
|
2026-03-25 08:41:21 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 11:05:10 -04:00
|
|
|
// ── State ─────────────────────────────────────────────────────────────────
|
2026-03-11 15:21:43 -04:00
|
|
|
const cardIndex = [];
|
|
|
|
|
let currentCardId = null;
|
|
|
|
|
let isNavigating = false;
|
|
|
|
|
|
|
|
|
|
// ── Register cards as HTMX loads them ────────────────────────────────────
|
|
|
|
|
const cardGrid = document.getElementById('cardGrid');
|
|
|
|
|
const gridObserver = new MutationObserver((mutations) => {
|
|
|
|
|
for (const mutation of mutations) {
|
|
|
|
|
for (const node of mutation.addedNodes) {
|
|
|
|
|
if (node.nodeType !== 1) continue;
|
|
|
|
|
const triggers = node.querySelectorAll
|
|
|
|
|
? node.querySelectorAll('[data-card-id]')
|
|
|
|
|
: [];
|
|
|
|
|
for (const el of triggers) {
|
|
|
|
|
const id = Number(el.getAttribute('data-card-id'));
|
|
|
|
|
if (id && !cardIndex.includes(id)) cardIndex.push(id);
|
|
|
|
|
}
|
|
|
|
|
if (node.dataset?.cardId) {
|
|
|
|
|
const id = Number(node.dataset.cardId);
|
|
|
|
|
if (id && !cardIndex.includes(id)) cardIndex.push(id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
gridObserver.observe(cardGrid, { childList: true, subtree: true });
|
|
|
|
|
|
|
|
|
|
// ── Navigation helpers ────────────────────────────────────────────────────
|
|
|
|
|
function getAdjacentIds() {
|
|
|
|
|
const idx = cardIndex.indexOf(currentCardId);
|
|
|
|
|
return {
|
|
|
|
|
prev: idx > 0 ? cardIndex[idx - 1] : null,
|
|
|
|
|
next: idx < cardIndex.length - 1 ? cardIndex[idx + 1] : null,
|
|
|
|
|
idx,
|
|
|
|
|
total: cardIndex.length,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateNavButtons(modal) {
|
|
|
|
|
const prevBtn = document.getElementById('modalPrevBtn');
|
|
|
|
|
const nextBtn = document.getElementById('modalNextBtn');
|
|
|
|
|
if (!modal || !modal.classList.contains('show')) {
|
|
|
|
|
prevBtn.classList.add('d-none');
|
|
|
|
|
nextBtn.classList.add('d-none');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const { prev, next } = getAdjacentIds();
|
|
|
|
|
prevBtn.classList.toggle('d-none', prev === null);
|
|
|
|
|
nextBtn.classList.toggle('d-none', next === null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function tryTriggerSentinel() {
|
|
|
|
|
const sentinel = cardGrid.querySelector('[hx-trigger="revealed"]');
|
|
|
|
|
if (!sentinel) return;
|
|
|
|
|
if (typeof htmx !== 'undefined') {
|
|
|
|
|
htmx.trigger(sentinel, 'revealed');
|
|
|
|
|
} else {
|
|
|
|
|
sentinel.scrollIntoView({ behavior: 'instant', block: 'end' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 11:05:10 -04:00
|
|
|
function initChartAfterSwap(modal) {
|
|
|
|
|
const canvas = modal.querySelector('#priceHistoryChart');
|
|
|
|
|
if (!canvas) return;
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
modal.dispatchEvent(new CustomEvent('card-modal:swapped', { bubbles: false }));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 15:21:43 -04:00
|
|
|
async function loadCard(cardId, direction = null) {
|
|
|
|
|
if (!cardId || isNavigating) return;
|
|
|
|
|
isNavigating = true;
|
|
|
|
|
|
|
|
|
|
currentCardId = cardId;
|
|
|
|
|
|
|
|
|
|
const modal = document.getElementById('cardModal');
|
|
|
|
|
const url = `/partials/card-modal?cardId=${cardId}`;
|
|
|
|
|
|
|
|
|
|
const { idx, total } = getAdjacentIds();
|
2026-03-16 11:05:10 -04:00
|
|
|
if (idx >= total - 3) tryTriggerSentinel();
|
2026-03-11 15:21:43 -04:00
|
|
|
|
|
|
|
|
const doSwap = async () => {
|
|
|
|
|
const response = await fetch(url);
|
|
|
|
|
const html = await response.text();
|
2026-03-16 11:05:10 -04:00
|
|
|
|
|
|
|
|
if (modal._reconnectChartObserver) modal._reconnectChartObserver();
|
|
|
|
|
|
2026-03-25 08:42:17 -04:00
|
|
|
modal.querySelectorAll('[data-bs-toggle="tab"]').forEach(el => {
|
|
|
|
|
bootstrap.Tab.getInstance(el)?.dispose();
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-11 15:21:43 -04:00
|
|
|
modal.innerHTML = html;
|
2026-03-25 08:42:17 -04:00
|
|
|
|
2026-03-11 15:21:43 -04:00
|
|
|
if (typeof htmx !== 'undefined') htmx.process(modal);
|
2026-03-25 08:42:17 -04:00
|
|
|
initInventoryForms(modal);
|
2026-03-11 15:21:43 -04:00
|
|
|
updateNavButtons(modal);
|
2026-03-16 11:05:10 -04:00
|
|
|
initChartAfterSwap(modal);
|
2026-03-25 08:41:21 -04:00
|
|
|
switchToRequestedTab();
|
2026-03-11 15:21:43 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (document.startViewTransition && direction) {
|
|
|
|
|
modal.dataset.navDirection = direction;
|
|
|
|
|
await document.startViewTransition(doSwap).finished;
|
|
|
|
|
delete modal.dataset.navDirection;
|
|
|
|
|
} else {
|
|
|
|
|
await doSwap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isNavigating = false;
|
|
|
|
|
|
|
|
|
|
const { idx: newIdx, total: newTotal } = getAdjacentIds();
|
2026-03-16 11:05:10 -04:00
|
|
|
if (newIdx >= newTotal - 3) tryTriggerSentinel();
|
2026-03-11 15:21:43 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function navigatePrev() {
|
|
|
|
|
const { prev } = getAdjacentIds();
|
|
|
|
|
if (prev) loadCard(prev, 'prev');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function navigateNext() {
|
|
|
|
|
const { next } = getAdjacentIds();
|
|
|
|
|
if (next) loadCard(next, 'next');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.getElementById('modalPrevBtn').addEventListener('click', navigatePrev);
|
|
|
|
|
document.getElementById('modalNextBtn').addEventListener('click', navigateNext);
|
|
|
|
|
|
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
|
|
|
const modal = document.getElementById('cardModal');
|
|
|
|
|
if (!modal.classList.contains('show')) return;
|
|
|
|
|
if (e.key === 'ArrowLeft') { e.preventDefault(); navigatePrev(); }
|
|
|
|
|
if (e.key === 'ArrowRight') { e.preventDefault(); navigateNext(); }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let touchStartX = 0;
|
|
|
|
|
let touchStartY = 0;
|
|
|
|
|
const SWIPE_THRESHOLD = 50;
|
|
|
|
|
|
|
|
|
|
document.getElementById('cardModal').addEventListener('touchstart', (e) => {
|
|
|
|
|
touchStartX = e.touches[0].clientX;
|
|
|
|
|
touchStartY = e.touches[0].clientY;
|
|
|
|
|
}, { passive: true });
|
|
|
|
|
|
|
|
|
|
document.getElementById('cardModal').addEventListener('touchend', (e) => {
|
|
|
|
|
const dx = e.changedTouches[0].clientX - touchStartX;
|
|
|
|
|
const dy = e.changedTouches[0].clientY - touchStartY;
|
|
|
|
|
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dy) > Math.abs(dx)) return;
|
|
|
|
|
if (dx < 0) navigateNext();
|
|
|
|
|
else navigatePrev();
|
|
|
|
|
}, { passive: true });
|
|
|
|
|
|
|
|
|
|
document.body.addEventListener('htmx:beforeRequest', async (e) => {
|
|
|
|
|
if (e.detail.elt.getAttribute('hx-target') !== '#cardModal') return;
|
|
|
|
|
|
|
|
|
|
const cardEl = e.detail.elt.closest('[data-card-id]');
|
|
|
|
|
if (cardEl) currentCardId = Number(cardEl.getAttribute('data-card-id'));
|
|
|
|
|
|
|
|
|
|
if (!document.startViewTransition) return;
|
|
|
|
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
const url = e.detail.requestConfig.path;
|
|
|
|
|
const target = document.getElementById('cardModal');
|
|
|
|
|
const sourceImg = cardEl?.querySelector('img');
|
|
|
|
|
|
|
|
|
|
const response = await fetch(url, { headers: { 'HX-Request': 'true' } });
|
|
|
|
|
if (!response.ok) throw new Error(`Failed to load modal: ${response.status}`);
|
|
|
|
|
const html = await response.text();
|
|
|
|
|
|
2026-03-12 13:40:12 -04:00
|
|
|
const transitionName = `card-hero-${currentCardId}`;
|
|
|
|
|
|
2026-03-11 15:21:43 -04:00
|
|
|
try {
|
|
|
|
|
if (sourceImg) {
|
2026-03-12 13:40:12 -04:00
|
|
|
sourceImg.style.viewTransitionName = transitionName;
|
2026-03-16 11:05:10 -04:00
|
|
|
sourceImg.style.opacity = '0';
|
2026-03-11 15:21:43 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const transition = document.startViewTransition(async () => {
|
2026-03-12 13:40:12 -04:00
|
|
|
if (sourceImg) sourceImg.style.viewTransitionName = '';
|
|
|
|
|
|
2026-03-16 11:05:10 -04:00
|
|
|
if (target._reconnectChartObserver) target._reconnectChartObserver();
|
|
|
|
|
|
2026-03-25 08:42:17 -04:00
|
|
|
target.querySelectorAll('[data-bs-toggle="tab"]').forEach(el => {
|
|
|
|
|
bootstrap.Tab.getInstance(el)?.dispose();
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-11 15:21:43 -04:00
|
|
|
target.innerHTML = html;
|
2026-03-25 08:42:17 -04:00
|
|
|
|
2026-03-11 15:21:43 -04:00
|
|
|
if (typeof htmx !== 'undefined') htmx.process(target);
|
2026-03-25 08:42:17 -04:00
|
|
|
initInventoryForms(target);
|
2026-03-11 15:21:43 -04:00
|
|
|
|
|
|
|
|
const destImg = target.querySelector('img.card-image');
|
|
|
|
|
if (destImg) {
|
2026-03-16 11:05:10 -04:00
|
|
|
destImg.style.viewTransitionName = transitionName;
|
2026-03-11 15:21:43 -04:00
|
|
|
if (!destImg.complete) {
|
|
|
|
|
await new Promise(resolve => {
|
|
|
|
|
destImg.addEventListener('load', resolve, { once: true });
|
|
|
|
|
destImg.addEventListener('error', resolve, { once: true });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await transition.finished;
|
|
|
|
|
updateNavButtons(target);
|
2026-03-16 11:05:10 -04:00
|
|
|
initChartAfterSwap(target);
|
2026-03-25 08:41:21 -04:00
|
|
|
switchToRequestedTab();
|
2026-03-11 15:21:43 -04:00
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[card-modal] transition failed:', err);
|
|
|
|
|
e.detail.elt.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
|
|
|
} finally {
|
|
|
|
|
if (sourceImg) {
|
|
|
|
|
sourceImg.style.viewTransitionName = '';
|
2026-03-16 11:05:10 -04:00
|
|
|
sourceImg.style.opacity = '';
|
2026-03-11 15:21:43 -04:00
|
|
|
}
|
|
|
|
|
const destImg = target.querySelector('img.card-image');
|
|
|
|
|
if (destImg) destImg.style.viewTransitionName = '';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const cardModal = document.getElementById('cardModal');
|
2026-04-03 22:50:54 -04:00
|
|
|
|
|
|
|
|
// ── Delegated submit handler for inventory form ──────────────────────────
|
|
|
|
|
cardModal.addEventListener('submit', async (e) => {
|
|
|
|
|
const form = e.target.closest('[data-inventory-form]');
|
|
|
|
|
if (!form) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
if (!form.checkValidity()) { form.classList.add('was-validated'); return; }
|
|
|
|
|
|
|
|
|
|
const cardId = form.closest('[data-card-id]')?.dataset.cardId;
|
|
|
|
|
if (!cardId) return;
|
|
|
|
|
|
|
|
|
|
const submitBtn = form.querySelector('button[type="submit"]');
|
|
|
|
|
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Saving…'; }
|
|
|
|
|
|
2026-04-05 16:09:52 -04:00
|
|
|
// resolveFormPrice converts % → $ and strips priceMode before POSTing
|
|
|
|
|
const body = resolveFormPrice(form);
|
2026-04-03 22:50:54 -04:00
|
|
|
body.append('action', 'add');
|
|
|
|
|
body.append('cardId', cardId);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/inventory', { method: 'POST', body });
|
|
|
|
|
const html = await res.text();
|
|
|
|
|
const invList = document.getElementById('inventoryEntryList');
|
2026-04-05 16:09:52 -04:00
|
|
|
if (invList) {
|
|
|
|
|
invList.innerHTML = html || '';
|
|
|
|
|
syncEmptyState(invList);
|
|
|
|
|
}
|
2026-04-03 22:50:54 -04:00
|
|
|
form.reset();
|
|
|
|
|
form.classList.remove('was-validated');
|
2026-04-05 16:09:52 -04:00
|
|
|
// reset fires our listener which restores $ mode UI
|
2026-04-03 22:50:54 -04:00
|
|
|
} catch {
|
|
|
|
|
// keep current inventory list state
|
|
|
|
|
} finally {
|
|
|
|
|
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Save to inventory'; }
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Delegated click handler for inventory entry buttons ─────────────────
|
|
|
|
|
cardModal.addEventListener('click', async (e) => {
|
|
|
|
|
const btn = e.target.closest('[data-inv-action]');
|
|
|
|
|
if (!btn) return;
|
|
|
|
|
|
|
|
|
|
const article = btn.closest('[data-inventory-id]');
|
|
|
|
|
if (!article) return;
|
|
|
|
|
|
|
|
|
|
const action = btn.dataset.invAction;
|
|
|
|
|
const inventoryId = article.dataset.inventoryId;
|
|
|
|
|
const cardId = article.dataset.cardId;
|
|
|
|
|
const qtyEl = article.querySelector('[data-inv-qty]');
|
|
|
|
|
let qty = Number(qtyEl?.textContent) || 1;
|
|
|
|
|
|
|
|
|
|
if (action === 'increment') {
|
|
|
|
|
qtyEl.textContent = ++qty;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (action === 'decrement') {
|
|
|
|
|
if (qty > 1) qtyEl.textContent = --qty;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// update or remove — POST to API and reload inventory list
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
const body = new FormData();
|
|
|
|
|
body.append('cardId', cardId);
|
|
|
|
|
|
|
|
|
|
if (action === 'update') {
|
|
|
|
|
body.append('action', 'update');
|
|
|
|
|
body.append('inventoryId', inventoryId);
|
|
|
|
|
body.append('quantity', String(qty));
|
|
|
|
|
body.append('purchasePrice', article.dataset.purchasePrice);
|
|
|
|
|
body.append('note', article.dataset.note || '');
|
|
|
|
|
} else if (action === 'remove') {
|
|
|
|
|
body.append('action', 'remove');
|
|
|
|
|
body.append('inventoryId', inventoryId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/inventory', { method: 'POST', body });
|
|
|
|
|
const html = await res.text();
|
|
|
|
|
const invList = document.getElementById('inventoryEntryList');
|
2026-04-05 16:09:52 -04:00
|
|
|
if (invList) {
|
|
|
|
|
invList.innerHTML = html || '';
|
|
|
|
|
syncEmptyState(invList);
|
|
|
|
|
}
|
2026-04-03 22:50:54 -04:00
|
|
|
} catch {
|
|
|
|
|
// keep current state
|
|
|
|
|
} finally {
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-11 15:21:43 -04:00
|
|
|
cardModal.addEventListener('shown.bs.modal', () => {
|
|
|
|
|
updateNavButtons(cardModal);
|
2026-03-16 11:05:10 -04:00
|
|
|
initChartAfterSwap(cardModal);
|
2026-03-25 08:42:17 -04:00
|
|
|
initInventoryForms(cardModal);
|
2026-03-25 08:41:21 -04:00
|
|
|
switchToRequestedTab();
|
2026-03-11 15:21:43 -04:00
|
|
|
});
|
2026-03-25 08:42:17 -04:00
|
|
|
|
2026-03-11 15:21:43 -04:00
|
|
|
cardModal.addEventListener('hidden.bs.modal', () => {
|
|
|
|
|
currentCardId = null;
|
|
|
|
|
updateNavButtons(null);
|
|
|
|
|
});
|
2026-04-05 10:17:43 -04:00
|
|
|
|
2026-03-25 08:42:17 -04:00
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
|
initInventoryForms();
|
2026-04-09 11:34:37 -04:00
|
|
|
|
|
|
|
|
const pending = sessionStorage.getItem('pendingSearch');
|
|
|
|
|
if (pending) {
|
|
|
|
|
sessionStorage.removeItem('pendingSearch');
|
|
|
|
|
const input = document.getElementById('searchInput');
|
|
|
|
|
if (input) input.value = pending;
|
|
|
|
|
// The form's hx-trigger="load" will fire automatically on page load,
|
|
|
|
|
// picking up the pre-populated input value — no manual trigger needed.
|
2026-05-26 05:32:26 -04:00
|
|
|
}
|
2026-04-05 10:17:43 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-11 15:21:43 -04:00
|
|
|
})();
|
|
|
|
|
</script>
|