4 Commits

6 changed files with 407 additions and 224 deletions

View File

@@ -172,6 +172,16 @@ html {
} }
} }
.col:has(.image-grow:hover) .inventory-button {
opacity: 0.20;
transition: opacity 350ms ease;
}
.inventory-button {
// add transition to existing rule
transition: opacity 350ms ease;
}
.card-modal { .card-modal {
background-color: rgba(1, 11, 18, 0.8); background-color: rgba(1, 11, 18, 0.8);
cursor: default; cursor: default;
@@ -482,7 +492,7 @@ $cond-text: (
.inventory-button { .inventory-button {
margin-bottom: -2.25rem; margin-bottom: -2.25rem;
margin-right: -0.5rem; margin-right: -0.25rem;
z-index: 2; z-index: 2;
} }

View File

@@ -47,6 +47,82 @@ import BackToTop from "./BackToTop.astro"
<script is:inline> <script is:inline>
(function () { (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) { function initInventoryForms(root = document) {
// Fetch inventory entries for this card // Fetch inventory entries for this card
const invList = root.querySelector('#inventoryEntryList') || document.getElementById('inventoryEntryList'); const invList = root.querySelector('#inventoryEntryList') || document.getElementById('inventoryEntryList');
@@ -58,7 +134,10 @@ import BackToTop from "./BackToTop.astro"
body.append('cardId', cardId); body.append('cardId', cardId);
fetch('/api/inventory', { method: 'POST', body }) fetch('/api/inventory', { method: 'POST', body })
.then(r => r.text()) .then(r => r.text())
.then(html => { invList.innerHTML = html || ''; }) .then(html => {
invList.innerHTML = html || '';
syncEmptyState(invList);
})
.catch(() => { invList.innerHTML = '<span class="text-danger">Failed to load inventory</span>'; }); .catch(() => { invList.innerHTML = '<span class="text-danger">Failed to load inventory</span>'; });
} }
} }
@@ -70,38 +149,37 @@ import BackToTop from "./BackToTop.astro"
form.dataset.inventoryBound = 'true'; form.dataset.inventoryBound = 'true';
const priceInput = form.querySelector('#purchasePrice'); const priceInput = form.querySelector('#purchasePrice');
const pricePrefix = form.querySelector('#pricePrefix');
const priceSuffix = form.querySelector('#priceSuffix');
const priceHint = form.querySelector('#priceHint');
const modeInputs = form.querySelectorAll('input[name="priceMode"]'); const modeInputs = form.querySelectorAll('input[name="priceMode"]');
const condInputs = form.querySelectorAll('input[name="condition"]');
if (!priceInput || !pricePrefix || !priceSuffix || !priceHint || !modeInputs.length) return; // Set initial UI state
const checkedMode = form.querySelector('input[name="priceMode"]:checked')?.value ?? 'dollar';
function updatePriceMode(mode) { applyPriceModeUI(form, checkedMode);
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 ? '100' : '0.00';
priceInput.value = '';
priceHint.textContent = isPct
? 'Enter the percentage of market price you paid.'
: 'Enter the purchase price.';
// swap rounded edge classes based on visible prepend/append
priceInput.classList.toggle('rounded-end', !isPct);
priceInput.classList.toggle('rounded-start', isPct);
}
// Mode toggle
modeInputs.forEach((input) => { modeInputs.forEach((input) => {
input.addEventListener('change', () => updatePriceMode(input.value)); input.addEventListener('change', () => {
if (priceInput) priceInput.value = ''; // clear stale value on mode switch
applyPriceModeUI(form, input.value);
updatePriceHint(form);
});
}); });
const checked = form.querySelector('input[name="priceMode"]:checked'); // Condition change updates the hint when in % mode
updatePriceMode(checked ? checked.value : 'dollar'); 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);
});
}); });
} }
@@ -476,7 +554,8 @@ import BackToTop from "./BackToTop.astro"
const submitBtn = form.querySelector('button[type="submit"]'); const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Saving…'; } if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Saving…'; }
const body = new FormData(form); // resolveFormPrice converts % → $ and strips priceMode before POSTing
const body = resolveFormPrice(form);
body.append('action', 'add'); body.append('action', 'add');
body.append('cardId', cardId); body.append('cardId', cardId);
@@ -484,9 +563,13 @@ import BackToTop from "./BackToTop.astro"
const res = await fetch('/api/inventory', { method: 'POST', body }); const res = await fetch('/api/inventory', { method: 'POST', body });
const html = await res.text(); const html = await res.text();
const invList = document.getElementById('inventoryEntryList'); const invList = document.getElementById('inventoryEntryList');
if (invList) invList.innerHTML = html || ''; if (invList) {
invList.innerHTML = html || '';
syncEmptyState(invList);
}
form.reset(); form.reset();
form.classList.remove('was-validated'); form.classList.remove('was-validated');
// reset fires our listener which restores $ mode UI
} catch { } catch {
// keep current inventory list state // keep current inventory list state
} finally { } finally {
@@ -538,7 +621,10 @@ import BackToTop from "./BackToTop.astro"
const res = await fetch('/api/inventory', { method: 'POST', body }); const res = await fetch('/api/inventory', { method: 'POST', body });
const html = await res.text(); const html = await res.text();
const invList = document.getElementById('inventoryEntryList'); const invList = document.getElementById('inventoryEntryList');
if (invList) invList.innerHTML = html || ''; if (invList) {
invList.innerHTML = html || '';
syncEmptyState(invList);
}
} catch { } catch {
// keep current state // keep current state
} finally { } finally {

View File

@@ -131,9 +131,11 @@ export const inventory = pokeSchema.table('inventory',{
catalogName: varchar({ length: 100 }), catalogName: varchar({ length: 100 }),
cardId: integer().notNull(), cardId: integer().notNull(),
condition: varchar({ length: 255 }).notNull(), condition: varchar({ length: 255 }).notNull(),
variant: varchar({ length: 100 }).default('Normal'),
quantity: integer(), quantity: integer(),
purchasePrice: integer(), purchasePrice: decimal({ precision: 10, scale: 2 }),
note: varchar({ length:255 }) note: varchar({ length:255 }),
createdAt: timestamp().notNull().defaultNow(),
}, },
(table) => [ (table) => [
index('idx_userid_cardid').on(table.userId, table.cardId) index('idx_userid_cardid').on(table.userId, table.cardId)

View File

@@ -1,58 +1,89 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { db } from '../../db/index'; import { db } from '../../db/index';
import { inventory } from '../../db/schema'; import { inventory, skus, cards } from '../../db/schema';
import { client } from '../../db/typesense'; import { client } from '../../db/typesense';
import { eq } from 'drizzle-orm'; import { eq, and, sql } from 'drizzle-orm';
const GainLoss = (purchasePrice: any, marketPrice: any) => { const GainLoss = (purchasePrice: any, marketPrice: any) => {
if (!purchasePrice || !marketPrice) return '<div class="fs-5 fw-semibold">N/A</div>'; if (!purchasePrice || !marketPrice) return '<div class="fs-5 fw-semibold">N/A</div>';
const pp = Number(purchasePrice); const pp = Number(purchasePrice);
const mp = Number(marketPrice); const mp = Number(marketPrice);
if (pp === mp) return '<div class="fs-5 fw-semibold text-warning">-</div>'; if (pp === mp) return '<div class="fs-5 fw-semibold text-warning">-</div>';
if (pp > mp) return `<div class="fs-5 fw-semibold text-critical">-$${pp-mp}</div>`; if (pp > mp) return `<div class="fs-5 fw-semibold text-critical">-$${(pp - mp).toFixed(2)}</div>`;
return `<div class="fs-5 fw-semibold text-success">+$${mp-pp}</div>`; return `<div class="fs-6 fw-semibold text-success">+$${(mp - pp).toFixed(2)}</div>`;
} }
const getInventory = async (userId: string, cardId: number) => { const getInventory = async (userId: string, cardId: number) => {
const inventories = await db.query.inventory.findMany({ const inventories = await db
where: { userId:userId, cardId:cardId, }, .select({
with: { card: true, sku: true, } inventoryId: inventory.inventoryId,
}); cardId: inventory.cardId,
condition: inventory.condition,
variant: inventory.variant,
quantity: inventory.quantity,
purchasePrice: inventory.purchasePrice,
note: inventory.note,
marketPrice: skus.marketPrice,
createdAt: inventory.createdAt,
})
.from(inventory)
.leftJoin(
cards,
eq(inventory.cardId, cards.cardId)
)
.leftJoin(
skus,
and(
eq(cards.productId, skus.productId),
eq(inventory.condition, skus.condition),
eq(
sql`COALESCE(${inventory.variant}, 'Normal')`,
skus.variant
)
)
)
.where(and(
eq(inventory.userId, userId),
eq(inventory.cardId, cardId)
));
const invHtml = inventories.map(inv => { const invHtml = inventories.map(inv => {
const marketPrice = inv.marketPrice ? Number(inv.marketPrice).toFixed(2) : null;
const marketPriceDisplay = marketPrice ? `$${marketPrice}` : '—';
const purchasePriceDisplay = inv.purchasePrice ? `$${Number(inv.purchasePrice).toFixed(2)}` : '—';
return ` return `
<article class="border rounded-4 p-2 bg-body-tertiary inventory-entry-card" <article class="border rounded-4 p-2 inventory-entry-card"
data-inventory-id="${inv.inventoryId}" data-inventory-id="${inv.inventoryId}"
data-card-id="${inv.cardId}" data-card-id="${inv.cardId}"
data-purchase-price="${inv.purchasePrice}" data-purchase-price="${inv.purchasePrice}"
data-note="${(inv.note || '').replace(/"/g, '&quot;')}"> data-note="${(inv.note || '').replace(/"/g, '&quot;')}">
<div class="d-flex flex-column gap-2"> <div class="d-flex flex-column">
<!-- Top row --> <!-- Top row -->
<div class="d-flex justify-content-between align-items-start gap-3"> <div class="d-flex justify-content-between gap-3">
<div class="min-w-0 flex-grow-1"> <div class="min-w-0 flex-grow-1">
<div class="fw-semibold fs-5 text-body mb-1">${inv.condition}</div> <div class="fw-semibold fs-6 text-body mb-1">${inv.condition}</div>
</div> </div>
<div class="fs-7 text-secondary">Added: ${inv.createdAt ? new Date(inv.createdAt).toLocaleDateString() : '—'}</div>
</div> </div>
<!-- Middle row --> <!-- Middle row -->
<div class="row g-2"> <div class="row g-2">
<div class="col-4"> <div class="col-4">
<div class="small text-secondary">Purchase price</div> <div class="small text-secondary">Purchase price</div>
<div class="fs-5 fw-semibold">$${inv.purchasePrice}</div> <div class="fs-6 fw-semibold">${purchasePriceDisplay}</div>
</div> </div>
<div class="col-4"> <div class="col-4">
<div class="small text-secondary">Market price</div> <div class="small text-secondary">Market price</div>
<div class="fs-5 text-success">$${inv.sku?.marketPrice}</div> <div class="fs-6 text-success">${marketPriceDisplay}</div>
</div> </div>
<div class="col-4"> <div class="col-4">
<div class="small text-secondary">Gain / loss</div> <div class="small text-secondary">Gain / loss</div>
${GainLoss(inv.purchasePrice, inv.sku?.marketPrice)} ${GainLoss(inv.purchasePrice, marketPrice)}
</div> </div>
</div> </div>
<!-- Bottom row --> <!-- Bottom row -->
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap"> <div class="d-flex justify-content-between align-items-center gap-3 flex-wrap mt-2">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<span class="small text-secondary">Qty</span> <span class="small text-secondary">Qty</span>
<div class="btn-group" role="group" aria-label="Quantity controls"> <div class="btn-group" role="group" aria-label="Quantity controls">
@@ -63,7 +94,7 @@ const getInventory = async (userId:string, cardId:number) => {
</div> </div>
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2 flex-wrap">
<button type="button" class="btn btn-sm btn-outline-secondary" data-inv-action="update">Edit</button> <button type="button" class="btn btn-sm btn-outline-secondary" data-inv-action="update">Edit</button>
<button type="button" class="btn btn-sm btn-outline-danger" data-inv-action="remove">Remove</button> <button type="button" class="btn btn-sm btn-outline-danger" data-inv-action="remove" onclick="if(!confirm('Are you sure you want to remove this card from your inventory?')) event.stopImmediatePropagation();">Remove</button>
</div> </div>
</div> </div>
</div> </div>
@@ -77,22 +108,20 @@ const getInventory = async (userId:string, cardId:number) => {
headers: { 'Content-Type': 'text/html' }, headers: { 'Content-Type': 'text/html' },
} }
); );
} }
const addToInventory = async (userId:string, cardId:number, condition:string, purchasePrice:number, quantity:number, note:string, catalogName:string) => { const addToInventory = async (userId: string, cardId: number, condition: string, variant: string, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
// First add to database
const inv = await db.insert(inventory).values({ const inv = await db.insert(inventory).values({
userId: userId, userId: userId,
cardId: cardId, cardId: cardId,
catalogName: catalogName, catalogName: catalogName,
condition: condition, condition: condition,
variant: variant,
purchasePrice: purchasePrice, purchasePrice: purchasePrice,
quantity: quantity, quantity: quantity,
note: note, note: note,
}).returning(); }).returning();
// And then add to Typesense
await client.collections('inventories').documents().import(inv.map(i => ({ await client.collections('inventories').documents().import(inv.map(i => ({
id: i.inventoryId, id: i.inventoryId,
userId: i.userId, userId: i.userId,
@@ -107,17 +136,14 @@ const removeFromInventory = async (inventoryId:string) => {
} }
const updateInventory = async (inventoryId: string, quantity: number, purchasePrice: number, note: string) => { const updateInventory = async (inventoryId: string, quantity: number, purchasePrice: number, note: string) => {
// Update in database
await db.update(inventory).set({ await db.update(inventory).set({
quantity: quantity, quantity: quantity,
purchasePrice: purchasePrice, purchasePrice: purchasePrice,
note: note, note: note,
}).where(eq(inventory.inventoryId, inventoryId)); }).where(eq(inventory.inventoryId, inventoryId));
// There is no need to update Typesense for these fields as they are not indexed
} }
export const POST: APIRoute = async ({ request, locals }) => { export const POST: APIRoute = async ({ request, locals }) => {
// Access form data from the request body
const formData = await request.formData(); const formData = await request.formData();
const action = formData.get('action'); const action = formData.get('action');
const cardId = Number(formData.get('cardId')) || 0; const cardId = Number(formData.get('cardId')) || 0;
@@ -127,12 +153,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
case 'add': case 'add':
const condition = formData.get('condition')?.toString() || 'Unknown'; const condition = formData.get('condition')?.toString() || 'Unknown';
const variant = formData.get('variant')?.toString() || 'Normal';
const purchasePrice = Number(formData.get('purchasePrice')) || 0; const purchasePrice = Number(formData.get('purchasePrice')) || 0;
const quantity = Number(formData.get('quantity')) || 1; const quantity = Number(formData.get('quantity')) || 1;
const note = formData.get('note')?.toString() || ''; const note = formData.get('note')?.toString() || '';
const catalogName = formData.get('catalogName')?.toString() || 'Default'; const catalogName = formData.get('catalogName')?.toString() || 'Default';
await addToInventory(userId!, cardId, condition, purchasePrice, quantity, note, catalogName); await addToInventory(userId!, cardId, condition, variant, purchasePrice, quantity, note, catalogName);
//return await getInventory(cardId);
break; break;
case 'remove': case 'remove':
@@ -149,12 +175,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
break; break;
default: default:
// No action = list inventory for this card
return getInventory(userId!, cardId); return getInventory(userId!, cardId);
} }
// Always return current inventory after a mutation
return getInventory(userId!, cardId); return getInventory(userId!, cardId);
}; };

View File

@@ -10,6 +10,18 @@ import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
import { Tooltip } from "bootstrap"; import { Tooltip } from "bootstrap";
import { clerkClient } from '@clerk/astro/server';
const { userId, has } = Astro.locals.auth();
const TARGET_ORG_ID = 'org_3Baav9czkRLLlC7g89oJWqRRulK';
let hasAccess = has({ feature: 'inventory_add' });
if (!hasAccess && userId) {
const memberships = await clerkClient(Astro).users.getOrganizationMembershipList({ userId });
hasAccess = memberships.data.some(m => m.organization.id === TARGET_ORG_ID);
}
export const partial = true; export const partial = true;
export const prerender = false; export const prerender = false;
@@ -161,6 +173,17 @@ const conditionAttributes = (price: any) => {
}[condition]; }[condition];
}; };
// ── Build a market price lookup keyed by condition for use in JS ──────────
const marketPriceByCondition: Record<string, number> = {};
for (const price of card?.prices ?? []) {
if (price.condition && price.marketPrice != null) {
marketPriceByCondition[price.condition] = Number(price.marketPrice);
}
}
// ── Derive distinct variants available for this card ─────────────────────
const availableVariants = [...new Set(cardSkus.map(s => s.variant))].sort();
const ebaySearchUrl = (card: any) => { 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`; 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`;
}; };
@@ -168,6 +191,7 @@ const ebaySearchUrl = (card: any) => {
const altSearchUrl = (card: any) => { const altSearchUrl = (card: any) => {
return `https://alt.xyz/browse?query=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&sortBy=newest_first`; return `https://alt.xyz/browse?query=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&sortBy=newest_first`;
}; };
--- ---
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl"> <div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
<div class="modal-content" data-card-id={card?.cardId}> <div class="modal-content" data-card-id={card?.cardId}>
@@ -248,11 +272,13 @@ const altSearchUrl = (card: any) => {
<span class="d-none">Damaged</span><span class="d-inline">DMG</span> <span class="d-none">Damaged</span><span class="d-inline">DMG</span>
</button> </button>
</li> </li>
{hasAccess && (
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<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"> <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> <span class="d-none d-xxl-inline">Inventory</span><span class="d-xxl-none">+/-</span>
</button> </button>
</li> </li>
)}
</ul> </ul>
<div class="tab-content" id="myTabContent"> <div class="tab-content" id="myTabContent">
@@ -309,7 +335,7 @@ const altSearchUrl = (card: any) => {
<!-- Table only — chart is outside the tab panes --> <!-- Table only — chart is outside the tab panes -->
<div class="w-100"> <div class="w-100">
<div class="alert alert-dark rounded p-2 mb-0 table-responsive"> <div class="alert alert-dark rounded p-2 mb-0 table-responsive d-none">
<h6>Latest Verified Sales</h6> <h6>Latest Verified Sales</h6>
<table class="table table-sm mb-0"> <table class="table table-sm mb-0">
<caption class="small">Filtered to remove mismatched language variants</caption> <caption class="small">Filtered to remove mismatched language variants</caption>
@@ -335,16 +361,16 @@ const altSearchUrl = (card: any) => {
</div> </div>
); );
})} })}
{hasAccess && (
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0"> <div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0">
<div class="row g-3"> <div class="row g-4">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<h5 class="my-3">Add {card?.productName} to inventory</h5> <h6 class="mt-1 mb-2">Add {card?.productName} to inventory</h6>
<form id="inventoryForm" data-inventory-form novalidate> <form id="inventoryForm" data-inventory-form novalidate>
<div class="row g-3"> <div class="row gx-3 gy-1">
<div class="col-4"> <div class="col-3">
<label for="quantity" class="form-label fw-medium">Quantity</label> <label for="quantity" class="form-label">Quantity</label>
<input <input
type="number" type="number"
class="form-control mt-1" class="form-control mt-1"
@@ -358,9 +384,9 @@ const altSearchUrl = (card: any) => {
<div class="invalid-feedback">Required.</div> <div class="invalid-feedback">Required.</div>
</div> </div>
<div class="col-8"> <div class="col-9">
<div class="d-flex justify-content-between align-items-start gap-2 flex-wrap"> <div class="d-flex justify-content-between align-items-start gap-2 flex-wrap">
<label for="purchasePrice" class="form-label fw-medium mb-0 mt-1"> <label for="purchasePrice" class="form-label">
Purchase price Purchase price
</label> </label>
@@ -409,7 +435,7 @@ const altSearchUrl = (card: any) => {
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label fw-medium">Condition</label> <label class="form-label">Condition</label>
<div class="btn-group condition-input w-100" role="group" aria-label="Condition"> <div class="btn-group condition-input w-100" role="group" aria-label="Condition">
<input <input
type="radio" type="radio"
@@ -464,10 +490,32 @@ const altSearchUrl = (card: any) => {
</div> </div>
</div> </div>
<input type="hidden" name="variant" value={card?.variant} />
<div class="col-12"> <div class="col-12">
<label for="note" class="form-label fw-medium"> <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"
/>
<datalist id="catalogSuggestions">
</datalist>
<div class="form-text">
Type a name or pick an existing catalog.
</div>
</div>
<div class="col-12">
<label for="note" class="form-label">
Note Note
<span class="text-body-tertiary fw-normal ms-1 small">optional</span>
</label> </label>
<textarea <textarea
class="form-control" class="form-control"
@@ -489,7 +537,7 @@ const altSearchUrl = (card: any) => {
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<h5 class="my-3">Inventory entries for {card?.productName}</h5> <h6 class="mt-1 mb-2">Inventory entries for {card?.productName}</h6>
<!-- Empty state --> <!-- Empty state -->
<div class="alert alert-dark border-0 rounded-4 d-none" id="inventoryEmptyState"> <div class="alert alert-dark border-0 rounded-4 d-none" id="inventoryEmptyState">
@@ -500,16 +548,17 @@ const altSearchUrl = (card: any) => {
</div> </div>
<!-- Inventory list --> <!-- Inventory list -->
<div class="d-flex flex-column gap-3" id="inventoryEntryList" data-card-id={cardId}> <div class="d-flex flex-column gap-3" id="inventoryEntryList" data-card-id={cardId} data-market-prices={JSON.stringify(marketPriceByCondition)}>
<span>Loading...</span> <span>Loading...</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
)}
</div> </div>
<!-- Chart lives permanently outside tab-content so Bootstrap never hides it. --> <!-- Chart lives permanently outside tab-content so Bootstrap never hides it. -->
<div class="d-block d-lg-flex gap-1 mt-1"> <div class="d-block d-lg-flex gap-1 mt-1 price-chart-container">
<div class="col-12"> <div class="col-12">
<div class="alert alert-dark rounded p-2 mb-0"> <div class="alert alert-dark rounded p-2 mb-0">
<h6>Market Price History</h6> <h6>Market Price History</h6>

View File

@@ -1,5 +1,17 @@
--- ---
import { client } from '../../db/typesense'; import { client } from '../../db/typesense';
import { clerkClient } from '@clerk/astro/server';
const { userId, has } = Astro.locals.auth();
const TARGET_ORG_ID = 'org_3Baav9czkRLLlC7g89oJWqRRulK';
let hasAccess = has({ feature: 'inventory_add' });
if (!hasAccess && userId) {
const memberships = await clerkClient(Astro).users.getOrganizationMembershipList({ userId });
hasAccess = memberships.data.some(m => m.organization.id === TARGET_ORG_ID);
}
import RarityIcon from '../../components/RarityIcon.astro'; import RarityIcon from '../../components/RarityIcon.astro';
import FirstEditionIcon from "../../components/FirstEditionIcon.astro"; import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
export const prerender = false; export const prerender = false;
@@ -283,9 +295,11 @@ const facets = searchResults.results.slice(1).map((result: any) => {
{pokemon.map((card:any) => ( {pokemon.map((card:any) => (
<div class="col"> <div class="col">
{hasAccess && (
<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');"> <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> <b>+/</b>
</button> </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="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={`/static/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='/static/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="image-grow rounded-4 card-image" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}><img src={`/static/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='/static/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> <div class="holo-shine"></div>