Merge branch 'feat/inventory'
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
---
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-info p-2 rounded-circle"
|
||||
class="btn btn-light p-2 rounded-squircle"
|
||||
aria-label="Back to Top"
|
||||
aria-hidden="true"
|
||||
id="btn-back-to-top"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import BackToTop from "./BackToTop.astro"
|
||||
---
|
||||
<div class="container-fluid container-sm mt-3">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-2">
|
||||
<div class="h5 d-none">Inventory management placeholder</div>
|
||||
@@ -43,17 +44,150 @@ import BackToTop from "./BackToTop.astro"
|
||||
</button>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<!-- <script src="src/assets/js/holofoil-init.js" is:inline></script> -->
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
|
||||
// ── 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) ───────────────────
|
||||
function initInventoryForms(root = document) {
|
||||
// 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())
|
||||
.then(html => {
|
||||
invList.innerHTML = html || '';
|
||||
syncEmptyState(invList);
|
||||
})
|
||||
.catch(() => { invList.innerHTML = '<span class="text-danger">Failed to load inventory</span>'; });
|
||||
}
|
||||
}
|
||||
|
||||
const forms = root.querySelectorAll('[data-inventory-form]');
|
||||
|
||||
forms.forEach((form) => {
|
||||
if (form.dataset.inventoryBound === 'true') return;
|
||||
form.dataset.inventoryBound = 'true';
|
||||
|
||||
const priceInput = form.querySelector('#purchasePrice');
|
||||
const modeInputs = form.querySelectorAll('input[name="priceMode"]');
|
||||
const condInputs = form.querySelectorAll('input[name="condition"]');
|
||||
|
||||
// Set initial UI state
|
||||
const checkedMode = form.querySelector('input[name="priceMode"]:checked')?.value ?? 'dollar';
|
||||
applyPriceModeUI(form, checkedMode);
|
||||
|
||||
// Mode toggle
|
||||
modeInputs.forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
if (priceInput) priceInput.value = ''; // clear stale value on mode switch
|
||||
applyPriceModeUI(form, input.value);
|
||||
updatePriceHint(form);
|
||||
});
|
||||
});
|
||||
|
||||
// Condition change updates the hint when in % mode
|
||||
condInputs.forEach((input) => {
|
||||
input.addEventListener('change', () => updatePriceHint(form));
|
||||
});
|
||||
|
||||
// Live hint as user types
|
||||
priceInput?.addEventListener('input', () => updatePriceHint(form));
|
||||
|
||||
// Reset — restore to $ mode
|
||||
form.addEventListener('reset', () => {
|
||||
setTimeout(() => {
|
||||
applyPriceModeUI(form, 'dollar');
|
||||
updatePriceHint(form);
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sort dropdown ─────────────────────────────────────────────────────────
|
||||
document.addEventListener('click', (e) => {
|
||||
const sortBy = document.getElementById('sortBy');
|
||||
|
||||
const btn = e.target.closest('#sortBy [data-bs-toggle="dropdown"]');
|
||||
const btn = e.target.closest('#sortBy [data-toggle="sort-dropdown"]');
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -121,7 +255,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';
|
||||
@@ -183,6 +316,20 @@ import BackToTop from "./BackToTop.astro"
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// ── Tab switching helper ──────────────────────────────────────────────────
|
||||
function switchToRequestedTab() {
|
||||
const tab = sessionStorage.getItem('openModalTab');
|
||||
if (!tab) return;
|
||||
sessionStorage.removeItem('openModalTab');
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
const tabEl = document.querySelector(`#cardModal [data-bs-target="#${tab}"]`);
|
||||
if (tabEl) bootstrap.Tab.getOrCreateInstance(tabEl).show();
|
||||
} catch (e) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
const cardIndex = [];
|
||||
let currentCardId = null;
|
||||
@@ -270,10 +417,17 @@ import BackToTop from "./BackToTop.astro"
|
||||
|
||||
if (modal._reconnectChartObserver) modal._reconnectChartObserver();
|
||||
|
||||
modal.querySelectorAll('[data-bs-toggle="tab"]').forEach(el => {
|
||||
bootstrap.Tab.getInstance(el)?.dispose();
|
||||
});
|
||||
|
||||
modal.innerHTML = html;
|
||||
|
||||
if (typeof htmx !== 'undefined') htmx.process(modal);
|
||||
initInventoryForms(modal);
|
||||
updateNavButtons(modal);
|
||||
initChartAfterSwap(modal);
|
||||
switchToRequestedTab();
|
||||
};
|
||||
|
||||
if (document.startViewTransition && direction) {
|
||||
@@ -358,8 +512,14 @@ import BackToTop from "./BackToTop.astro"
|
||||
|
||||
if (target._reconnectChartObserver) target._reconnectChartObserver();
|
||||
|
||||
target.querySelectorAll('[data-bs-toggle="tab"]').forEach(el => {
|
||||
bootstrap.Tab.getInstance(el)?.dispose();
|
||||
});
|
||||
|
||||
target.innerHTML = html;
|
||||
|
||||
if (typeof htmx !== 'undefined') htmx.process(target);
|
||||
initInventoryForms(target);
|
||||
|
||||
const destImg = target.querySelector('img.card-image');
|
||||
if (destImg) {
|
||||
@@ -376,6 +536,7 @@ import BackToTop from "./BackToTop.astro"
|
||||
await transition.finished;
|
||||
updateNavButtons(target);
|
||||
initChartAfterSwap(target);
|
||||
switchToRequestedTab();
|
||||
|
||||
} catch (err) {
|
||||
console.error('[card-modal] transition failed:', err);
|
||||
@@ -391,18 +552,122 @@ import BackToTop from "./BackToTop.astro"
|
||||
});
|
||||
|
||||
const cardModal = document.getElementById('cardModal');
|
||||
|
||||
// ── 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…'; }
|
||||
|
||||
// resolveFormPrice converts % → $ and strips priceMode before POSTing
|
||||
const body = resolveFormPrice(form);
|
||||
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');
|
||||
if (invList) {
|
||||
invList.innerHTML = html || '';
|
||||
syncEmptyState(invList);
|
||||
}
|
||||
form.reset();
|
||||
form.classList.remove('was-validated');
|
||||
// reset fires our listener which restores $ mode UI
|
||||
} 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');
|
||||
if (invList) {
|
||||
invList.innerHTML = html || '';
|
||||
syncEmptyState(invList);
|
||||
}
|
||||
} catch {
|
||||
// keep current state
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
cardModal.addEventListener('shown.bs.modal', () => {
|
||||
updateNavButtons(cardModal);
|
||||
initChartAfterSwap(cardModal);
|
||||
initInventoryForms(cardModal);
|
||||
switchToRequestedTab();
|
||||
});
|
||||
|
||||
cardModal.addEventListener('hidden.bs.modal', () => {
|
||||
currentCardId = null;
|
||||
updateNavButtons(null);
|
||||
});
|
||||
|
||||
// ── AdSense re-init on infinite scroll ───────────────────────────────────
|
||||
document.addEventListener('htmx:afterSwap', () => {
|
||||
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initInventoryForms();
|
||||
|
||||
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.
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
---
|
||||
|
||||
import logo from "/src/svg/logo/rat_light.svg?raw";
|
||||
---
|
||||
<footer class="bd-footer py-4 py-md-5 mt-0 bg-body-tertiary">
|
||||
<div class="container py-4 py-md-5 px-4 px-md-3 text-body-secondary">
|
||||
<div class="row justify-content-end">
|
||||
<div class="col mb-3">
|
||||
<a class="btn btn-outline-success rounded p-2 float-end" href="/contact">
|
||||
Contact Us
|
||||
<svg aria-hidden="true" class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path opacity=".25" d="M112 176L404 176C411.9 206.7 431 233 456.6 250.2L320 353.9L112 196.1L112 176zM112 256.3L305.5 403.1L320 414.1L334.5 403.1L509.2 270.6C515.3 271.5 521.6 272 528 272L528 464L112 464L112 256.3z"/>
|
||||
<path d="M528 64C572.2 64 608 99.8 608 144C608 188.2 572.2 224 528 224C483.8 224 448 188.2 448 144C448 99.8 483.8 64 528 64zM88 128L401 128C400.3 133.2 400 138.6 400 144C400 155 401.4 165.8 404 176L112 176L112 196.1L320 353.9L456.6 250.3C472.1 260.7 489.9 267.8 509.2 270.7L334.5 403.2L320 414.2L305.5 403.2L112 256.4L112 464.1L528 464.1L528 272.1C545 272.1 561.2 268.8 576 262.8L576 512.1L64 512.1L64 128.1L88 128.1z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<footer class="footer py-5 border-top border-subtle" role="contentinfo">
|
||||
<div class="container">
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4">
|
||||
<a href="/" class="d-inline-block mb-3" aria-label="RAT home">
|
||||
<span set:html={logo} class="logo-svg d-flex" style="--logo-width: 8rem;"></span>
|
||||
</a>
|
||||
<p class="text-body-secondary small">Real. Accurate. Transparent. Pokémon card price tracker for collectors who want to buy, sell, and trade with confidence.</p>
|
||||
</div>
|
||||
<nav class="col-md-2 ms-md-auto" aria-label="Tools">
|
||||
<h3 class="h6 fw-semibold text-body-emphasis mb-3">Tools</h3>
|
||||
<ul class="list-unstyled small text-body-secondary">
|
||||
<li class="mb-2"><a href="/pokemon" class="text-body-secondary text-decoration-none hover-white">Browse Cards</a></li>
|
||||
<li class="mb-2"><span class="text-body-tertiary">Inventory/Collection Tracker <em>(soon)</em></span></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<nav class="col-md-2" aria-label="Company">
|
||||
<h3 class="h6 fw-semibold text-body-emphasis mb-3">Company</h3>
|
||||
<ul class="list-unstyled small text-body-secondary">
|
||||
<li class="mb-2"><a href="https://www.route301cards.com/" class="text-body-secondary text-decoration-none hover-white">About</a></li>
|
||||
<li class="mb-2"><a href="/privacy" class="text-body-secondary text-decoration-none hover-white">Terms and Privacy</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center pt-4 border-top border-subtle">
|
||||
<p class="text-body-tertiary small mb-0">© {new Date().getFullYear()} RAT. Not affiliated with Nintendo, The Pokémon Company, or their affiliates.</p>
|
||||
<p class="text-body-tertiary small mb-0">Pokémon and all related names are trademarks of Nintendo / Creatures Inc. / GAME FREAK inc.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</footer>
|
||||
90
src/components/Hero.astro
Normal file
90
src/components/Hero.astro
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
import { SignInButton, Show } from '@clerk/astro/components'
|
||||
import type { sign } from 'node:crypto'
|
||||
---
|
||||
<!-- ═══════════════════════════════════════════
|
||||
HERO
|
||||
═══════════════════════════════════════════ -->
|
||||
<div class="hero position-relative overflow-hidden">
|
||||
<div class="hero-bg" aria-hidden="true"></div>
|
||||
<div class="container py-5 py-md-6 position-relative">
|
||||
<div class="row align-items-center g-5">
|
||||
<div class="col-12 col-xl-6">
|
||||
<p class="eyebrow text-purple-light mb-3">Pokémon Card Price Aggregator</p>
|
||||
<h1 class="display-4 fw-bold lh-sm mb-4">
|
||||
The home of</br>
|
||||
<span class="text-gradient">Real. Accurate. Transparent.</span><br/>
|
||||
pricing data.
|
||||
</h1>
|
||||
<p class="lead text-body-secondary mb-4 pe-lg-4">
|
||||
Real-time prices across the Pokémon trading card game. See prices for all conditions at a glance — no spreadsheets, no guesswork.
|
||||
</p>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<Show when="signed-out">
|
||||
<SignInButton asChild mode="modal">
|
||||
<button class="btn btn-purple btn-lg px-4">
|
||||
Get Started Free
|
||||
</button>
|
||||
</SignInButton>
|
||||
</Show>
|
||||
<Show when="signed-in">
|
||||
<SignInButton asChild mode="modal">
|
||||
<a href="/pokemon" class="btn btn-outline-light btn-lg px-4">Browse Cards</a>
|
||||
</SignInButton>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="mt-3 text-body-tertiary small d-none">Free forever. No credit card required.</p>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-6 d-none d-xl-block" aria-hidden="true">
|
||||
<div class="hero-cards-mockup">
|
||||
<div class="mockup-card mockup-card--1 shadow-lg rounded-4">
|
||||
<img class="img-fluid" src="/static/cards/124125.jpg" alt="Sample Pokémon Card" />
|
||||
</div>
|
||||
<div class="mockup-card mockup-card--2 shadow-lg rounded-4">
|
||||
<img class="img-fluid" src="/static/cards/88875.jpg" alt="Sample Pokémon Card" />
|
||||
</div>
|
||||
<div class="mockup-card mockup-card--3 shadow-lg rounded-4">
|
||||
<img class="img-fluid" src="/static/cards/567429.jpg" alt="Sample Pokémon Card" />
|
||||
</div>
|
||||
<div class="price-chip price-chip--nm">NM <strong>$114.99</strong></div>
|
||||
<div class="price-chip price-chip--lp">LP <strong>$85.66</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
// Your product image IDs
|
||||
const productImages = [
|
||||
"124125.jpg",
|
||||
"88875.jpg",
|
||||
"567429.jpg",
|
||||
"88788.jpg",
|
||||
"88789.jpg",
|
||||
"88996.jpg",
|
||||
"88997.jpg",
|
||||
"189659.jpg",
|
||||
"86745.jpg",
|
||||
"517025.jpg",
|
||||
"86911.jpg",
|
||||
"87456.jpg",
|
||||
"246733.jpg",
|
||||
"567418.jpg",
|
||||
"613917.jpg",
|
||||
];
|
||||
|
||||
function getRandomImages(arr, count) {
|
||||
const shuffled = [...arr].sort(() => 0.5 - Math.random());
|
||||
return shuffled.slice(0, count);
|
||||
}
|
||||
|
||||
const images = document.querySelectorAll(".mockup-card img");
|
||||
|
||||
const selectedImages = getRandomImages(productImages, images.length);
|
||||
|
||||
images.forEach((img, index) => {
|
||||
img.src = `/static/cards/${selectedImages[index]}`;
|
||||
});
|
||||
</script>
|
||||
37
src/components/InventoryTable.astro
Normal file
37
src/components/InventoryTable.astro
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
const mockInventory = [
|
||||
{ name: "Charizard", set: "Base Set", condition: "NM", qty: 2, price: 350, market: 400, gain: 50 },
|
||||
{ name: "Pikachu", set: "Shining Legends", condition: "LP", qty: 5, price: 15, market: 20, gain: 5 },
|
||||
];
|
||||
---
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Card</th>
|
||||
<th>Set</th>
|
||||
<th>Condition</th>
|
||||
<th>Qty</th>
|
||||
<th>Price</th>
|
||||
<th>Market</th>
|
||||
<th>Gain/Loss</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockInventory.map(card => (
|
||||
<tr>
|
||||
<td>{card.name}</td>
|
||||
<td>{card.set}</td>
|
||||
<td>{card.condition}</td>
|
||||
<td>{card.qty}</td>
|
||||
<td>${card.price}</td>
|
||||
<td>${card.market}</td>
|
||||
<td class={card.gain >= 0 ? "text-success" : "text-danger"}>
|
||||
{card.gain >= 0 ? "+" : "-"}${Math.abs(card.gain)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1,12 +1,36 @@
|
||||
---
|
||||
|
||||
import { UserButton, SignInButton, Show } from '@clerk/astro/components'
|
||||
import logo from "/src/svg/logo/rat_light.svg?raw";
|
||||
---
|
||||
<nav class="navbar navbar-expand sticky-top bg-dark" data-bs-theme="dark" aria-label="Main navigation">
|
||||
<div class="container">
|
||||
<a class="navbar-brand d-flex" href="/">
|
||||
<span class="h3 d-none d-md-flex">Rigid's App Thing</span><span aria-hidden="true" class="h3 d-md-none d-flex m-auto">RAT</span>
|
||||
<nav class="navbar sticky-top bg-dark shadow" data-bs-theme="dark" aria-label="Main navigation">
|
||||
<div class="container align-items-center" id="navContainer">
|
||||
<a class="navbar-brand" href="/">
|
||||
<span set:html={logo} class="logo-svg d-flex"></span>
|
||||
</a>
|
||||
<slot name="navItems"/>
|
||||
<slot name="searchInput"/>
|
||||
<div class="d-flex d-md-none nav-user-btn" id="navUserBtn">
|
||||
<Show when="signed-in">
|
||||
<UserButton afterSignOutUrl="/" showName={false} />
|
||||
</Show>
|
||||
<Show when="signed-out">
|
||||
<SignInButton asChild mode="modal">
|
||||
<button class="btn btn-light">Sign In</button>
|
||||
</SignInButton>
|
||||
</Show>
|
||||
<slot name="navItems"/>
|
||||
</div>
|
||||
<div class="d-flex flex-column-reverse flex-md-row search-container" id="searchContainer">
|
||||
<slot name="searchInput"/>
|
||||
<div class="d-none d-md-flex ms-4 nav-user-btn">
|
||||
<Show when="signed-in">
|
||||
<UserButton afterSignOutUrl="/" showName={false} />
|
||||
</Show>
|
||||
<Show when="signed-out">
|
||||
<SignInButton asChild mode="modal">
|
||||
<button class="btn btn-light">Sign In</button>
|
||||
</SignInButton>
|
||||
</Show>
|
||||
<slot name="navItems"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -1,16 +1,46 @@
|
||||
---
|
||||
---
|
||||
<button
|
||||
class="navbar-toggler ms-4 p-1 btn btn-purple border-0"
|
||||
type="button"
|
||||
data-bs-toggle="offcanvas"
|
||||
data-bs-target="#navOffcanvas"
|
||||
aria-controls="navOffcanvas"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
---
|
||||
<div class="navbar-collapse" id="navbarNav" aria-labelledby="navbarToggler">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item d-flex">
|
||||
<a class="nav-link btn btn-warning rounded p-2" href="/pokemon" aria-label="Cards">
|
||||
<span class="d-inline-block d-md-none" aria-hidden="true">Cards</span>
|
||||
<svg aria-hidden="true" class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path opacity=".4" d="M256 519.9L256 576L576 576L576 128L378.8 128C408.7 239.7 438.6 351.3 468.5 463C397.7 482 326.8 501 256 519.9z"/>
|
||||
<path d="M43.5 113L352.6 30.2L468.6 462.9L159.5 545.7z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
id="navOffcanvasWrapper"
|
||||
data-bs-theme="dark"
|
||||
>
|
||||
<div
|
||||
class="offcanvas offcanvas-end"
|
||||
tabindex="-1"
|
||||
id="navOffcanvas"
|
||||
aria-labelledby="navOffcanvasLabel"
|
||||
>
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title" id="navOffcanvasLabel">Menu</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body px-3 pt-0">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-3 border-bottom border-secondary" href="/pokemon">Browse Cards</a>
|
||||
</li>
|
||||
<!--<li class="nav-item">
|
||||
<a class="nav-link py-3" href="/dashboard">Dashboard</a>
|
||||
</li> -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const wrapper = document.getElementById('navOffcanvasWrapper');
|
||||
if (wrapper) document.body.appendChild(wrapper);
|
||||
});
|
||||
</script>
|
||||
@@ -8,7 +8,6 @@ import { Show } from '@clerk/astro/components'
|
||||
const val = Number(start.value) || 0;
|
||||
start.value = (val + 20).toString();
|
||||
}
|
||||
// delete the triggering element
|
||||
if (e && e.detail && e.detail.elt) {
|
||||
e.detail.elt.remove();
|
||||
}
|
||||
@@ -26,21 +25,47 @@ import { Show } from '@clerk/astro/components'
|
||||
</script>
|
||||
|
||||
<Show when="signed-in">
|
||||
<form class="d-flex ms-2 align-items-center gap-2" role="search" id="searchform" hx-post="/partials/cards" hx-target="#cardGrid" hx-trigger="load, submit" hx-vals='{"start":"0"}' hx-on--after-request="afterUpdate()" hx-on--before-request="beforeSearch()">
|
||||
<a class="btn btn-secondary btn-lg" data-bs-toggle="offcanvas" href="#filterBar" role="button" aria-controls="filterBar" aria-label="filter">
|
||||
<span class="d-block d-md-none filter-icon py-2">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg>
|
||||
</span>
|
||||
<span class="d-none d-md-block">Filters</span>
|
||||
</a>
|
||||
|
||||
<form
|
||||
class="d-flex align-items-center"
|
||||
role="search"
|
||||
id="searchform"
|
||||
hx-post="/partials/cards"
|
||||
hx-target="#cardGrid"
|
||||
hx-trigger="load, submit"
|
||||
hx-vals='{"start":"0"}'
|
||||
hx-on--after-request="afterUpdate()"
|
||||
hx-on--before-request="beforeSearch()"
|
||||
>
|
||||
<div class="input-group">
|
||||
{Astro.url.pathname === '/pokemon' && (
|
||||
<a class="btn btn-purple" data-bs-toggle="offcanvas" href="#filterBar" type="button" role="button" aria-controls="filterBar" aria-label="filter">
|
||||
<span class="d-block d-md-none filter-icon py-2">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M528.8 96.3C558.6 90.8 571.2 118.9 568.9 142.2C572.3 173.4 570.8 207 553.9 230.8C513.9 283.2 459.3 315.9 414.3 364.3C414.9 418.3 419.8 459.8 423.6 511.2C427.6 552.4 388.7 586.8 346.6 570.1C303.2 550.5 259.4 527.5 230.4 493.3C217 453.1 225.9 407.5 222.2 365.3C222.2 365.3 222.1 365.1 222 365C151.4 319.6 59.3 250.9 61 158.4C59.9 121 91.8 96.1 123.8 96.5C259.3 98.5 394.1 104.4 528.8 96.3zM506.1 161.4C378.3 168.2 252 162.1 125.2 160.5C128.6 227 199 270.8 250 306.8C305.5 335.4 281.6 410.5 288.3 461.7C310.8 478.9 334.6 494.6 358.9 505.8C355.4 458 350.7 415.4 350.2 364.6C349.9 349.2 355.3 333.7 366.5 321.7C384.3 302.6 402.8 287.8 421.5 270.1C446.1 245.2 477.9 225.1 499.7 196.7C509 182.2 504.7 174.5 506 161.5z"/></svg>
|
||||
</span>
|
||||
<span class="d-none d-md-block fw-medium">Filters</span>
|
||||
</a>
|
||||
)}
|
||||
<input type="hidden" name="start" id="start" value="0" />
|
||||
<input type="hidden" name="sort" id="sortInput" value="" />
|
||||
<input type="hidden" name="language" id="languageInput" value="all" />
|
||||
<input type="search" name="q" class="form-control form-control-lg" placeholder="Search cards..." />
|
||||
<button type="submit" class="btn btn-danger btn-lg border border-danger-subtle border-start-0" aria-label="search" value="" onclick="const q = this.closest('form').querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });">
|
||||
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M432 272C432 183.6 360.4 112 272 112C183.6 112 112 183.6 112 272C112 360.4 183.6 432 272 432C360.4 432 432 360.4 432 272zM401.1 435.1C365.7 463.2 320.8 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272C480 320.8 463.2 365.7 435.1 401.1L569 535C578.4 544.4 578.4 558.1 569 567.5C559.6 575.9 544.4 575.9 536 567.5L401.1 435.1z"/></svg>
|
||||
<input type="search" name="q" id="searchInput" class="form-control search-input" placeholder="Search cards" />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-purple border-start-0"
|
||||
aria-label="search"
|
||||
onclick="
|
||||
const q = this.closest('form').querySelector('[name=q]').value;
|
||||
dataLayer.push({ event: 'view_search_results', search_term: q });
|
||||
if (window.location.pathname !== '/pokemon') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
sessionStorage.setItem('pendingSearch', q);
|
||||
window.location.href = '/pokemon';
|
||||
}
|
||||
"
|
||||
>
|
||||
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</Show>
|
||||
Reference in New Issue
Block a user