Compare commits
3 Commits
5548f5000f
...
eebb183d36
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eebb183d36 | ||
|
|
a0349aa9c4 | ||
|
|
b19a3fdf48 |
60
.planning/debug/resolved/items-reorder-async-transaction.md
Normal file
60
.planning/debug/resolved/items-reorder-async-transaction.md
Normal file
@@ -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
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import DOMPurify from 'dompurify';
|
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 Header from '@/components/header';
|
||||||
import GuestGuard from '@/components/guest-guard';
|
import GuestGuard from '@/components/guest-guard';
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ function PublicWishlistContent() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [wishlist, setWishlist] = useState<Wishlist | null>(null);
|
const [wishlist, setWishlist] = useState<Wishlist | null>(null);
|
||||||
const [items, setItems] = useState<Item[]>([]);
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
|
const [siteSettings, setSiteSettings] = useState<Settings>({ siteTitle: '', homepageSubtext: '', claimingEnabled: true, showQuantity: true });
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ function PublicWishlistContent() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
settingsApi.getSettings().then(setSiteSettings).catch(() => {});
|
||||||
fetchWishlist();
|
fetchWishlist();
|
||||||
}, [params.slug]);
|
}, [params.slug]);
|
||||||
|
|
||||||
@@ -192,54 +194,54 @@ function PublicWishlistContent() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{filteredItems.map((item) => {
|
{filteredItems.map((item) => {
|
||||||
const myClaim = myClaimFor(item);
|
const myClaim = myClaimFor(item);
|
||||||
const maxForMe = item.remainingQuantity + (myClaim?.quantity ?? 0);
|
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 sold = item.remainingQuantity === 0 && !myClaim;
|
||||||
|
const canClaim = siteSettings.claimingEnabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-all duration-300 overflow-hidden"
|
className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-all duration-300 overflow-hidden flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col md:flex-row">
|
{/* Top: Image */}
|
||||||
{/* Left: Image */}
|
|
||||||
{item.imageUrl && (
|
{item.imageUrl && (
|
||||||
<div className="md:w-48 md:flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
src={item.imageUrl}
|
src={item.imageUrl}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="w-full h-48 md:h-full object-cover"
|
className="w-full h-48 object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Middle: Item Details */}
|
{/* Middle: Item Details */}
|
||||||
<div className="flex-1 p-6">
|
<div className="flex-1 p-5">
|
||||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-1">
|
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-1">
|
||||||
{item.name}
|
{item.name}
|
||||||
</h3>
|
</h3>
|
||||||
{item.price != null && (
|
{item.price != null && (
|
||||||
<p className="text-lg font-semibold text-indigo-600 dark:text-indigo-400 mb-1">
|
<p className="text-base font-semibold text-indigo-600 dark:text-indigo-400 mb-1">
|
||||||
{new Intl.NumberFormat('pt-BR', { style: 'currency', currency: item.currency || 'BRL' }).format(item.price)}
|
{new Intl.NumberFormat('pt-BR', { style: 'currency', currency: item.currency || 'BRL' }).format(item.price)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{showQuantitySummary && (
|
{showQuantitySummary && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||||
{item.claimedQuantity} de {item.quantity} reservados
|
{item.claimedQuantity} de {item.quantity} reservados
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{item.description && (
|
{item.description && (
|
||||||
<p className="text-base text-gray-600 dark:text-gray-300 mb-4">
|
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Existing claims list */}
|
{/* Existing claims list */}
|
||||||
{item.claims.length > 0 && (
|
{item.claims.length > 0 && (
|
||||||
<ul className="text-sm text-gray-700 dark:text-gray-300 space-y-1 mt-4 border-t border-gray-200 dark:border-gray-700 pt-3">
|
<ul className="text-sm text-gray-700 dark:text-gray-300 space-y-1 mt-3 border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||||
{item.claims.map((c) => {
|
{item.claims.map((c) => {
|
||||||
const isMine = c.guest.id === currentGuestId;
|
const isMine = c.guest.id === currentGuestId;
|
||||||
const canCancel = isMine || isAdmin;
|
const canCancel = isMine || isAdmin;
|
||||||
@@ -272,9 +274,8 @@ function PublicWishlistContent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Action Area */}
|
{/* Bottom: Action Area */}
|
||||||
<div className="md:w-80 md:flex-shrink-0 p-6 bg-gray-50 dark:bg-gray-900/50 border-t md:border-t-0 md:border-l border-gray-200 dark:border-gray-700 flex flex-col">
|
<div className="p-5 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex flex-col gap-3">
|
||||||
<div className="mb-4">
|
|
||||||
{item.purchaseUrls && item.purchaseUrls.length > 0 && (
|
{item.purchaseUrls && item.purchaseUrls.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{item.purchaseUrls.map((url, idx) => (
|
{item.purchaseUrls.map((url, idx) => (
|
||||||
@@ -283,7 +284,7 @@ function PublicWishlistContent() {
|
|||||||
href={url.url}
|
href={url.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center justify-between text-base px-4 py-3 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors cursor-pointer border border-gray-200 dark:border-gray-700"
|
className="flex items-center justify-between text-sm px-3 py-2 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors cursor-pointer border border-gray-200 dark:border-gray-700"
|
||||||
>
|
>
|
||||||
<span className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-medium">
|
<span className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-medium">
|
||||||
{url.label}
|
{url.label}
|
||||||
@@ -292,10 +293,8 @@ function PublicWishlistContent() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto">
|
{!canClaim ? null : sold ? (
|
||||||
{sold ? (
|
|
||||||
<div className="bg-gray-100 dark:bg-gray-700 rounded p-3 text-center text-sm text-gray-700 dark:text-gray-300">
|
<div className="bg-gray-100 dark:bg-gray-700 rounded p-3 text-center text-sm text-gray-700 dark:text-gray-300">
|
||||||
Esgotado
|
Esgotado
|
||||||
</div>
|
</div>
|
||||||
@@ -307,7 +306,7 @@ function PublicWishlistContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.quantity > 1 && (
|
{item.quantity > 1 && siteSettings.showQuantity && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={`claim-qty-${item.id}`} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label htmlFor={`claim-qty-${item.id}`} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Quantidade (máx {maxForMe}):
|
Quantidade (máx {maxForMe}):
|
||||||
@@ -362,8 +361,6 @@ function PublicWishlistContent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ function AdminPageContent() {
|
|||||||
const [settings, setSettings] = useState<Settings>({
|
const [settings, setSettings] = useState<Settings>({
|
||||||
siteTitle: 'Wishlist',
|
siteTitle: 'Wishlist',
|
||||||
homepageSubtext: '',
|
homepageSubtext: '',
|
||||||
|
claimingEnabled: true,
|
||||||
|
showQuantity: true,
|
||||||
});
|
});
|
||||||
|
const [adminToken, setAdminToken] = useState<string | null>(null);
|
||||||
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
|
|
||||||
// Unified config edit state
|
// Unified config edit state
|
||||||
const [isEditingWishlist, setIsEditingWishlist] = useState(false);
|
const [isEditingWishlist, setIsEditingWishlist] = useState(false);
|
||||||
@@ -52,6 +56,8 @@ function AdminPageContent() {
|
|||||||
isPublic: true,
|
isPublic: true,
|
||||||
siteTitle: '',
|
siteTitle: '',
|
||||||
homepageSubtext: '',
|
homepageSubtext: '',
|
||||||
|
claimingEnabled: true,
|
||||||
|
showQuantity: true,
|
||||||
});
|
});
|
||||||
const [editError, setEditError] = useState('');
|
const [editError, setEditError] = useState('');
|
||||||
const [isWishlistImageUploading, setIsWishlistImageUploading] = useState(false);
|
const [isWishlistImageUploading, setIsWishlistImageUploading] = useState(false);
|
||||||
@@ -68,8 +74,27 @@ function AdminPageContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
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 () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [lists, settingsData] = await Promise.all([
|
const [lists, settingsData] = await Promise.all([
|
||||||
@@ -108,6 +133,8 @@ function AdminPageContent() {
|
|||||||
isPublic: wishlist.isPublic,
|
isPublic: wishlist.isPublic,
|
||||||
siteTitle: settings.siteTitle,
|
siteTitle: settings.siteTitle,
|
||||||
homepageSubtext: settings.homepageSubtext,
|
homepageSubtext: settings.homepageSubtext,
|
||||||
|
claimingEnabled: settings.claimingEnabled,
|
||||||
|
showQuantity: settings.showQuantity,
|
||||||
});
|
});
|
||||||
setEditError('');
|
setEditError('');
|
||||||
setIsEditingWishlist(true);
|
setIsEditingWishlist(true);
|
||||||
@@ -118,13 +145,13 @@ function AdminPageContent() {
|
|||||||
if (!wishlist) return;
|
if (!wishlist) return;
|
||||||
setEditError('');
|
setEditError('');
|
||||||
try {
|
try {
|
||||||
const { siteTitle, homepageSubtext, ...wishlistFields } = editForm;
|
const { siteTitle, homepageSubtext, claimingEnabled, showQuantity, ...wishlistFields } = editForm;
|
||||||
const [updated] = await Promise.all([
|
const [updated] = await Promise.all([
|
||||||
wishlistsApi.update(wishlist.id, wishlistFields),
|
wishlistsApi.update(wishlist.id, wishlistFields),
|
||||||
settingsApi.updateSettings({ siteTitle, homepageSubtext }),
|
settingsApi.updateSettings({ siteTitle, homepageSubtext, claimingEnabled, showQuantity }),
|
||||||
]);
|
]);
|
||||||
setWishlist(updated);
|
setWishlist(updated);
|
||||||
setSettings({ siteTitle, homepageSubtext });
|
setSettings({ siteTitle, homepageSubtext, claimingEnabled, showQuantity });
|
||||||
setIsEditingWishlist(false);
|
setIsEditingWishlist(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setEditError(error.message || 'Failed to update settings');
|
setEditError(error.message || 'Failed to update settings');
|
||||||
@@ -292,6 +319,26 @@ function AdminPageContent() {
|
|||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="w-4 h-4 rounded accent-indigo-600"
|
||||||
|
checked={editForm.claimingEnabled}
|
||||||
|
onChange={(e) => setEditForm((prev) => ({ ...prev, claimingEnabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Permitir reservas</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="w-4 h-4 rounded accent-indigo-600"
|
||||||
|
checked={editForm.showQuantity}
|
||||||
|
onChange={(e) => setEditForm((prev) => ({ ...prev, showQuantity: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Mostrar quantidade</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Descrição da lista
|
Descrição da lista
|
||||||
@@ -370,6 +417,30 @@ function AdminPageContent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-4 mt-1">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${settings.claimingEnabled ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'}`}>
|
||||||
|
Reservas {settings.claimingEnabled ? 'ativas' : 'desativadas'}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${settings.showQuantity ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400'}`}>
|
||||||
|
Quantidade {settings.showQuantity ? 'visível' : 'oculta'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{adminToken && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Link de acesso admin</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-3 py-2 rounded truncate">
|
||||||
|
{`${typeof window !== 'undefined' ? window.location.origin : ''}/?adm=${adminToken}`}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={copyAdminLink}
|
||||||
|
className="flex-shrink-0 px-3 py-2 text-xs font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{linkCopied ? 'Copiado!' : 'Copiar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
9
app/api/admin/access-link/route.ts
Normal file
9
app/api/admin/access-link/route.ts
Normal file
@@ -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() });
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { and, eq, ne, sql } from 'drizzle-orm';
|
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';
|
import { getGuestFromRequest } from '@/lib/auth/tokens';
|
||||||
|
|
||||||
export async function POST(
|
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.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 });
|
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
|
// Atomically check remaining quantity and upsert the claim inside a synchronous
|
||||||
// transaction to prevent race conditions (better-sqlite3 is synchronous).
|
// transaction to prevent race conditions (better-sqlite3 is synchronous).
|
||||||
type TxResult = { ok: true } | { ok: false; remaining: number };
|
type TxResult = { ok: true } | { ok: false; remaining: number };
|
||||||
|
|||||||
@@ -13,18 +13,18 @@ export async function GET() {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, string>);
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
if (!settingsObj.siteTitle) {
|
if (!settingsObj.siteTitle) settingsObj.siteTitle = 'Wishlist';
|
||||||
settingsObj.siteTitle = 'Wishlist';
|
if (!settingsObj.homepageSubtext) settingsObj.homepageSubtext = 'Browse and explore available wishlists';
|
||||||
}
|
if (settingsObj.claimingEnabled === undefined) settingsObj.claimingEnabled = 'true';
|
||||||
if (!settingsObj.homepageSubtext) {
|
if (settingsObj.showQuantity === undefined) settingsObj.showQuantity = 'true';
|
||||||
settingsObj.homepageSubtext = 'Browse and explore available wishlists';
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
settings: {
|
settings: {
|
||||||
siteTitle: settingsObj.siteTitle,
|
siteTitle: settingsObj.siteTitle,
|
||||||
homepageSubtext: settingsObj.homepageSubtext,
|
homepageSubtext: settingsObj.homepageSubtext,
|
||||||
|
claimingEnabled: settingsObj.claimingEnabled !== 'false',
|
||||||
|
showQuantity: settingsObj.showQuantity !== 'false',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -44,7 +44,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { siteTitle, homepageSubtext } = body;
|
const { siteTitle, homepageSubtext, claimingEnabled, showQuantity } = body;
|
||||||
|
|
||||||
if (siteTitle !== undefined) {
|
if (siteTitle !== undefined) {
|
||||||
const existing = await db
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Settings updated successfully',
|
message: 'Settings updated successfully',
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function Header({ title, subtitle, imageUrl, actions, maxWidth =
|
|||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className={`text-xl sm:text-2xl text-[color:var(--ink-soft)] mb-6 ${imageUrl ? '' : 'max-w-3xl mx-auto'}`}>
|
<p className={`text-xl sm:text-2xl text-[color:var(--ink-soft)] mb-6 whitespace-pre-line ${imageUrl ? '' : 'max-w-3xl mx-auto'}`}>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Binary file not shown.
80
docs/skill-item-images.md
Normal file
80
docs/skill-item-images.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
name: item-images
|
||||||
|
description: Use when finding and updating product images for items on the Chá do Martin wishlist. Triggers on "find image", "add image", "update image", or when an item is missing its imageUrl.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Item Images — Chá do Martin
|
||||||
|
|
||||||
|
Finds a product image for a wishlist item via web search and patches it to the API.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
- **Base URL**: `https://chadomartin.omeu.website`
|
||||||
|
- **Admin token**: read from `/home/adriano/chadebebe/.env` (key `ADMIN_TOKEN`)
|
||||||
|
- **Auth**: cookie `adm_token=<token>` sent with every request
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
1. Load the admin token:
|
||||||
|
```bash
|
||||||
|
ADMIN_TOKEN=$(grep ADMIN_TOKEN /home/adriano/chadebebe/.env | cut -d= -f2)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Search the web for a clean product image. Good queries:
|
||||||
|
- `"<item name> bebê" site:amazon.com.br`
|
||||||
|
- `"<item name> newborn product photo"`
|
||||||
|
- Try mitiendanube stores, newbiestore.com, or brand sites (burtsbeesbaby.com, halosleep.com, honestbabyclothing.com)
|
||||||
|
|
||||||
|
3. Find a direct image URL ending in `.jpg`, `.png`, or `.webp`.
|
||||||
|
|
||||||
|
4. Verify the URL returns an actual image:
|
||||||
|
```bash
|
||||||
|
curl -sI "<IMAGE_URL>" | grep -i "content-type\|http/"
|
||||||
|
# Must be HTTP 200 and content-type: image/*
|
||||||
|
```
|
||||||
|
|
||||||
|
5. PATCH the item:
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH \
|
||||||
|
"https://chadomartin.omeu.website/api/items/<ITEM_ID>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Cookie: adm_token=${ADMIN_TOKEN}" \
|
||||||
|
-d '{"imageUrl": "<IMAGE_URL>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Check response for `"success": true`.
|
||||||
|
|
||||||
|
## Listing items without images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ADMIN_TOKEN=$(grep ADMIN_TOKEN /home/adriano/chadebebe/.env | cut -d= -f2)
|
||||||
|
curl -s "https://chadomartin.omeu.website/api/wishlists/r7ug0ez62x7ljx8c870ofwcv/items" \
|
||||||
|
-H "Cookie: adm_token=${ADMIN_TOKEN}" | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
for it in sorted(data['items'], key=lambda x: x['sortOrder']):
|
||||||
|
if not it['imageUrl']:
|
||||||
|
print(it['id'], it['name'])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Committing the DB snapshot after updates
|
||||||
|
|
||||||
|
Images are stored in the live SQLite DB (`/home/adriano/chadebebe/data/db/wishlist.db`).
|
||||||
|
After a batch of image updates, commit a snapshot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 /home/adriano/chadebebe/data/db/wishlist.db "PRAGMA wal_checkpoint(FULL);"
|
||||||
|
cp /home/adriano/chadebebe/data/db/wishlist.db /home/adriano/chadebebe/src/data/db/wishlist.db
|
||||||
|
cd /home/adriano/chadebebe/src
|
||||||
|
git add data/db/wishlist.db
|
||||||
|
git commit -m "chore: db snapshot with updated item images"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Do NOT use placeholder or stock photo URLs — find actual product images
|
||||||
|
- Prefer `.webp` or `.jpg` from CDNs (faster loading)
|
||||||
|
- Good CDN sources: `mitiendanube.com`, `newbiestore.com`, `burtsbeesbaby.com`, `halosleep.com`, `honestbabyclothing.com`
|
||||||
|
- Always verify HTTP 200 before patching — dead URLs show broken images to guests
|
||||||
|
- Run multiple items in parallel (background subagents, model: haiku) for speed
|
||||||
@@ -354,6 +354,8 @@ export const scrapingApi = {
|
|||||||
export interface Settings {
|
export interface Settings {
|
||||||
siteTitle: string;
|
siteTitle: string;
|
||||||
homepageSubtext: string;
|
homepageSubtext: string;
|
||||||
|
claimingEnabled: boolean;
|
||||||
|
showQuantity: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
|
|||||||
Reference in New Issue
Block a user