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 && (
-
-

+ {/* Top: Image */}
+ {item.imageUrl && (
+
+

+
+ )}
+
+ {/* 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 && (
+
+ )}
+
+
+ {/* Bottom: Action Area */}
+
+ {item.purchaseUrls && item.purchaseUrls.length > 0 && (
+
)}
- {/* 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 && (
-
- )}
-
-
- {/* Right: Action Area */}
-
-
- {item.purchaseUrls && item.purchaseUrls.length > 0 && (
-
- {item.purchaseUrls.map((url, idx) => (
-
-
- {url.label}
-
-
- ))}
+ {!canClaim ? (
+
+ Reservas desativadas
+
+ ) : sold ? (
+
+ Esgotado
+
+ ) : claimingItemId === item.id ? (
+
-
- {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 ? (
-
- ) : (
-
)}
-
-
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
);
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
index b280518..9edef8a 100644
--- a/app/admin/page.tsx
+++ b/app/admin/page.tsx
@@ -39,7 +39,11 @@ function AdminPageContent() {
const [settings, setSettings] = useState
({
siteTitle: 'Wishlist',
homepageSubtext: '',
+ claimingEnabled: true,
+ showQuantity: true,
});
+ const [adminToken, setAdminToken] = useState(null);
+ const [linkCopied, setLinkCopied] = useState(false);
// Unified config edit state
const [isEditingWishlist, setIsEditingWishlist] = useState(false);
@@ -52,6 +56,8 @@ function AdminPageContent() {
isPublic: true,
siteTitle: '',
homepageSubtext: '',
+ claimingEnabled: true,
+ showQuantity: true,
});
const [editError, setEditError] = useState('');
const [isWishlistImageUploading, setIsWishlistImageUploading] = useState(false);
@@ -68,8 +74,27 @@ function AdminPageContent() {
useEffect(() => {
fetchData();
+ fetchAdminToken();
}, []);
+ const fetchAdminToken = async () => {
+ try {
+ const res = await fetch('/api/admin/access-link', { credentials: 'include' });
+ if (res.ok) {
+ const data = await res.json();
+ setAdminToken(data.token);
+ }
+ } catch { /* ignore */ }
+ };
+
+ const copyAdminLink = async () => {
+ if (!adminToken) return;
+ const url = `${window.location.origin}/?adm=${adminToken}`;
+ await navigator.clipboard.writeText(url);
+ setLinkCopied(true);
+ setTimeout(() => setLinkCopied(false), 2000);
+ };
+
const fetchData = async () => {
try {
const [lists, settingsData] = await Promise.all([
@@ -108,6 +133,8 @@ function AdminPageContent() {
isPublic: wishlist.isPublic,
siteTitle: settings.siteTitle,
homepageSubtext: settings.homepageSubtext,
+ claimingEnabled: settings.claimingEnabled,
+ showQuantity: settings.showQuantity,
});
setEditError('');
setIsEditingWishlist(true);
@@ -118,13 +145,13 @@ function AdminPageContent() {
if (!wishlist) return;
setEditError('');
try {
- const { siteTitle, homepageSubtext, ...wishlistFields } = editForm;
+ const { siteTitle, homepageSubtext, claimingEnabled, showQuantity, ...wishlistFields } = editForm;
const [updated] = await Promise.all([
wishlistsApi.update(wishlist.id, wishlistFields),
- settingsApi.updateSettings({ siteTitle, homepageSubtext }),
+ settingsApi.updateSettings({ siteTitle, homepageSubtext, claimingEnabled, showQuantity }),
]);
setWishlist(updated);
- setSettings({ siteTitle, homepageSubtext });
+ setSettings({ siteTitle, homepageSubtext, claimingEnabled, showQuantity });
setIsEditingWishlist(false);
} catch (error: any) {
setEditError(error.message || 'Failed to update settings');
@@ -292,6 +319,26 @@ function AdminPageContent() {
rows={2}
/>
+
+
+
+
+
+
+ Reservas {settings.claimingEnabled ? 'ativas' : 'desativadas'}
+
+
+ Quantidade {settings.showQuantity ? 'visível' : 'oculta'}
+
+
+ {adminToken && (
+
+
Link de acesso admin
+
+
+ {`${typeof window !== 'undefined' ? window.location.origin : ''}/?adm=${adminToken}`}
+
+
+
+
+ )}
)}
diff --git a/app/api/admin/access-link/route.ts b/app/api/admin/access-link/route.ts
new file mode 100644
index 0000000..7435752
--- /dev/null
+++ b/app/api/admin/access-link/route.ts
@@ -0,0 +1,9 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAdminToken, getAdminToken } from '@/lib/auth/tokens';
+
+export async function GET(request: NextRequest) {
+ if (!verifyAdminToken(request)) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+ return NextResponse.json({ token: getAdminToken() });
+}
diff --git a/app/api/public/items/[id]/claim/route.ts b/app/api/public/items/[id]/claim/route.ts
index bbb9b34..6706d8e 100644
--- a/app/api/public/items/[id]/claim/route.ts
+++ b/app/api/public/items/[id]/claim/route.ts
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq, ne, sql } from 'drizzle-orm';
-import { db, wishlistItems, wishlists, itemClaims, guests } from '@/lib/db';
+import { db, wishlistItems, wishlists, itemClaims, guests, settings } from '@/lib/db';
import { getGuestFromRequest } from '@/lib/auth/tokens';
export async function POST(
@@ -26,6 +26,11 @@ export async function POST(
if (wl.length === 0) return NextResponse.json({ error: 'Wishlist not found' }, { status: 404 });
if (!wl[0].isPublic) return NextResponse.json({ error: 'This wishlist is private' }, { status: 403 });
+ const claimingSetting = await db.select().from(settings).where(eq(settings.key, 'claimingEnabled')).limit(1);
+ if (claimingSetting[0]?.value === 'false') {
+ return NextResponse.json({ error: 'Reservas desativadas' }, { status: 403 });
+ }
+
// Atomically check remaining quantity and upsert the claim inside a synchronous
// transaction to prevent race conditions (better-sqlite3 is synchronous).
type TxResult = { ok: true } | { ok: false; remaining: number };
diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts
index 770c4d8..14e6db2 100644
--- a/app/api/settings/route.ts
+++ b/app/api/settings/route.ts
@@ -13,18 +13,18 @@ export async function GET() {
return acc;
}, {} as Record
);
- if (!settingsObj.siteTitle) {
- settingsObj.siteTitle = 'Wishlist';
- }
- if (!settingsObj.homepageSubtext) {
- settingsObj.homepageSubtext = 'Browse and explore available wishlists';
- }
+ if (!settingsObj.siteTitle) settingsObj.siteTitle = 'Wishlist';
+ if (!settingsObj.homepageSubtext) settingsObj.homepageSubtext = 'Browse and explore available wishlists';
+ if (settingsObj.claimingEnabled === undefined) settingsObj.claimingEnabled = 'true';
+ if (settingsObj.showQuantity === undefined) settingsObj.showQuantity = 'true';
return NextResponse.json({
success: true,
settings: {
siteTitle: settingsObj.siteTitle,
homepageSubtext: settingsObj.homepageSubtext,
+ claimingEnabled: settingsObj.claimingEnabled !== 'false',
+ showQuantity: settingsObj.showQuantity !== 'false',
},
});
} catch (error) {
@@ -44,7 +44,7 @@ export async function PUT(request: NextRequest) {
}
const body = await request.json();
- const { siteTitle, homepageSubtext } = body;
+ const { siteTitle, homepageSubtext, claimingEnabled, showQuantity } = body;
if (siteTitle !== undefined) {
const existing = await db
@@ -86,6 +86,18 @@ export async function PUT(request: NextRequest) {
}
}
+ for (const [key, val] of [['claimingEnabled', claimingEnabled], ['showQuantity', showQuantity]] as const) {
+ if (val !== undefined) {
+ const strVal = val ? 'true' : 'false';
+ const existing = await db.select().from(settings).where(eq(settings.key, key)).limit(1);
+ if (existing.length > 0) {
+ await db.update(settings).set({ value: strVal, updatedAt: new Date() }).where(eq(settings.key, key));
+ } else {
+ await db.insert(settings).values({ key, value: strVal });
+ }
+ }
+ }
+
return NextResponse.json({
success: true,
message: 'Settings updated successfully',
diff --git a/components/header.tsx b/components/header.tsx
index def2b3d..110069d 100644
--- a/components/header.tsx
+++ b/components/header.tsx
@@ -40,7 +40,7 @@ export default function Header({ title, subtitle, imageUrl, actions, maxWidth =
{title}
{subtitle && (
-
+
{subtitle}
)}
diff --git a/lib/api.ts b/lib/api.ts
index c7cf78b..4d4aba5 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -354,6 +354,8 @@ export const scrapingApi = {
export interface Settings {
siteTitle: string;
homepageSubtext: string;
+ claimingEnabled: boolean;
+ showQuantity: boolean;
}
export const settingsApi = {