Compare commits

..

11 Commits

Author SHA1 Message Date
Adriano Belisario
a28a3a489d feat: add animated Mars planet decoration to header
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:24:39 +00:00
Adriano Belisario
653121921e chore: db snapshot with product-only images
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-04 00:09:50 +00:00
Adriano Belisario
78bef85c96 feat: allow anonymous public wishlist viewing 2026-05-04 00:09:43 +00:00
Adriano Belisario
6c8e11c851 chore: db snapshot with updated item images
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-03 23:14:23 +00:00
Adriano Belisario
7ef1065971 fix: hide item count when reservations are disabled 2026-05-03 23:14:17 +00:00
Adriano Belisario
9f6a7c15d9 docs: add repository contributor guide 2026-05-03 23:14:11 +00:00
Adriano Belisario
eebb183d36 Hide disabled reservations button
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-03 23:00:43 +00:00
Adriano Belisario
a0349aa9c4 chore: db snapshot with item images (batch 1) + image skill doc
Images applied to 9 items (Macacões RN, Bodies RN, Luvas, Meias, Sapatinhos malha,
Bodies ML 1-3m, Bodies MC 1-3m, Calças 1-3m, Macacões 1-3m, Swaddle).
Adds docs/skill-item-images.md with search/patch workflow and DB snapshot steps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 22:58:06 +00:00
Adriano Belisario
b19a3fdf48 feat: grid layout, global claim/qty toggles, admin access link, swaddle image
- 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 <noreply@anthropic.com>
2026-05-03 22:52:32 +00:00
Adriano Belisario
5548f5000f feat(ui): show price in public view and admin item card; fix quantity badge in claims list
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
- Public wishlist page now displays item price (formatted as BRL) below the
  item name when a price is set
- Admin ItemCard shows price and quantity badge so items can be scanned at a
  glance without opening the edit form
- Claims list no longer shows "· 1 un." for single-unit items (the unit count
  is only displayed when item.quantity > 1)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:13:45 +00:00
Adriano Belisario
3df9a67b97 feat(admin): merge Site Settings and Wishlist into single Configurações panel
Single-wishlist apps no longer need two separate edit sections.
Both siteTitle/homepageSubtext and wishlist fields now save together
in one action from a unified Configurações card.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:10:58 +00:00
20 changed files with 849 additions and 267 deletions

View 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

34
AGENTS.md Normal file
View File

@@ -0,0 +1,34 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository contains the Next.js application for the Chadebebe wishlist site. App Router routes live in `app/`, including public wishlist pages in `app/[slug]/`, admin screens in `app/admin/`, and API handlers in `app/api/`. Shared UI belongs in `components/`, while server utilities, auth, database access, and scraping helpers live in `lib/`. Drizzle and SQLite data are under `lib/db/` and `data/db/`. Static assets are in `public/`; uploaded runtime assets are stored in `data/uploads/`. Deployment configuration is in the parent project root at `/home/adriano/chadebebe/docker-compose.yaml`, which builds this `src/` directory.
## Build, Test, and Development Commands
Run commands from `src/` unless noted.
- `npm install`: install local dependencies.
- `npm run dev`: start Next.js locally on port `3000`.
- `npm run build`: create the production Next.js standalone build.
- `npm run lint`: run the configured Next.js ESLint command.
- `npm run db:migrate`: apply Drizzle migrations.
- `npm run db:studio`: open Drizzle Studio for database inspection.
- `npm run guest:create -- --name="Name"`: create a guest access token.
- From `/home/adriano/chadebebe`, `docker compose up -d --build`: rebuild and redeploy the production container.
## Coding Style & Naming Conventions
Use TypeScript, React function components, and the App Router conventions already present in `app/`. Keep indentation at two spaces. Prefer descriptive camelCase for variables/functions, PascalCase for components, and lowercase route directory names. Keep UI copy in Portuguese for user-facing screens. Use Tailwind CSS utility classes for styling and follow existing component patterns before introducing new abstractions.
## Testing Guidelines
There is currently no dedicated test framework or `npm test` script. For changes, run `npm run build` at minimum; use `npm run lint` when dependencies are available. For database or reservation changes, manually exercise the relevant public wishlist and admin flows, including quantity limits, claim/unclaim behavior, and guest-token access.
## Commit & Pull Request Guidelines
Recent history mostly uses Conventional Commit prefixes, such as `feat:`, `fix:`, `docs:`, and `chore:`. Use concise imperative messages, for example `fix: hide disabled reservation action`. Pull requests should include a short summary, verification steps, linked issue if applicable, and screenshots for UI changes. Note any database snapshot or migration changes explicitly.
## Security & Configuration Tips
Do not commit real admin tokens or secrets. Admin access is controlled by `ADMIN_TOKEN`; production deployment also depends on Traefik labels and the external `web` Docker network. Treat `data/db/wishlist.db` as a snapshot and checkpoint SQLite before committing database updates.

View File

@@ -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]);
@@ -178,11 +180,13 @@ function PublicWishlistContent() {
)} )}
{/* Controls */} {/* Controls */}
<div className="mb-6 flex items-center justify-end"> {siteSettings.claimingEnabled && (
<div className="text-sm text-gray-600 dark:text-gray-400"> <div className="mb-6 flex items-center justify-end">
{filteredItems.length} de {items.length} itens <div className="text-sm text-gray-600 dark:text-gray-400">
{filteredItems.length} de {items.length} itens
</div>
</div> </div>
</div> )}
{/* Items List */} {/* Items List */}
{filteredItems.length === 0 ? ( {filteredItems.length === 0 ? (
@@ -192,169 +196,171 @@ 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 && Boolean(currentGuestId);
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="flex-shrink-0">
<div className="md:w-48 md:flex-shrink-0"> <img
<img src={item.imageUrl}
src={item.imageUrl} alt={item.name}
alt={item.name} className="w-full h-48 object-cover"
className="w-full h-48 md:h-full object-cover" />
/> </div>
)}
{/* Middle: Item Details */}
<div className="flex-1 p-5">
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-1">
{item.name}
</h3>
{item.price != null && (
<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)}
</p>
)}
{showQuantitySummary && (
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
{item.claimedQuantity} de {item.quantity} reservados
</p>
)}
{item.description && (
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
{item.description}
</p>
)}
{/* Existing claims list */}
{item.claims.length > 0 && (
<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) => {
const isMine = c.guest.id === currentGuestId;
const canCancel = isMine || isAdmin;
return (
<li key={c.id} className="flex items-center justify-between gap-2">
<div>
<span className="font-medium">{isMine ? 'Você' : 'Reservado'}</span>
{item.quantity > 1 && (
<span className="text-gray-500"> · {c.quantity} un.</span>
)}
{c.note && isMine && (
<span className="block text-xs italic text-gray-500">
&quot;{c.note}&quot;
</span>
)}
</div>
{canCancel && (
<button
onClick={() => handleUnclaim(item.id, isMine ? undefined : c.guest.id)}
disabled={isUnclaiming}
className="text-xs text-red-600 hover:underline disabled:opacity-50 cursor-pointer"
>
Cancelar
</button>
)}
</li>
);
})}
</ul>
)}
</div>
{/* Bottom: Action Area */}
<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">
{item.purchaseUrls && item.purchaseUrls.length > 0 && (
<div className="space-y-2">
{item.purchaseUrls.map((url, idx) => (
<a
key={idx}
href={url.url}
target="_blank"
rel="noopener noreferrer"
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">
{url.label}
</span>
</a>
))}
</div> </div>
)} )}
{/* Middle: Item Details */} {!canClaim ? null : sold ? (
<div className="flex-1 p-6"> <div className="bg-gray-100 dark:bg-gray-700 rounded p-3 text-center text-sm text-gray-700 dark:text-gray-300">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-1"> Esgotado
{item.name} </div>
</h3> ) : claimingItemId === item.id ? (
{showQuantitySummary && ( <form onSubmit={(e) => handleSubmitClaim(e, item.id)} className="space-y-3">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3"> {claimError && (
{item.claimedQuantity} de {item.quantity} reservados <div className="p-2 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded text-xs">
</p> {claimError}
)}
{item.description && (
<p className="text-base text-gray-600 dark:text-gray-300 mb-4">
{item.description}
</p>
)}
{/* Existing claims list */}
{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">
{item.claims.map((c) => {
const isMine = c.guest.id === currentGuestId;
const canCancel = isMine || isAdmin;
return (
<li key={c.id} className="flex items-center justify-between gap-2">
<div>
<span className="font-medium">{isMine ? 'Você' : 'Reservado'}</span>
<span className="text-gray-500"> · {c.quantity} un.</span>
{c.note && isMine && (
<span className="block text-xs italic text-gray-500">
&quot;{c.note}&quot;
</span>
)}
</div>
{canCancel && (
<button
onClick={() => handleUnclaim(item.id, isMine ? undefined : c.guest.id)}
disabled={isUnclaiming}
className="text-xs text-red-600 hover:underline disabled:opacity-50 cursor-pointer"
>
Cancelar
</button>
)}
</li>
);
})}
</ul>
)}
</div>
{/* Right: 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="mb-4">
{item.purchaseUrls && item.purchaseUrls.length > 0 && (
<div className="space-y-2">
{item.purchaseUrls.map((url, idx) => (
<a
key={idx}
href={url.url}
target="_blank"
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"
>
<span className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-medium">
{url.label}
</span>
</a>
))}
</div> </div>
)} )}
</div>
<div className="mt-auto"> {item.quantity > 1 && siteSettings.showQuantity && (
{sold ? ( <div>
<div className="bg-gray-100 dark:bg-gray-700 rounded p-3 text-center text-sm text-gray-700 dark:text-gray-300"> <label htmlFor={`claim-qty-${item.id}`} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Esgotado Quantidade (máx {maxForMe}):
</label>
<input
id={`claim-qty-${item.id}`}
type="number"
min={1}
max={maxForMe}
value={claimQty}
onChange={(e) => 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"
/>
</div> </div>
) : claimingItemId === item.id ? (
<form onSubmit={(e) => handleSubmitClaim(e, item.id)} className="space-y-3">
{claimError && (
<div className="p-2 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded text-xs">
{claimError}
</div>
)}
{item.quantity > 1 && (
<div>
<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}):
</label>
<input
id={`claim-qty-${item.id}`}
type="number"
min={1}
max={maxForMe}
value={claimQty}
onChange={(e) => 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"
/>
</div>
)}
<div>
<label htmlFor={`claim-note-${item.id}`} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Deixe uma nota (opcional):
</label>
<textarea
id={`claim-note-${item.id}`}
rows={3}
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 resize-none"
value={claimNote}
onChange={(e) => setClaimNote(e.target.value)}
/>
</div>
<button
type="submit"
disabled={isClaiming}
className="w-full px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 font-medium disabled:opacity-50 transition-colors cursor-pointer"
>
{isClaiming ? 'Reservando...' : (myClaim ? 'Atualizar reserva' : 'Confirmar reserva')}
</button>
<button
type="button"
onClick={() => setClaimingItemId(null)}
className="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
>
Cancelar
</button>
</form>
) : (
<button
onClick={() => handleStartClaim(item)}
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 font-medium transition-colors cursor-pointer"
>
{myClaim ? 'Atualizar reserva' : 'Reservar'}
</button>
)} )}
</div>
</div> <div>
<label htmlFor={`claim-note-${item.id}`} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Deixe uma nota (opcional):
</label>
<textarea
id={`claim-note-${item.id}`}
rows={3}
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 resize-none"
value={claimNote}
onChange={(e) => setClaimNote(e.target.value)}
/>
</div>
<button
type="submit"
disabled={isClaiming}
className="w-full px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 font-medium disabled:opacity-50 transition-colors cursor-pointer"
>
{isClaiming ? 'Reservando...' : (myClaim ? 'Atualizar reserva' : 'Confirmar reserva')}
</button>
<button
type="button"
onClick={() => setClaimingItemId(null)}
className="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
>
Cancelar
</button>
</form>
) : (
<button
onClick={() => handleStartClaim(item)}
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 font-medium transition-colors cursor-pointer"
>
{myClaim ? 'Atualizar reserva' : 'Reservar'}
</button>
)}
</div> </div>
</div> </div>
); );

View File

@@ -14,7 +14,6 @@ import {
} from '@/lib/api'; } from '@/lib/api';
import Header from '@/components/header'; import Header from '@/components/header';
import Link from 'next/link'; import Link from 'next/link';
import SettingsSection from '@/components/admin/SettingsSection';
import ItemCard from '@/components/admin/ItemCard'; import ItemCard from '@/components/admin/ItemCard';
import ItemForm from '@/components/admin/ItemForm'; import ItemForm from '@/components/admin/ItemForm';
import ImageUpload from '@/components/image-upload'; import ImageUpload from '@/components/image-upload';
@@ -40,9 +39,13 @@ 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);
// Wishlist edit state // Unified config edit state
const [isEditingWishlist, setIsEditingWishlist] = useState(false); const [isEditingWishlist, setIsEditingWishlist] = useState(false);
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({
name: '', name: '',
@@ -51,6 +54,10 @@ function AdminPageContent() {
preferences: '', preferences: '',
imageUrl: '', imageUrl: '',
isPublic: true, isPublic: true,
siteTitle: '',
homepageSubtext: '',
claimingEnabled: true,
showQuantity: true,
}); });
const [editError, setEditError] = useState(''); const [editError, setEditError] = useState('');
const [isWishlistImageUploading, setIsWishlistImageUploading] = useState(false); const [isWishlistImageUploading, setIsWishlistImageUploading] = useState(false);
@@ -67,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([
@@ -105,6 +131,10 @@ function AdminPageContent() {
preferences: wishlist.preferences || '', preferences: wishlist.preferences || '',
imageUrl: wishlist.imageUrl || '', imageUrl: wishlist.imageUrl || '',
isPublic: wishlist.isPublic, isPublic: wishlist.isPublic,
siteTitle: settings.siteTitle,
homepageSubtext: settings.homepageSubtext,
claimingEnabled: settings.claimingEnabled,
showQuantity: settings.showQuantity,
}); });
setEditError(''); setEditError('');
setIsEditingWishlist(true); setIsEditingWishlist(true);
@@ -115,11 +145,16 @@ function AdminPageContent() {
if (!wishlist) return; if (!wishlist) return;
setEditError(''); setEditError('');
try { try {
const updated = await wishlistsApi.update(wishlist.id, editForm); const { siteTitle, homepageSubtext, claimingEnabled, showQuantity, ...wishlistFields } = editForm;
const [updated] = await Promise.all([
wishlistsApi.update(wishlist.id, wishlistFields),
settingsApi.updateSettings({ siteTitle, homepageSubtext, claimingEnabled, showQuantity }),
]);
setWishlist(updated); setWishlist(updated);
setSettings({ siteTitle, homepageSubtext, claimingEnabled, showQuantity });
setIsEditingWishlist(false); setIsEditingWishlist(false);
} catch (error: any) { } catch (error: any) {
setEditError(error.message || 'Failed to update wishlist'); setEditError(error.message || 'Failed to update settings');
} }
}; };
@@ -181,10 +216,6 @@ function AdminPageContent() {
} }
}; };
const handleUpdateSettings = async (updatedSettings: Settings) => {
await settingsApi.updateSettings(updatedSettings);
setSettings(updatedSettings);
};
const editingItem = items.find((item) => item.id === editingItemId); const editingItem = items.find((item) => item.id === editingItemId);
@@ -229,55 +260,92 @@ function AdminPageContent() {
</div> </div>
) : ( ) : (
<> <>
{/* Settings Section */} {/* Unified Settings & Wishlist Config */}
<SettingsSection settings={settings} onUpdate={handleUpdateSettings} />
{/* Wishlist Info Section */}
{wishlist && ( {wishlist && (
<div className="mb-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"> <div className="mb-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-5 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Configurações</h2>
{!isEditingWishlist && (
<button
onClick={startEditingWishlist}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm cursor-pointer"
>
Editar
</button>
)}
</div>
<div className="p-5"> <div className="p-5">
{isEditingWishlist ? ( {isEditingWishlist ? (
<form onSubmit={handleUpdateWishlist} className="space-y-4"> <form onSubmit={handleUpdateWishlist} className="space-y-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Editar Lista
</h2>
{editError && ( {editError && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded-lg text-base"> <div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded-lg text-base">
{editError} {editError}
</div> </div>
)} )}
<ImageUpload <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
currentImageUrl={editForm.imageUrl} <div>
onImageChange={(url) => <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
setEditForm((prev) => ({ ...prev, imageUrl: url })) Título do site *
} </label>
onUploadStateChange={setIsWishlistImageUploading} <input
type="wishlist" type="text"
label="Imagem da Lista" required
/> value={editForm.siteTitle}
<div> onChange={(e) => setEditForm((prev) => ({ ...prev, siteTitle: e.target.value }))}
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
Nome * />
</label> </div>
<input <div>
type="text" <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
required Nome da lista *
value={editForm.name} </label>
onChange={(e) => <input
setEditForm((prev) => ({ ...prev, name: e.target.value })) type="text"
} required
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" value={editForm.name}
/> onChange={(e) => setEditForm((prev) => ({ ...prev, name: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
/>
</div>
</div> </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 Mensagem de boas-vindas
</label>
<textarea
value={editForm.homepageSubtext}
onChange={(e) => setEditForm((prev) => ({ ...prev, homepageSubtext: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
rows={2}
/>
</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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Descrição da lista
</label> </label>
<textarea <textarea
value={editForm.description} value={editForm.description}
onChange={(e) => onChange={(e) => setEditForm((prev) => ({ ...prev, description: e.target.value }))}
setEditForm((prev) => ({ ...prev, description: e.target.value }))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
rows={2} rows={2}
/> />
@@ -288,12 +356,17 @@ function AdminPageContent() {
</label> </label>
<RichTextEditor <RichTextEditor
value={editForm.preferences} value={editForm.preferences}
onChange={(html) => onChange={(html) => setEditForm((prev) => ({ ...prev, preferences: html }))}
setEditForm((prev) => ({ ...prev, preferences: html }))
}
placeholder="Interesses e preferências gerais..." placeholder="Interesses e preferências gerais..."
/> />
</div> </div>
<ImageUpload
currentImageUrl={editForm.imageUrl}
onImageChange={(url) => setEditForm((prev) => ({ ...prev, imageUrl: url }))}
onUploadStateChange={setIsWishlistImageUploading}
type="wishlist"
label="Imagem da Lista"
/>
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<button <button
type="button" type="button"
@@ -312,33 +385,62 @@ function AdminPageContent() {
</div> </div>
</form> </form>
) : ( ) : (
<div className="flex items-start gap-4"> <div className="space-y-3">
{wishlist.imageUrl && ( <div className="flex items-start gap-4">
<img {wishlist.imageUrl && (
src={wishlist.imageUrl} <img
alt={wishlist.name} src={wishlist.imageUrl}
className="w-20 h-20 object-cover rounded border border-gray-200 dark:border-gray-600 flex-shrink-0" alt={wishlist.name}
/> className="w-16 h-16 object-cover rounded border border-gray-200 dark:border-gray-600 flex-shrink-0"
)} />
<div className="flex-1">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
{wishlist.name}
</h2>
{wishlist.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{wishlist.description}
</p>
)} )}
<p className="text-sm text-gray-500 dark:text-gray-500 mt-1"> <div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2">
/{wishlist.slug} <div>
</p> <p className="text-xs font-medium text-gray-500 dark:text-gray-400">Título do site</p>
<p className="text-sm text-gray-900 dark:text-white">{settings.siteTitle}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">Nome da lista</p>
<p className="text-sm text-gray-900 dark:text-white">{wishlist.name}</p>
</div>
{settings.homepageSubtext && (
<div className="md:col-span-2">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">Mensagem de boas-vindas</p>
<p className="text-sm text-gray-900 dark:text-white">{settings.homepageSubtext}</p>
</div>
)}
{wishlist.description && (
<div className="md:col-span-2">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">Descrição</p>
<p className="text-sm text-gray-900 dark:text-white">{wishlist.description}</p>
</div>
)}
</div>
</div> </div>
<button <div className="flex gap-4 mt-1">
onClick={startEditingWishlist} <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'}`}>
className="flex-shrink-0 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm cursor-pointer" Reservas {settings.claimingEnabled ? 'ativas' : 'desativadas'}
> </span>
Editar <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'}`}>
</button> 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>

View File

@@ -8,13 +8,9 @@ export async function GET(
{ params }: { params: Promise<{ slug: string }> } { params }: { params: Promise<{ slug: string }> }
) { ) {
try { try {
const { slug } = await params;
const isAdmin = verifyAdminToken(request); const isAdmin = verifyAdminToken(request);
const guest = await getGuestFromRequest(request); const guest = await getGuestFromRequest(request);
if (!isAdmin && !guest) {
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
}
const { slug } = await params;
const wishlist = await db const wishlist = await db
.select() .select()
@@ -29,11 +25,11 @@ export async function GET(
); );
} }
// Only return public wishlists (admin can see all) // Public wishlists can be viewed anonymously; private wishlists require admin or guest access.
if (!wishlist[0].isPublic && !isAdmin) { if (!wishlist[0].isPublic && !isAdmin && !guest) {
return NextResponse.json( return NextResponse.json(
{ error: 'Wishlist not found' }, { error: 'Convite necessário' },
{ status: 404 } { status: 401 }
); );
} }

View 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() });
}

View File

@@ -11,12 +11,6 @@ export async function GET(
try { try {
const { id } = await params; const { id } = await params;
const isAdmin = verifyAdminToken(request);
const guest = await getGuestFromRequest(request);
if (!isAdmin && !guest) {
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
}
// Get item // Get item
const item = await db const item = await db
.select() .select()
@@ -45,10 +39,13 @@ export async function GET(
); );
} }
if (!wishlist[0].isPublic && !isAdmin) { const isAdmin = verifyAdminToken(request);
const guest = await getGuestFromRequest(request);
if (!wishlist[0].isPublic && !isAdmin && !guest) {
return NextResponse.json( return NextResponse.json(
{ error: 'This item is private' }, { error: 'Convite necessário' },
{ status: 403 } { status: 401 }
); );
} }
@@ -81,6 +78,8 @@ export async function PATCH(
const { const {
name, name,
description, description,
price,
currency,
quantity, quantity,
imageUrl, imageUrl,
purchaseUrls, purchaseUrls,
@@ -108,6 +107,8 @@ export async function PATCH(
if (name !== undefined) updateData.name = name; if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description; if (description !== undefined) updateData.description = description;
if (price !== undefined) updateData.price = price != null ? Number(price) : null;
if (currency !== undefined) updateData.currency = currency;
if (quantity !== undefined) updateData.quantity = quantity; if (quantity !== undefined) updateData.quantity = quantity;
if (imageUrl !== undefined) updateData.imageUrl = imageUrl; if (imageUrl !== undefined) updateData.imageUrl = imageUrl;
if (purchaseUrls !== undefined) updateData.purchaseUrls = purchaseUrls; if (purchaseUrls !== undefined) updateData.purchaseUrls = purchaseUrls;

View File

@@ -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 };

View File

@@ -1,16 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { eq, asc } from 'drizzle-orm'; import { eq, asc } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db'; import { db, wishlists } from '@/lib/db';
import { getGuestFromRequest, verifyAdminToken } from '@/lib/auth/tokens';
export async function GET(request: NextRequest) { export async function GET() {
try { try {
const isAdmin = verifyAdminToken(request);
const guest = await getGuestFromRequest(request);
if (!isAdmin && !guest) {
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
}
// Fetch only public wishlists // Fetch only public wishlists
const publicWishlists = await db const publicWishlists = await db
.select() .select()

View File

@@ -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',

View File

@@ -11,12 +11,6 @@ export async function GET(
try { try {
const { id } = await params; const { id } = await params;
const isAdmin = verifyAdminToken(request);
const guest = await getGuestFromRequest(request);
if (!isAdmin && !guest) {
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
}
// Check if wishlist exists // Check if wishlist exists
const wishlist = await db const wishlist = await db
.select() .select()
@@ -31,11 +25,14 @@ export async function GET(
); );
} }
// Permissions: guest can only see public wishlists; admin sees all const isAdmin = verifyAdminToken(request);
if (!wishlist[0].isPublic && !isAdmin) { const guest = await getGuestFromRequest(request);
// Public wishlists can be viewed anonymously; private wishlists require admin or guest access.
if (!wishlist[0].isPublic && !isAdmin && !guest) {
return NextResponse.json( return NextResponse.json(
{ error: 'This wishlist is private' }, { error: 'Convite necessário' },
{ status: 403 } { status: 401 }
); );
} }
@@ -82,6 +79,8 @@ export async function POST(
const { const {
name, name,
description, description,
price,
currency,
quantity, quantity,
imageUrl, imageUrl,
purchaseUrls, purchaseUrls,
@@ -126,6 +125,8 @@ export async function POST(
wishlistId: id, wishlistId: id,
name, name,
description: description || null, description: description || null,
price: price != null ? Number(price) : null,
currency: currency || 'BRL',
quantity: quantity || 1, quantity: quantity || 1,
imageUrl: imageUrl || null, imageUrl: imageUrl || null,
purchaseUrls: purchaseUrls || null, purchaseUrls: purchaseUrls || null,

View File

@@ -75,6 +75,198 @@ body {
opacity: 0.85; opacity: 0.85;
} }
.mars-planet {
--mars-x: 0;
--mars-y: 0;
position: absolute;
z-index: 0;
width: clamp(5.5rem, 16vw, 8.5rem);
aspect-ratio: 1;
display: grid;
place-items: center;
cursor: pointer;
perspective: 600px;
filter: drop-shadow(0 24px 34px rgba(138, 47, 21, 0.24));
transform:
translate3d(calc(var(--mars-x) * 6px), calc(var(--mars-y) * 6px), 0)
rotateX(calc(var(--mars-y) * -8deg))
rotateY(calc(var(--mars-x) * 10deg));
transition: transform 180ms ease, filter 180ms ease;
}
.mars-planet:hover {
filter: drop-shadow(0 28px 38px rgba(138, 47, 21, 0.32));
}
.mars-planet:active {
transform:
translate3d(calc(var(--mars-x) * 5px), calc(var(--mars-y) * 5px), 0)
rotateX(calc(var(--mars-y) * -8deg))
rotateY(calc(var(--mars-x) * 10deg))
scale(0.96);
}
.mars-planet__body,
.mars-planet__orbit,
.mars-planet__spark,
.mars-planet__band,
.mars-planet__crater {
position: absolute;
pointer-events: none;
}
.mars-planet__body {
inset: 12%;
overflow: hidden;
border-radius: 9999px;
background:
radial-gradient(circle at 32% 28%, #ffc6a1 0 9%, transparent 10%),
radial-gradient(circle at 30% 32%, #e97445 0 34%, #c1502c 56%, #7c2814 100%);
box-shadow:
inset -18px -16px 30px rgba(71, 22, 12, 0.42),
inset 10px 9px 18px rgba(255, 219, 184, 0.34),
0 0 34px rgba(231, 111, 65, 0.48);
animation: mars-float 5.5s ease-in-out infinite;
}
.mars-planet__body::before {
content: "";
position: absolute;
inset: -18% -24%;
background:
linear-gradient(16deg, transparent 20%, rgba(255, 196, 142, 0.24) 24% 29%, transparent 34%),
linear-gradient(-11deg, transparent 45%, rgba(121, 44, 24, 0.24) 49% 54%, transparent 59%);
animation: mars-drift 9s linear infinite;
}
.mars-planet__orbit {
width: 116%;
height: 42%;
border: 2px solid rgba(255, 205, 165, 0.78);
border-left-color: transparent;
border-right-color: transparent;
border-radius: 9999px;
transform: rotate(-18deg);
box-shadow: 0 0 18px rgba(255, 188, 138, 0.34);
animation: mars-orbit 4.5s ease-in-out infinite;
}
.mars-planet__band {
left: 8%;
right: 8%;
height: 11%;
border-radius: 9999px;
background: rgba(255, 178, 124, 0.26);
transform: rotate(-14deg);
}
.mars-planet__band--one {
top: 36%;
}
.mars-planet__band--two {
top: 58%;
background: rgba(113, 41, 23, 0.22);
}
.mars-planet__crater {
border-radius: 9999px;
background:
radial-gradient(circle at 38% 35%, rgba(255, 219, 184, 0.24), transparent 36%),
rgba(106, 36, 19, 0.38);
box-shadow: inset 2px 2px 4px rgba(68, 21, 12, 0.34);
}
.mars-planet__crater--one {
width: 16%;
height: 16%;
top: 30%;
left: 52%;
}
.mars-planet__crater--two {
width: 11%;
height: 11%;
top: 58%;
left: 29%;
}
.mars-planet__crater--three {
width: 9%;
height: 9%;
top: 64%;
left: 64%;
}
.mars-planet__spark {
width: 0.48rem;
height: 0.48rem;
border-radius: 9999px;
background: #fff1df;
box-shadow: 0 0 12px rgba(255, 241, 223, 0.9);
animation: mars-spark 2.4s ease-in-out infinite;
}
.mars-planet__spark--one {
top: 10%;
left: 17%;
}
.mars-planet__spark--two {
right: 10%;
bottom: 18%;
width: 0.34rem;
height: 0.34rem;
animation-delay: 0.8s;
}
@keyframes mars-float {
0%, 100% {
transform: translateY(0) rotate(-2deg);
}
50% {
transform: translateY(-8px) rotate(2deg);
}
}
@keyframes mars-drift {
from {
transform: translateX(-10%);
}
to {
transform: translateX(10%);
}
}
@keyframes mars-orbit {
0%, 100% {
transform: rotate(-18deg) scaleX(1);
}
50% {
transform: rotate(-18deg) scaleX(1.08);
}
}
@keyframes mars-spark {
0%, 100% {
opacity: 0.35;
transform: scale(0.72);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.mars-planet,
.mars-planet *,
.mars-planet *::before {
animation: none !important;
transition: none !important;
}
}
.bg-card { .bg-card {
background-color: var(--card); background-color: var(--card);
} }

View File

@@ -64,6 +64,17 @@ export default function ItemCard({
<h5 className="text-base font-semibold text-gray-900 dark:text-white"> <h5 className="text-base font-semibold text-gray-900 dark:text-white">
{item.name} {item.name}
</h5> </h5>
{item.price != null && (
<p className="text-sm text-indigo-600 dark:text-indigo-400 mt-0.5">
{new Intl.NumberFormat('pt-BR', { style: 'currency', currency: item.currency || 'BRL' }).format(item.price)}
{item.quantity > 1 && ` · Qtd: ${item.quantity}`}
</p>
)}
{item.price == null && item.quantity > 1 && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Qtd: {item.quantity}
</p>
)}
{item.description && ( {item.description && (
<p className="text-base text-gray-600 dark:text-gray-400 mt-1"> <p className="text-base text-gray-600 dark:text-gray-400 mt-1">
{item.description} {item.description}

View File

@@ -18,6 +18,8 @@ export default function ItemForm({ item, onSubmit, onCancel, mode, error }: Item
item || { item || {
name: '', name: '',
description: '', description: '',
price: null,
currency: 'BRL',
quantity: 1, quantity: 1,
imageUrl: '', imageUrl: '',
purchaseUrls: [], purchaseUrls: [],
@@ -80,6 +82,25 @@ export default function ItemForm({ item, onSubmit, onCancel, mode, error }: Item
} }
/> />
</div> </div>
<div>
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-1">
Preço (R$)
</label>
<input
type="number"
min={0}
step="0.01"
placeholder="0,00"
className="w-full px-2 py-1.5 text-base border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
value={formData.price ?? ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
price: e.target.value === '' ? null : parseFloat(e.target.value) || null,
}))
}
/>
</div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-1">
Description Description

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { authApi } from '@/lib/api'; import { authApi, wishlistsApi } from '@/lib/api';
type Status = 'checking' | { kind: 'ok'; guestName: string } | 'denied'; type Status = 'checking' | { kind: 'ok'; guestName: string } | 'denied';
@@ -21,6 +21,15 @@ export default function GuestGuard({ children }: { children: React.ReactNode })
if (who.role === 'admin' || who.role === 'guest') { if (who.role === 'admin' || who.role === 'guest') {
setStatus({ kind: 'ok', guestName: who.guest?.name ?? 'admin' }); setStatus({ kind: 'ok', guestName: who.guest?.name ?? 'admin' });
} else { } else {
const slug = window.location.pathname.split('/').filter(Boolean)[0];
if (slug) {
const wishlist = await wishlistsApi.getBySlug(slug);
if (cancelled) return;
if (wishlist.isPublic) {
setStatus({ kind: 'ok', guestName: 'visitante' });
return;
}
}
setStatus('denied'); setStatus('denied');
} }
} catch { } catch {

View File

@@ -1,5 +1,7 @@
'use client'; 'use client';
import MarsPlanet from '@/components/mars-planet';
interface HeaderProps { interface HeaderProps {
title: string; title: string;
subtitle?: string; subtitle?: string;
@@ -25,6 +27,7 @@ export default function Header({ title, subtitle, imageUrl, actions, maxWidth =
/> />
<div className={`${maxWidth} mx-auto relative py-14 px-4 sm:py-20 sm:px-6 lg:px-8`}> <div className={`${maxWidth} mx-auto relative py-14 px-4 sm:py-20 sm:px-6 lg:px-8`}>
<MarsPlanet className="absolute right-4 top-4 sm:right-8 sm:top-7 lg:right-10 lg:top-9" />
<div className="flex flex-col md:flex-row items-center md:items-start gap-8"> <div className="flex flex-col md:flex-row items-center md:items-start gap-8">
{imageUrl && ( {imageUrl && (
<div className="md:w-56 flex-shrink-0"> <div className="md:w-56 flex-shrink-0">
@@ -40,7 +43,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>
)} )}

View File

@@ -0,0 +1,43 @@
'use client';
import type { PointerEvent } from 'react';
type MarsPlanetProps = {
className?: string;
};
export default function MarsPlanet({ className = '' }: MarsPlanetProps) {
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
const bounds = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 2;
const y = ((event.clientY - bounds.top) / bounds.height - 0.5) * 2;
event.currentTarget.style.setProperty('--mars-x', x.toFixed(3));
event.currentTarget.style.setProperty('--mars-y', y.toFixed(3));
};
const resetTilt = (event: PointerEvent<HTMLDivElement>) => {
event.currentTarget.style.setProperty('--mars-x', '0');
event.currentTarget.style.setProperty('--mars-y', '0');
};
return (
<div
aria-hidden="true"
className={`mars-planet ${className}`}
onPointerMove={handlePointerMove}
onPointerLeave={resetTilt}
>
<span className="mars-planet__orbit" />
<span className="mars-planet__body">
<span className="mars-planet__band mars-planet__band--one" />
<span className="mars-planet__band mars-planet__band--two" />
<span className="mars-planet__crater mars-planet__crater--one" />
<span className="mars-planet__crater mars-planet__crater--two" />
<span className="mars-planet__crater mars-planet__crater--three" />
</span>
<span className="mars-planet__spark mars-planet__spark--one" />
<span className="mars-planet__spark mars-planet__spark--two" />
</div>
);
}

Binary file not shown.

80
docs/skill-item-images.md Normal file
View 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

View File

@@ -147,6 +147,8 @@ export interface Item {
wishlistId: string; wishlistId: string;
name: string; name: string;
description: string | null; description: string | null;
price: number | null;
currency: string;
quantity: number; quantity: number;
imageUrl: string | null; imageUrl: string | null;
purchaseUrls: Array<{ label: string; url: string }> | null; purchaseUrls: Array<{ label: string; url: string }> | null;
@@ -352,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 = {