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'}
-
- -
- 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}

+ +
+ +
+ + +
+
+ +
+ + +
+
+
+ )}
); } 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() { {openMenuId === item.id && ( -
+