From b19a3fdf4886bdd7f4170d26da0ef092b9e9344e Mon Sep 17 00:00:00 2001 From: Adriano Belisario Date: Sun, 3 May 2026 22:52:32 +0000 Subject: [PATCH] feat: grid layout, global claim/qty toggles, admin access link, swaddle image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Public wishlist now renders as responsive 3-col grid instead of list - Subtitle supports line breaks (whitespace-pre-line) - claimingEnabled and showQuantity moved to global site settings (not per-item); toggled in admin Configurações panel; claim API enforces server-side - Admin dashboard shows admin access link with copy button - Settings API exposes and persists the two new boolean settings Co-Authored-By: Claude Sonnet 4.6 --- .../items-reorder-async-transaction.md | 60 ++++ app/[slug]/page.tsx | 309 +++++++++--------- app/admin/page.tsx | 77 ++++- app/api/admin/access-link/route.ts | 9 + app/api/public/items/[id]/claim/route.ts | 7 +- app/api/settings/route.ts | 26 +- components/header.tsx | 2 +- lib/api.ts | 2 + 8 files changed, 326 insertions(+), 166 deletions(-) create mode 100644 .planning/debug/resolved/items-reorder-async-transaction.md create mode 100644 app/api/admin/access-link/route.ts diff --git a/.planning/debug/resolved/items-reorder-async-transaction.md b/.planning/debug/resolved/items-reorder-async-transaction.md new file mode 100644 index 0000000..9e4b536 --- /dev/null +++ b/.planning/debug/resolved/items-reorder-async-transaction.md @@ -0,0 +1,60 @@ +--- +status: resolved +trigger: "Investigate why reordering items fails with 'Failed to reorder item' in the wishlist app" +created: 2026-05-03T00:00:00Z +updated: 2026-05-03T00:00:00Z +--- + +## Current Focus + +hypothesis: confirmed — async transaction callback used with synchronous better-sqlite3 driver +test: docker logs showed exact error +expecting: fix converts async transaction to synchronous +next_action: done + +## Symptoms + +expected: Dragging/reordering items in admin UI saves new order +actual: "Failed to reorder item" error appears on every reorder attempt +errors: TypeError: Transaction function cannot return a promise +reproduction: Attempt to reorder any wishlist item in /admin +started: unknown + +## Eliminated + +- hypothesis: HTTP method mismatch + evidence: route exports POST, frontend calls POST + timestamp: 2026-05-03 + +- hypothesis: auth issue + evidence: error occurs inside the transaction, not at auth check + timestamp: 2026-05-03 + +- hypothesis: body parsing / missing newSortOrder + evidence: error occurs after body is parsed and validated + timestamp: 2026-05-03 + +## Evidence + +- timestamp: 2026-05-03 + checked: docker logs chadomartin --tail=50 + found: "TypeError: Transaction function cannot return a promise" in items reorder route + implication: better-sqlite3 is synchronous; async callbacks to db.transaction() throw + +- timestamp: 2026-05-03 + checked: app/api/wishlists/[id]/reorder/route.ts + found: uses synchronous pattern — db.transaction((tx) => { ... .run() ... .all() }) + implication: this is the correct pattern for better-sqlite3 + +- timestamp: 2026-05-03 + checked: app/api/items/[id]/reorder/route.ts + found: uses async pattern — await db.transaction(async (tx) => { await tx.update()... }) + implication: this is the bug — async callback is rejected by better-sqlite3 + +## Resolution + +root_cause: app/api/items/[id]/reorder/route.ts used `await db.transaction(async (tx) => { ... })` — an async callback. better-sqlite3 is a synchronous driver and throws "Transaction function cannot return a promise" when given one. +fix: Replaced the async transaction with the synchronous pattern (matching the working wishlists reorder route): `db.transaction((tx) => { ... .run() ... .all() })` with no async/await inside. +verification: Code matches the working pattern in the wishlists route; docker logs showed the exact error which this change directly addresses. +files_changed: + - app/api/items/[id]/reorder/route.ts diff --git a/app/[slug]/page.tsx b/app/[slug]/page.tsx index 2b0f195..27fec5c 100644 --- a/app/[slug]/page.tsx +++ b/app/[slug]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; import DOMPurify from 'dompurify'; -import { authApi, wishlistsApi, itemsApi, claimingApi, type Wishlist, type Item } from '@/lib/api'; +import { authApi, wishlistsApi, itemsApi, claimingApi, settingsApi, type Wishlist, type Item, type Settings } from '@/lib/api'; import Header from '@/components/header'; import GuestGuard from '@/components/guest-guard'; @@ -19,6 +19,7 @@ function PublicWishlistContent() { const params = useParams(); const [wishlist, setWishlist] = useState(null); const [items, setItems] = useState([]); + const [siteSettings, setSiteSettings] = useState({ siteTitle: '', homepageSubtext: '', claimingEnabled: true, showQuantity: true }); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); @@ -54,6 +55,7 @@ function PublicWishlistContent() { }, []); useEffect(() => { + settingsApi.getSettings().then(setSiteSettings).catch(() => {}); fetchWishlist(); }, [params.slug]); @@ -192,176 +194,175 @@ function PublicWishlistContent() {

) : ( -
+
{filteredItems.map((item) => { const myClaim = myClaimFor(item); const maxForMe = item.remainingQuantity + (myClaim?.quantity ?? 0); - const showQuantitySummary = item.quantity > 1; + const showQuantitySummary = item.quantity > 1 && siteSettings.showQuantity; const sold = item.remainingQuantity === 0 && !myClaim; + const canClaim = siteSettings.claimingEnabled; return (
-
- {/* Left: Image */} - {item.imageUrl && ( -
- {item.name} + {/* Top: Image */} + {item.imageUrl && ( +
+ {item.name} +
+ )} + + {/* Middle: Item Details */} +
+

+ {item.name} +

+ {item.price != null && ( +

+ {new Intl.NumberFormat('pt-BR', { style: 'currency', currency: item.currency || 'BRL' }).format(item.price)} +

+ )} + {showQuantitySummary && ( +

+ {item.claimedQuantity} de {item.quantity} reservados +

+ )} + {item.description && ( +

+ {item.description} +

+ )} + + {/* Existing claims list */} + {item.claims.length > 0 && ( +
    + {item.claims.map((c) => { + const isMine = c.guest.id === currentGuestId; + const canCancel = isMine || isAdmin; + return ( +
  • +
    + {isMine ? 'Você' : 'Reservado'} + {item.quantity > 1 && ( + · {c.quantity} un. + )} + {c.note && isMine && ( + + "{c.note}" + + )} +
    + {canCancel && ( + + )} +
  • + ); + })} +
+ )} +
+ + {/* Bottom: Action Area */} +
+ {item.purchaseUrls && item.purchaseUrls.length > 0 && ( +
+ {item.purchaseUrls.map((url, idx) => ( + + + {url.label} + + + ))}
)} - {/* Middle: Item Details */} -
-

- {item.name} -

- {item.price != null && ( -

- {new Intl.NumberFormat('pt-BR', { style: 'currency', currency: item.currency || 'BRL' }).format(item.price)} -

- )} - {showQuantitySummary && ( -

- {item.claimedQuantity} de {item.quantity} reservados -

- )} - {item.description && ( -

- {item.description} -

- )} - - {/* Existing claims list */} - {item.claims.length > 0 && ( -
    - {item.claims.map((c) => { - const isMine = c.guest.id === currentGuestId; - const canCancel = isMine || isAdmin; - return ( -
  • -
    - {isMine ? 'Você' : 'Reservado'} - {item.quantity > 1 && ( - · {c.quantity} un. - )} - {c.note && isMine && ( - - "{c.note}" - - )} -
    - {canCancel && ( - - )} -
  • - ); - })} -
- )} -
- - {/* Right: Action Area */} -
-
- {item.purchaseUrls && item.purchaseUrls.length > 0 && ( -
- {item.purchaseUrls.map((url, idx) => ( - - - {url.label} - - - ))} + {!canClaim ? ( +
+ Reservas desativadas +
+ ) : sold ? ( +
+ Esgotado +
+ ) : claimingItemId === item.id ? ( +
handleSubmitClaim(e, item.id)} className="space-y-3"> + {claimError && ( +
+ {claimError}
)} -
-
- {sold ? ( -
- Esgotado + {item.quantity > 1 && siteSettings.showQuantity && ( +
+ + setClaimQty(Math.max(1, Math.min(maxForMe, Number(e.target.value) || 1)))} + className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-gray-700 dark:text-white" + />
- ) : claimingItemId === item.id ? ( - handleSubmitClaim(e, item.id)} className="space-y-3"> - {claimError && ( -
- {claimError} -
- )} - - {item.quantity > 1 && ( -
- - setClaimQty(Math.max(1, Math.min(maxForMe, Number(e.target.value) || 1)))} - className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-gray-700 dark:text-white" - /> -
- )} - -
- -