diff --git a/retrovault/src/__tests__/fieldPage.test.ts b/retrovault/src/__tests__/fieldPage.test.ts
index e13a1e7..55b15b8 100644
--- a/retrovault/src/__tests__/fieldPage.test.ts
+++ b/retrovault/src/__tests__/fieldPage.test.ts
@@ -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');
diff --git a/retrovault/src/__tests__/layoutOverflow.test.ts b/retrovault/src/__tests__/layoutOverflow.test.ts
index 353ecfe..87869f1 100644
--- a/retrovault/src/__tests__/layoutOverflow.test.ts
+++ b/retrovault/src/__tests__/layoutOverflow.test.ts
@@ -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(/
/)?.[0] ?? '';
- expect(dropdownCtx).toContain('w-64');
- expect(dropdownCtx).toContain('max-w-[calc(100vw-2rem)]');
- expect(dropdownCtx).toContain('max-h-[70vh]');
+ const dropdownCtx = src.match(/
/)?.[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');
});
diff --git a/retrovault/src/__tests__/mobilePurchaseActions.test.ts b/retrovault/src/__tests__/mobilePurchaseActions.test.ts
new file mode 100644
index 0000000..bbfb062
--- /dev/null
+++ b/retrovault/src/__tests__/mobilePurchaseActions.test.ts
@@ -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.',
+ );
+ });
+});
diff --git a/retrovault/src/app/field/page.tsx b/retrovault/src/app/field/page.tsx
index 5cbc2d1..163a95f 100644
--- a/retrovault/src/app/field/page.tsx
+++ b/retrovault/src/app/field/page.tsx
@@ -19,6 +19,7 @@ import {
getMatchConfidence,
} from "@/lib/fieldMode";
import { addPurchaseToActiveConventionSession } from "@/lib/conventionSession";
+import { getExistingRecordNotice } from "@/lib/fieldPurchaseFlow";
type PriceVariantMatch = {
title: string;
@@ -167,6 +168,8 @@ export default function FieldPage() {
const [wishlistPlayerId, setWishlistPlayerId] = useState('');
const [fieldCondition, setFieldCondition] = useState
("Loose");
const [purchaseQuantity, setPurchaseQuantity] = useState("1");
+ const [pendingPurchase, setPendingPurchase] = useState(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 }[]>([]);
@@ -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('');
}
@@ -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 (
)}
- {findInventoryMatch(inventory, r.title, r.platform) && (
+ {existing && (
- ๐ฆ 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)}
)}
@@ -1150,11 +1156,10 @@ export default function FieldPage() {
: '๐ Add to Wishlist'}
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"
>
@@ -1162,22 +1167,6 @@ export default function FieldPage() {
-
-
- Qty
- 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"
- />
-
-
- Multi-copy buys will add one inventory copy and one acquisition log per unit.
-
-
{r.wishlistNotes && (
@@ -1219,11 +1208,6 @@ export default function FieldPage() {
);
})()}
- {askPrice && (
-
- Bought It will save {Math.max(1, parseInt(purchaseQuantity || '1', 10) || 1)} {Math.max(1, parseInt(purchaseQuantity || '1', 10) || 1) === 1 ? 'copy' : 'copies'} at ${parseFloat(askPrice || '0').toFixed(2)} each with condition {fieldCondition} .
-
- )}
>
)}
@@ -1299,6 +1283,63 @@ export default function FieldPage() {
})}
)}
+
+
+ {pendingPurchase && (
+
setPendingPurchase(null)}>
+
event.stopPropagation()}>
+
Record Purchase
+
{pendingPurchase.title} ยท {pendingPurchase.platform}
+
+
+
+
+ setPendingPurchase(null)} className="flex-1 border border-zinc-700 px-4 py-3 font-terminal text-zinc-400">CANCEL
+ {
+ 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'}
+
+
+
+
+ )}
);
}
diff --git a/retrovault/src/app/inventory/page.tsx b/retrovault/src/app/inventory/page.tsx
index 8a68f29..0095857 100644
--- a/retrovault/src/app/inventory/page.tsx
+++ b/retrovault/src/app/inventory/page.tsx
@@ -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"); }
@@ -967,14 +971,17 @@ export default function InventoryPage() {
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
>
โฏ
{openMenuId === item.id && (
-
+
{ fetchRow(item); setOpenMenuId(null); }}
@@ -1547,8 +1554,8 @@ export default function InventoryPage() {
{/* CRUD Modal */}
{isModalOpen && (
-
-
+
+
{editingItem ? "MANAGE ASSET & COPIES" : "NEW ASSET CATALOG ENTRY"}
@@ -1601,7 +1608,7 @@ export default function InventoryPage() {
{formCopies.map((copy, idx) => (
-
+
#{idx + 1}
@@ -1611,11 +1618,11 @@ export default function InventoryPage() {
updateCopy(copy.id, { hasManual: e.target.checked })} /> MANUAL
-
+
COST ($)
- updateCopy(copy.id, { priceAcquired: e.target.value })} />
+ updateCopy(copy.id, { priceAcquired: e.target.value })} />
-
+
CONDITION
updateCopy(copy.id, { condition: e.target.value })}>
MINT
@@ -1634,7 +1641,7 @@ export default function InventoryPage() {
-
+
Total Paid: ${totalPaid(formCopies).toFixed(2)}
setIsModalOpen(false)} className="px-4 py-2 font-terminal text-xl text-zinc-400 hover:text-white">CANCEL
diff --git a/retrovault/src/lib/fieldPurchaseFlow.ts b/retrovault/src/lib/fieldPurchaseFlow.ts
new file mode 100644
index 0000000..1cab9c9
--- /dev/null
+++ b/retrovault/src/lib/fieldPurchaseFlow.ts
@@ -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.`;
+}