Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions retrovault/src/__tests__/fieldPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ describe('Field page actions', () => {
expect(source).toContain('nextWishlist.forEach');
expect(source).toContain("That player'} already has this on their wishlist");
expect(source).toContain("'🛒 Bought It'");
expect(source).toContain('setPendingPurchase(r)');
expect(source).toContain('PRICE PAID ($)');
expect(source).toContain('CONFIRM PURCHASE');
expect(source).toContain('getExistingRecordNotice(existing.copies?.length || 0)');
expect(source).toContain("On ${selectedWishlistItem.player?.name || 'selected'}'s Wishlist");
expect(source).toContain("/api/field/identify");
expect(source).toContain('📸 Photo Lookup');
Expand Down
11 changes: 7 additions & 4 deletions retrovault/src/__tests__/layoutOverflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,13 @@ describe('Layout overflow — button rows must use flex-wrap', () => {

it('Inventory action dropdown is viewport-safe for longer player lists', () => {
const src = readFile(path.join(SRC, 'app/inventory/page.tsx'));
const dropdownCtx = src.match(/<div className="absolute right-0 top-full mt-1 z-20[^"]*" data-menu>/)?.[0] ?? '';
expect(dropdownCtx).toContain('w-64');
expect(dropdownCtx).toContain('max-w-[calc(100vw-2rem)]');
expect(dropdownCtx).toContain('max-h-[70vh]');
const dropdownCtx = src.match(/<div className="[^"]*" role="menu" aria-label=\{`Actions for \$\{item\.title\}`\} data-menu>/)?.[0] ?? '';
expect(dropdownCtx).toContain('fixed inset-x-4 bottom-4');
expect(dropdownCtx).toContain('max-h-[calc(100dvh-2rem)]');
expect(dropdownCtx).toContain('sm:absolute');
expect(dropdownCtx).toContain('sm:w-64');
expect(dropdownCtx).toContain('sm:max-w-[calc(100vw-2rem)]');
expect(dropdownCtx).toContain('sm:max-h-[70vh]');
expect(dropdownCtx).toContain('overflow-y-auto');
expect(dropdownCtx).toContain('overflow-x-hidden');
});
Expand Down
39 changes: 39 additions & 0 deletions retrovault/src/__tests__/mobilePurchaseActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
import { getExistingRecordNotice } from '@/lib/fieldPurchaseFlow';

describe('mobile inventory actions', () => {
const source = fs.readFileSync(path.join(process.cwd(), 'src/app/inventory/page.tsx'), 'utf8');

it('uses a viewport-fixed mobile action sheet rather than clipping the menu inside the table scroller', () => {
expect(source).toContain('fixed inset-x-4 bottom-4 z-[100]');
expect(source).toContain('sm:absolute sm:inset-x-auto sm:bottom-auto sm:right-0 sm:top-full');
expect(source).toContain('max-h-[calc(100dvh-2rem)]');
expect(source).toContain('min-h-11 min-w-11');
expect(source).toContain('aria-haspopup="menu"');
});

it('keeps copy cost editing and save controls usable on narrow screens', () => {
expect(source).toContain('w-full sm:w-[100px]');
expect(source).toContain('sticky bottom-0');
expect(source).toContain('inputMode=\"decimal\"');
});
});

describe('field purchase messaging', () => {
it('does not imply ownership when only a catalog/price record exists', () => {
expect(getExistingRecordNotice(0)).toBe(
'Price data exists for this game, but you do not have an owned copy yet. Bought It will add your first copy to the existing record.',
);
});

it('reports the owned-copy count when another copy will be added', () => {
expect(getExistingRecordNotice(1)).toBe(
'You own 1 copy of this game. Bought It will add another copy to the existing record.',
);
expect(getExistingRecordNotice(3)).toBe(
'You own 3 copies of this game. Bought It will add another copy to the existing record.',
);
});
});
97 changes: 69 additions & 28 deletions retrovault/src/app/field/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
getMatchConfidence,
} from "@/lib/fieldMode";
import { addPurchaseToActiveConventionSession } from "@/lib/conventionSession";
import { getExistingRecordNotice } from "@/lib/fieldPurchaseFlow";

type PriceVariantMatch = {
title: string;
Expand Down Expand Up @@ -167,6 +168,8 @@ export default function FieldPage() {
const [wishlistPlayerId, setWishlistPlayerId] = useState('');
const [fieldCondition, setFieldCondition] = useState<CopyCondition>("Loose");
const [purchaseQuantity, setPurchaseQuantity] = useState("1");
const [pendingPurchase, setPendingPurchase] = useState<PriceResult | null>(null);
const [paidPrice, setPaidPrice] = useState("");
const [lastSearchIssue, setLastSearchIssue] = useState<{ isOffline: boolean; hadTimeout: boolean } | null>(null);
const [needsCacheRefresh, setNeedsCacheRefresh] = useState(false);
const [suggestions, setSuggestions] = useState<{ title: string; platform: string }[]>([]);
Expand Down Expand Up @@ -644,8 +647,10 @@ export default function FieldPage() {
);
setNeedsCacheRefresh(!!cacheMeta);
await refreshFieldData();
return true;
} catch (e: unknown) {
setSaveStatus(`Error: ${getErrorMessage(e, 'Could not save to inventory')}`);
return false;
} finally {
setSavingKey('');
}
Expand Down Expand Up @@ -1007,6 +1012,7 @@ export default function FieldPage() {
const matchingWishlistItems = wishlistItems.filter((item) => normalizeFieldKey(item.title, item.platform) === normalizeFieldKey(r.title, r.platform));
const selectedWishlistItem = matchingWishlistItems.find((item) => (item.playerId || '') === wishlistPlayerId) || null;
const otherWishlistItems = matchingWishlistItems.filter((item) => (item.playerId || '') !== wishlistPlayerId);
const existing = findInventoryMatch(inventory, r.title, r.platform);

return (
<div key={i} className={`bg-zinc-950 border-2 mb-4 p-5 space-y-4 ${
Expand Down Expand Up @@ -1122,9 +1128,9 @@ export default function FieldPage() {
</div>
)}

{findInventoryMatch(inventory, r.title, r.platform) && (
{existing && (
<div className="border border-blue-800 bg-blue-950/20 px-4 py-2 font-terminal text-sm text-blue-300">
📦 You already have this game record. Bought It will add a new copy to it instead of creating a duplicate.
📦 {getExistingRecordNotice(existing.copies?.length || 0)}
</div>
)}

Expand All @@ -1150,34 +1156,17 @@ export default function FieldPage() {
: '🎁 Add to Wishlist'}
</button>
<button
onClick={() => saveToInventory(r, {
condition: fieldCondition,
priceAcquired: askPrice || '0.00',
quantity: Math.max(1, parseInt(purchaseQuantity || '1', 10) || 1),
})}
onClick={() => {
setPaidPrice(askPrice);
setPendingPurchase(r);
}}
disabled={savingKey === `purchase:${r.title}:${r.platform}`}
className="px-3 py-2 font-terminal text-sm border border-emerald-700 text-emerald-300 hover:bg-emerald-950/30 disabled:opacity-50 transition-colors"
>
{savingKey === `purchase:${r.title}:${r.platform}` ? '...' : '🛒 Bought It'}
</button>
</div>

<div className="flex flex-wrap items-end gap-3">
<label className="font-terminal text-xs text-zinc-500 uppercase tracking-wider">
Qty
<input
type="number"
min="1"
inputMode="numeric"
value={purchaseQuantity}
onChange={(e) => setPurchaseQuantity(e.target.value.replace(/[^0-9]/g, '') || '1')}
className="mt-1 w-20 bg-black border border-zinc-700 px-2 py-2 text-sm text-green-300 focus:border-green-500 focus:outline-none"
/>
</label>
<div className="font-terminal text-xs text-zinc-600">
Multi-copy buys will add one inventory copy and one acquisition log per unit.
</div>
</div>

{r.wishlistNotes && (
<div className="font-terminal text-xs text-zinc-500">
Expand Down Expand Up @@ -1219,11 +1208,6 @@ export default function FieldPage() {
);
})()}

{askPrice && (
<div className="font-terminal text-xs text-zinc-500">
Bought It will save <span className="text-yellow-400">{Math.max(1, parseInt(purchaseQuantity || '1', 10) || 1)}</span> {Math.max(1, parseInt(purchaseQuantity || '1', 10) || 1) === 1 ? 'copy' : 'copies'} at <span className="text-yellow-400">${parseFloat(askPrice || '0').toFixed(2)}</span> each with condition <span className="text-blue-400">{fieldCondition}</span>.
</div>
)}
</>
)}
</div>
Expand Down Expand Up @@ -1299,6 +1283,63 @@ export default function FieldPage() {
})}
</div>
)}


{pendingPurchase && (
<div className="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-end sm:items-center justify-center p-3" onClick={() => setPendingPurchase(null)}>
<div role="dialog" aria-modal="true" aria-labelledby="purchase-title" className="w-full max-w-md bg-zinc-950 border-2 border-emerald-700 p-5 shadow-[0_0_30px_rgba(16,185,129,0.25)]" onClick={(event) => event.stopPropagation()}>
<h2 id="purchase-title" className="font-terminal text-2xl text-emerald-300 uppercase">Record Purchase</h2>
<p className="mt-1 font-terminal text-sm text-zinc-400">{pendingPurchase.title} · {pendingPurchase.platform}</p>

<div className="mt-5 space-y-4">
<label className="block font-terminal text-sm text-zinc-400 uppercase">
PRICE PAID ($)
<input
autoFocus
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={paidPrice}
onChange={(event) => setPaidPrice(event.target.value)}
placeholder="0.00"
className="mt-1 w-full bg-black border-2 border-emerald-800 p-3 text-xl text-emerald-300 focus:border-emerald-400 focus:outline-none"
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="font-terminal text-sm text-zinc-400 uppercase">
Quantity
<input type="number" min="1" inputMode="numeric" value={purchaseQuantity} onChange={(event) => setPurchaseQuantity(event.target.value.replace(/[^0-9]/g, '') || '1')} className="mt-1 w-full bg-black border border-zinc-700 p-3 text-green-300 focus:outline-none" />
</label>
<label className="font-terminal text-sm text-zinc-400 uppercase">
Condition
<select value={fieldCondition} onChange={(event) => setFieldCondition(event.target.value as CopyCondition)} className="mt-1 w-full bg-black border border-zinc-700 p-3 text-blue-300 focus:outline-none">
{CONDITION_OPTIONS.map((condition) => <option key={condition} value={condition}>{condition}</option>)}
</select>
</label>
</div>
</div>

<div className="mt-6 flex gap-3">
<button onClick={() => setPendingPurchase(null)} className="flex-1 border border-zinc-700 px-4 py-3 font-terminal text-zinc-400">CANCEL</button>
<button
onClick={async () => {
const saved = await saveToInventory(pendingPurchase, {
condition: fieldCondition,
priceAcquired: paidPrice,
quantity: Math.max(1, parseInt(purchaseQuantity || '1', 10) || 1),
});
if (saved) setPendingPurchase(null);
}}
disabled={paidPrice.trim() === '' || savingKey === `purchase:${pendingPurchase.title}:${pendingPurchase.platform}`}
className="flex-1 bg-emerald-600 px-4 py-3 font-terminal font-bold text-black disabled:opacity-40"
>
{savingKey === `purchase:${pendingPurchase.title}:${pendingPurchase.platform}` ? 'SAVING...' : 'CONFIRM PURCHASE'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
27 changes: 17 additions & 10 deletions retrovault/src/app/inventory/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -577,11 +577,15 @@ export default function InventoryPage() {
const saveItem = async () => {
const method = editingItem ? "PUT" : "POST";
try {
await fetch("/api/inventory", {
const response = await fetch("/api/inventory", {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildEditPayload(formData, formCopies)),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload?.error || "Failed to save asset");
}
setIsModalOpen(false);
fetchInventory();
} catch (e: unknown) { alert(e instanceof Error ? e.message : "Failed to save asset"); }
Expand Down Expand Up @@ -967,14 +971,17 @@ export default function InventoryPage() {
<td className="p-3 text-center relative" data-menu>
<button
onClick={() => setOpenMenuId(openMenuId === item.id ? null : item.id)}
className="text-zinc-400 hover:text-green-400 font-terminal text-2xl leading-none px-3 py-1 rounded hover:bg-green-900/30 transition-colors"
className="min-h-11 min-w-11 text-zinc-400 hover:text-green-400 font-terminal text-2xl leading-none px-3 py-2 rounded hover:bg-green-900/30 transition-colors"
title="Actions"
aria-label={`Actions for ${item.title}`}
aria-haspopup="menu"
aria-expanded={openMenuId === item.id}
data-menu
>
</button>
{openMenuId === item.id && (
<div className="absolute right-0 top-full mt-1 z-20 bg-zinc-900 border-2 border-green-800 rounded-sm shadow-[0_0_15px_rgba(0,0,0,0.5)] w-64 max-w-[calc(100vw-2rem)] max-h-[70vh] overflow-y-auto overflow-x-hidden text-left" data-menu>
<div className="fixed inset-x-4 bottom-4 z-[100] bg-zinc-900 border-2 border-green-800 rounded-sm shadow-[0_0_25px_rgba(0,0,0,0.8)] max-h-[calc(100dvh-2rem)] overflow-y-auto overflow-x-hidden text-left sm:absolute sm:inset-x-auto sm:bottom-auto sm:right-0 sm:top-full sm:mt-1 sm:z-20 sm:w-64 sm:max-w-[calc(100vw-2rem)] sm:max-h-[70vh]" role="menu" aria-label={`Actions for ${item.title}`} data-menu>
<Tip text="Pull a fresh live market lookup for this title and save the latest PriceCharting data into the vault.">
<button
onClick={() => { fetchRow(item); setOpenMenuId(null); }}
Expand Down Expand Up @@ -1547,8 +1554,8 @@ export default function InventoryPage() {

{/* CRUD Modal */}
{isModalOpen && (
<div className="fixed inset-0 bg-black/80 flex items-start justify-center z-50 backdrop-blur-sm overflow-y-auto py-10">
<div className="bg-zinc-950 border-4 border-green-500 p-6 rounded-sm w-full max-w-3xl shadow-[0_0_30px_rgba(34,197,94,0.4)] my-auto">
<div className="fixed inset-0 bg-black/80 flex items-start justify-center z-50 backdrop-blur-sm overflow-y-auto px-2 py-2 sm:py-10">
<div className="bg-zinc-950 border-4 border-green-500 p-3 sm:p-6 rounded-sm w-full max-w-3xl shadow-[0_0_30px_rgba(34,197,94,0.4)] my-auto">
<h3 className="text-2xl text-green-400 font-terminal uppercase mb-6 tracking-widest border-b-2 border-green-900 pb-2">
{editingItem ? "MANAGE ASSET & COPIES" : "NEW ASSET CATALOG ENTRY"}
</h3>
Expand Down Expand Up @@ -1601,7 +1608,7 @@ export default function InventoryPage() {
</div>
<div className="space-y-4 max-h-[40vh] overflow-y-auto pr-2">
{formCopies.map((copy, idx) => (
<div key={copy.id} className="bg-black border border-green-800 p-4 flex flex-col md:flex-row gap-4 items-end relative">
<div key={copy.id} className="bg-black border border-green-800 p-4 flex flex-col md:flex-row gap-4 items-stretch md:items-end relative">
<span className="absolute top-2 right-2 text-zinc-500 text-sm">#{idx + 1}</span>
<div className="flex gap-4 items-center">
<label className="flex items-center gap-2 cursor-pointer">
Expand All @@ -1611,11 +1618,11 @@ export default function InventoryPage() {
<input type="checkbox" className="w-5 h-5 accent-green-600" checked={copy.hasManual} onChange={(e) => updateCopy(copy.id, { hasManual: e.target.checked })} /> MANUAL
</label>
</div>
<div className="w-[100px]">
<div className="w-full sm:w-[100px]">
<label className="block mb-1 text-sm text-zinc-400">COST ($)</label>
<input type="number" step="0.01" className="w-full bg-black border-2 border-green-800 p-2 text-green-300 focus:outline-none" value={copy.priceAcquired} onChange={(e) => updateCopy(copy.id, { priceAcquired: e.target.value })} />
<input type="number" min="0" step="0.01" inputMode="decimal" className="w-full bg-black border-2 border-green-800 p-3 sm:p-2 text-green-300 focus:outline-none" value={copy.priceAcquired} onChange={(e) => updateCopy(copy.id, { priceAcquired: e.target.value })} />
</div>
<div className="w-[150px]">
<div className="w-full sm:w-[150px]">
<label className="block mb-1 text-sm text-zinc-400">CONDITION</label>
<select className="w-full bg-black border-2 border-green-800 p-2 text-green-300 uppercase" value={copy.condition} onChange={(e) => updateCopy(copy.id, { condition: e.target.value })}>
<option value="Mint">MINT</option>
Expand All @@ -1634,7 +1641,7 @@ export default function InventoryPage() {
</div>
</div>

<div className="mt-8 pt-4 border-t-2 border-green-900 flex justify-between items-center">
<div className="sticky bottom-0 z-10 -mx-3 sm:-mx-6 mt-8 px-3 sm:px-6 py-4 border-t-2 border-green-900 bg-zinc-950 flex flex-col sm:flex-row gap-3 justify-between sm:items-center">
<span className="text-zinc-500 font-terminal">Total Paid: ${totalPaid(formCopies).toFixed(2)}</span>
<div className="flex gap-4">
<button onClick={() => setIsModalOpen(false)} className="px-4 py-2 font-terminal text-xl text-zinc-400 hover:text-white">CANCEL</button>
Expand Down
7 changes: 7 additions & 0 deletions retrovault/src/lib/fieldPurchaseFlow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function getExistingRecordNotice(ownedCopies: number): string {
const count = Math.max(0, Math.floor(ownedCopies));
if (count === 0) {
return 'Price data exists for this game, but you do not have an owned copy yet. Bought It will add your first copy to the existing record.';
}
return `You own ${count} ${count === 1 ? 'copy' : 'copies'} of this game. Bought It will add another copy to the existing record.`;
}
Loading