3 Commits

Author SHA1 Message Date
Thad Miller
4992890503 [feat] inventory search working 2026-07-16 16:21:19 -04:00
Thad Miller
0858d3eaec [feat] infinite scroll on inventory 2026-07-16 16:08:51 -04:00
Thad Miller
b3799a5318 [feat] bulk import allows date, and quantity is split into multiple entries 2026-07-16 15:59:25 -04:00
3 changed files with 312 additions and 48 deletions

View File

@@ -1,4 +1,5 @@
import type { APIRoute } from 'astro';
import { parse } from 'csv';
import { db } from '../../db/index';
import { inventory, priceHistory } from '../../db/schema';
import { client } from '../../db/typesense';
@@ -89,16 +90,42 @@ const getInventory = async (userId: string, cardId: number) => {
}
// Build the Typesense document for an inventory row + its card details.
const inventoryTsDoc = (i: typeof inventory.$inferSelect, card: any) => ({
id: i.inventoryId,
userId: i.userId,
catalogName: i.catalogName,
sku_id: i.skuId.toString(),
purchasePrice: DollarToInt(i.purchasePrice),
productLineName: card?.productLineName,
rarityName: card?.rarityName,
setName: card?.set?.setName || "",
cardType: card?.cardType || "",
energyType: card?.energyType || "",
card_id: card?.cardId.toString() || "",
content: [
card?.productName,
card?.productLineName,
card?.set?.setName || "",
card?.number,
card?.rarityName,
card?.artist || ""
].join(' '),
});
const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
// First add to database
const inv = await db.insert(inventory).values({
userId: userId,
skuId: skuId,
catalogName: catalogName,
purchasePrice: purchasePrice.toFixed(2),
quantity: quantity,
note: note,
}).returning();
// Insert one row per copy: a quantity of 5 becomes 5 separate entries of qty 1.
const count = Math.max(1, Math.floor(quantity));
const inv = await db.insert(inventory).values(
Array.from({ length: count }, () => ({
userId: userId,
skuId: skuId,
catalogName: catalogName,
purchasePrice: purchasePrice.toFixed(2),
quantity: 1,
note: note,
}))
).returning();
// Get card details from the database to add to Typesense
const card = await db.query.cards.findFirst({
where: { cardId: cardId },
@@ -107,27 +134,7 @@ const addToInventory = async (userId: string, cardId: number, skuId: number, pur
try {
// And then add to Typesense for searching
await client.collections('inventories').documents().import(inv.map(i => ({
id: i.inventoryId,
userId: i.userId,
catalogName: i.catalogName,
sku_id: i.skuId.toString(),
purchasePrice: DollarToInt(i.purchasePrice),
productLineName: card?.productLineName,
rarityName: card?.rarityName,
setName: card?.set?.setName || "",
cardType: card?.cardType || "",
energyType: card?.energyType || "",
card_id: card?.cardId.toString() || "",
content: [
card?.productName,
card?.productLineName,
card?.set?.setName || "",
card?.number,
card?.rarityName,
card?.artist || ""
].join(' '),
})));
await client.collections('inventories').documents().import(inv.map(i => inventoryTsDoc(i, card)));
} catch (error) {
console.error('Error adding inventory to Typesense:', error);
}
@@ -154,6 +161,123 @@ const updateInventory = async (inventoryId: string, quantity: number, purchasePr
}
}
// ── Bulk CSV import ─────────────────────────────────────────────────────────
// Expected columns (header row, case-insensitive): name, set, condition, qty,
// price, market. An optional `date` column is used as the inventory createdAt;
// rows without a (valid) date default to the current time.
const CONDITION_MAP: Record<string, string> = {
'nm': 'Near Mint', 'near mint': 'Near Mint', 'mint': 'Near Mint',
'lp': 'Lightly Played', 'lightly played': 'Lightly Played',
'mp': 'Moderately Played', 'moderately played': 'Moderately Played',
'hp': 'Heavily Played', 'heavily played': 'Heavily Played',
'dmg': 'Damaged', 'dm': 'Damaged', 'damaged': 'Damaged',
};
const normalizeCondition = (value?: string) => {
const key = (value || '').trim().toLowerCase();
return CONDITION_MAP[key] || 'Near Mint';
};
// Parse an optional date cell into a Date, or null when empty/invalid.
const parseImportDate = (value?: string): Date | null => {
const raw = (value || '').trim();
if (!raw) return null;
const date = new Date(raw);
return isNaN(date.getTime()) ? null : date;
};
const parseCsv = (text: string): Promise<Record<string, string>[]> =>
new Promise((resolve, reject) => {
parse(text, { columns: true, trim: true, skip_empty_lines: true, bom: true },
(err, records) => (err ? reject(err) : resolve(records)));
});
// Case-insensitive column lookup for a parsed CSV row.
const lowerKeys = (row: Record<string, string>) => {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(row)) out[k.trim().toLowerCase()] = v;
return out;
};
const findCardId = async (name: string, set: string): Promise<number | undefined> => {
const escapedSet = set.replace(/`/g, '');
let result = await client.collections('cards').documents().search({
q: name, query_by: 'productName', per_page: 1,
...(escapedSet ? { filter_by: `setName:\`${escapedSet}\`` } : {}),
});
// Fall back to a name-only match if the set filter found nothing.
if (!result.hits?.length && escapedSet) {
result = await client.collections('cards').documents().search({
q: name, query_by: 'productName', per_page: 1,
});
}
return result.hits?.[0]?.document?.cardId as number | undefined;
};
const importInventory = async (userId: string, rows: Record<string, string>[], catalogName: string) => {
const skipped: { row: number; name: string; reason: string }[] = [];
const tsDocs: ReturnType<typeof inventoryTsDoc>[] = [];
let imported = 0;
for (let i = 0; i < rows.length; i++) {
const rowNumber = i + 2; // account for header row (1-indexed for humans)
const row = lowerKeys(rows[i]);
const name = (row.name || '').trim();
const set = (row.set || '').trim();
if (!name) { skipped.push({ row: rowNumber, name, reason: 'Missing card name' }); continue; }
const createdAt = parseImportDate(row.date);
if ((row.date || '').trim() && !createdAt) {
skipped.push({ row: rowNumber, name, reason: `Invalid date "${row.date.trim()}"` });
continue;
}
const condition = normalizeCondition(row.condition);
const count = Math.max(1, parseInt(row.qty, 10) || 1);
const purchasePrice = parseFloat(String(row.price ?? '').replace(/[^0-9.]/g, '')) || 0;
const cardId = await findCardId(name, set);
if (!cardId) { skipped.push({ row: rowNumber, name, reason: 'Card not found' }); continue; }
const sku = await db.query.skus.findFirst({
where: { cardId: cardId, condition: condition },
columns: { skuId: true },
});
if (!sku?.skuId) { skipped.push({ row: rowNumber, name, reason: `No "${condition}" SKU for card` }); continue; }
// One row per copy: a qty of 5 becomes 5 separate entries of qty 1.
const inserted = await db.insert(inventory).values(
Array.from({ length: count }, () => ({
userId,
skuId: sku.skuId,
catalogName,
purchasePrice: purchasePrice.toFixed(2),
quantity: 1,
note: '',
...(createdAt ? { createdAt } : {}),
}))
).returning();
const card = await db.query.cards.findFirst({
where: { cardId: cardId },
with: { set: true },
});
for (const entry of inserted) tsDocs.push(inventoryTsDoc(entry, card));
imported += inserted.length;
}
if (tsDocs.length) {
try {
await client.collections('inventories').documents().import(tsDocs);
} catch (error) {
console.error('Error importing inventory to Typesense:', error);
}
}
return { imported, skipped };
};
export const POST: APIRoute = async ({ request, locals }) => {
// Access form data from the request body
const formData = await request.formData();
@@ -179,6 +303,33 @@ export const POST: APIRoute = async ({ request, locals }) => {
await addToInventory(userId!, cardId, skuId, purchasePrice, quantity, note, catalogName);
break;
case 'import': {
if (!userId) {
return new Response(JSON.stringify({ error: 'Not authenticated' }), {
status: 401, headers: { 'Content-Type': 'application/json' },
});
}
const file = formData.get('file') as File | null;
if (!file) {
return new Response(JSON.stringify({ error: 'No file uploaded' }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});
}
const importCatalog = formData.get('catalogName')?.toString()?.trim() || 'Default';
try {
const rows = await parseCsv(await file.text());
const summary = await importInventory(userId, rows, importCatalog);
return new Response(JSON.stringify(summary), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Error importing inventory CSV:', error);
return new Response(JSON.stringify({ error: 'Could not parse CSV file' }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});
}
}
case 'remove':
const inventoryId = formData.get('inventoryId')?.toString() || '';
await removeFromInventory(inventoryId);

View File

@@ -103,24 +103,28 @@ const catalogs = catalogRows
<div class="ms-auto position-relative">
<div class="input-group">
<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="i" id="searchInput" class="form-control search-input" placeholder="Search your inventory" />
<input
type="search"
name="q"
id="inventorySearchInput"
class="form-control search-input"
placeholder="Search your inventory"
hx-post="/partials/inventory-cards"
hx-trigger="keyup[key=='Enter']"
hx-target="#gridView"
hx-swap="innerHTML"
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
/>
<button
type="submit"
type="button"
id="inventorySearchBtn"
class="btn btn-purple border-start-0"
aria-label="search"
onclick="
const i = this.closest('form').querySelector('[name=i]').value;
dataLayer.push({ event: 'view_inventory_results', search_term: i });
if (window.location.pathname !== '/dashboard') {
event.preventDefault();
event.stopPropagation();
sessionStorage.setItem('pendingSearch', 1);
window.location.href = '/dashboard';
}
"
hx-post="/partials/inventory-cards"
hx-include="#inventorySearchInput"
hx-target="#gridView"
hx-swap="innerHTML"
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
>
<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>
@@ -144,15 +148,19 @@ const catalogs = catalogRows
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="small text-secondary mb-3">
<p class="small text-secondary mb-2">
Upload a CSV exported from Collectr, TCGPlayer, or any marketplace. Columns: <code>name, set, condition, qty, price, market</code>.
</p>
<p class="small text-secondary mb-3">
Optionally add a <code>date</code> column (e.g. <code>2026-07-16</code>) to record when each card was acquired &mdash; it&rsquo;s used as the entry&rsquo;s added/purchase date. Rows without a date default to today.
</p>
<label class="form-label small text-secondary text-uppercase fw-semibold" for="csvFileInput">Choose File</label>
<input id="csvFileInput" type="file" accept=".csv" class="form-control bg-dark-subtle text-light border-secondary" />
<div id="csvPreview" class="mt-3 d-none">
<p class="small text-secondary fw-semibold mb-1">Preview</p>
<div class="border border-secondary rounded p-2 small text-secondary" id="csvPreviewContent">—</div>
<div class="border border-secondary rounded p-2 small text-secondary overflow-auto" id="csvPreviewContent" style="max-height: 12rem;">—</div>
</div>
<div id="csvImportResult" class="mt-3 d-none"></div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-danger" data-bs-dismiss="modal">Cancel</button>
@@ -172,14 +180,107 @@ const catalogs = catalogRows
<script is:inline>
(function () {
const catalogList = document.getElementById('catalogList');
const searchInput = document.getElementById('inventorySearchInput');
const searchBtn = document.getElementById('inventorySearchBtn');
if (catalogList) {
catalogList.addEventListener('click', (e) => {
const li = e.target.closest('li[data-catalog]');
if (!li) return;
catalogList.querySelectorAll('li[data-catalog]').forEach(el => el.classList.remove('active'));
li.classList.add('active');
// switching catalogs clears the search box so its contents don't misrepresent the grid
if (searchInput) searchInput.value = '';
});
}
// analytics only — the search request itself is fired declaratively via htmx attributes
function pushSearchEvent() {
if (window.dataLayer) {
window.dataLayer.push({ event: 'view_inventory_results', search_term: searchInput?.value.trim() || '' });
}
}
searchBtn?.addEventListener('click', pushSearchEvent);
searchInput?.addEventListener('keyup', (e) => { if (e.key === 'Enter') pushSearchEvent(); });
})();
// ── Bulk CSV import ──────────────────────────────────────────────────────
(function () {
const importModal = document.getElementById('bulkImportModal');
const fileInput = document.getElementById('csvFileInput');
const uploadBtn = document.getElementById('csvUploadBtn');
const preview = document.getElementById('csvPreview');
const previewContent = document.getElementById('csvPreviewContent');
const result = document.getElementById('csvImportResult');
if (!importModal || !fileInput || !uploadBtn) return;
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
let selectedFile = null;
let didImport = false;
fileInput.addEventListener('change', () => {
selectedFile = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null;
result.classList.add('d-none');
if (!selectedFile) { preview.classList.add('d-none'); return; }
const reader = new FileReader();
reader.onload = () => {
const lines = String(reader.result || '').split(/\r?\n/).filter((l) => l.trim()).slice(0, 6);
previewContent.innerHTML = lines
.map((l, i) => `<div class="${i === 0 ? 'text-light fw-semibold' : ''}">${esc(l)}</div>`)
.join('');
preview.classList.remove('d-none');
};
reader.readAsText(selectedFile);
});
uploadBtn.addEventListener('click', async () => {
if (!selectedFile) { alert('Choose a CSV file first.'); return; }
const original = uploadBtn.textContent;
uploadBtn.disabled = true;
uploadBtn.textContent = 'Importing…';
result.classList.add('d-none');
try {
const body = new FormData();
body.append('action', 'import');
body.append('file', selectedFile);
const res = await fetch('/api/inventory', { method: 'POST', body });
const data = await res.json();
if (!res.ok) {
result.className = 'mt-3 alert alert-danger py-2 small';
result.textContent = data.error || 'Import failed.';
result.classList.remove('d-none');
return;
}
didImport = data.imported > 0;
const skipped = data.skipped || [];
let html = `<div class="fw-semibold ${data.imported > 0 ? 'text-success' : ''}">Imported ${data.imported} card${data.imported === 1 ? '' : 's'}.</div>`;
if (skipped.length) {
html += `<div class="mt-1 text-warning fw-semibold">Skipped ${skipped.length}:</div>`;
html += '<ul class="mb-0 ps-3">' + skipped
.map((s) => `<li>Row ${s.row}${s.name ? ' (' + esc(s.name) + ')' : ''}: ${esc(s.reason)}</li>`)
.join('') + '</ul>';
}
result.className = 'mt-3 alert alert-dark py-2 small overflow-auto';
result.style.maxHeight = '14rem';
result.innerHTML = html;
result.classList.remove('d-none');
uploadBtn.textContent = didImport ? 'Done' : 'Upload';
} catch (e) {
result.className = 'mt-3 alert alert-danger py-2 small';
result.textContent = 'Import failed.';
result.classList.remove('d-none');
} finally {
uploadBtn.disabled = false;
if (uploadBtn.textContent === 'Importing…') uploadBtn.textContent = original;
}
});
// Refresh the dashboard (grid, catalogs, totals) after a successful import.
importModal.addEventListener('hidden.bs.modal', () => {
if (didImport) window.location.reload();
});
})();
(function () {

View File

@@ -137,4 +137,16 @@ console.log(`totalHits: ${totalHits}`);
</div>
);
})
})}
{start + 20 < totalHits &&
<div
hx-post="/partials/inventory-cards"
hx-trigger="revealed"
hx-vals={JSON.stringify({ start: start + 20, catalog, q: query })}
hx-target="#gridView"
hx-swap="beforeend"
hx-on--after-request="this.remove()"
>
Loading...
</div>
}