Compare commits
19 Commits
96d38301c4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a28a3a489d | ||
|
|
653121921e | ||
|
|
78bef85c96 | ||
|
|
6c8e11c851 | ||
|
|
7ef1065971 | ||
|
|
9f6a7c15d9 | ||
|
|
eebb183d36 | ||
|
|
a0349aa9c4 | ||
|
|
b19a3fdf48 | ||
|
|
5548f5000f | ||
|
|
3df9a67b97 | ||
|
|
21e8b7e137 | ||
|
|
4b428ce272 | ||
|
|
23114637ac | ||
|
|
2726be337e | ||
|
|
cac2c223dd | ||
|
|
1e513c318f | ||
|
|
4e031a9d4d | ||
|
|
007aa35521 |
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
|
||||||
34
AGENTS.md
Normal file
34
AGENTS.md
Normal 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.
|
||||||
@@ -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]);
|
||||||
|
|
||||||
@@ -125,13 +127,6 @@ function PublicWishlistContent() {
|
|||||||
return item.remainingQuantity > 0 || my || item.id === justClaimedItemId;
|
return item.remainingQuantity > 0 || my || item.id === justClaimedItemId;
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatPrice = (price: number | null, currency: string) => {
|
|
||||||
if (!price) return null;
|
|
||||||
return new Intl.NumberFormat('pt-BR', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency || 'BRL',
|
|
||||||
}).format(price);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -185,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 ? (
|
||||||
@@ -199,172 +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">
|
||||||
|
"{c.note}"
|
||||||
|
</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">
|
|
||||||
"{c.note}"
|
|
||||||
</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>
|
|
||||||
<span className="text-gray-900 dark:text-white font-bold text-lg">
|
|
||||||
{item.price && formatPrice(item.price, item.currency)}
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
82
app/admin/claims/page.tsx
Normal file
82
app/admin/claims/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import AdminGuard from '@/components/admin-guard';
|
||||||
|
import { claimsApi, type AdminClaim } from '@/lib/api';
|
||||||
|
|
||||||
|
export default function AdminClaimsPage() {
|
||||||
|
return (
|
||||||
|
<AdminGuard>
|
||||||
|
<ClaimsView />
|
||||||
|
</AdminGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClaimsView() {
|
||||||
|
const [claims, setClaims] = useState<AdminClaim[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
claimsApi.list().then((c) => setClaims(c)).finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const byItem: Record<string, { reserved: number; total: number }> = {};
|
||||||
|
for (const c of claims) {
|
||||||
|
const k = c.itemId;
|
||||||
|
if (!byItem[k]) byItem[k] = { reserved: 0, total: c.itemQuantity };
|
||||||
|
byItem[k].reserved += c.quantity;
|
||||||
|
}
|
||||||
|
return byItem;
|
||||||
|
}, [claims]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="min-h-screen flex items-center justify-center text-gray-500">Carregando…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Reservas</h1>
|
||||||
|
<div className="text-sm text-gray-500">{claims.length} no total</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{claims.length === 0 ? (
|
||||||
|
<div className="border rounded p-6 text-center text-gray-500">Nenhuma reserva ainda.</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto border rounded">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr className="text-left">
|
||||||
|
<th className="p-3">Item</th>
|
||||||
|
<th className="p-3">Qtd</th>
|
||||||
|
<th className="p-3">Convidado</th>
|
||||||
|
<th className="p-3">Nota</th>
|
||||||
|
<th className="p-3">Quando</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{claims.map((c) => {
|
||||||
|
const t = totals[c.itemId];
|
||||||
|
return (
|
||||||
|
<tr key={c.claimId} className="border-t">
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="font-medium">{c.itemName}</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{c.wishlistName} · {t.reserved}/{t.total} reservados
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">{c.quantity}</td>
|
||||||
|
<td className="p-3">{c.guestName}</td>
|
||||||
|
<td className="p-3 italic text-gray-600 dark:text-gray-400">{c.note ?? '—'}</td>
|
||||||
|
<td className="p-3 text-gray-500">{new Date(c.claimedAt).toLocaleString('pt-BR')}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,14 +3,21 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import AdminGuard from '@/components/admin-guard';
|
import AdminGuard from '@/components/admin-guard';
|
||||||
import { authApi, wishlistsApi, itemsApi, settingsApi, type Wishlist, type Settings } from '@/lib/api';
|
import {
|
||||||
|
authApi,
|
||||||
|
wishlistsApi,
|
||||||
|
itemsApi,
|
||||||
|
settingsApi,
|
||||||
|
type Wishlist,
|
||||||
|
type Item,
|
||||||
|
type Settings,
|
||||||
|
} from '@/lib/api';
|
||||||
import Header from '@/components/header';
|
import Header from '@/components/header';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import StatsGrid from '@/components/admin/StatsGrid';
|
import ItemCard from '@/components/admin/ItemCard';
|
||||||
import SettingsSection from '@/components/admin/SettingsSection';
|
import ItemForm from '@/components/admin/ItemForm';
|
||||||
import WishlistCard from '@/components/admin/WishlistCard';
|
import ImageUpload from '@/components/image-upload';
|
||||||
import CreateWishlistModal from '@/components/admin/CreateWishlistModal';
|
import RichTextEditor from '@/components/RichTextEditor';
|
||||||
import ShareButton from '@/components/share-button';
|
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
return (
|
return (
|
||||||
@@ -22,232 +29,510 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
function AdminPageContent() {
|
function AdminPageContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [wishlists, setWishlists] = useState<Wishlist[]>([]);
|
|
||||||
const [itemCounts, setItemCounts] = useState<Record<string, number>>({});
|
// Single wishlist state
|
||||||
|
const [wishlist, setWishlist] = useState<Wishlist | null>(null);
|
||||||
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Settings
|
||||||
const [settings, setSettings] = useState<Settings>({
|
const [settings, setSettings] = useState<Settings>({
|
||||||
siteTitle: 'Wishlist',
|
siteTitle: 'Wishlist',
|
||||||
homepageSubtext: 'Browse and explore available wishlists',
|
homepageSubtext: '',
|
||||||
|
claimingEnabled: true,
|
||||||
|
showQuantity: true,
|
||||||
});
|
});
|
||||||
|
const [adminToken, setAdminToken] = useState<string | null>(null);
|
||||||
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
|
|
||||||
|
// Unified config edit state
|
||||||
|
const [isEditingWishlist, setIsEditingWishlist] = useState(false);
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
preferences: '',
|
||||||
|
imageUrl: '',
|
||||||
|
isPublic: true,
|
||||||
|
siteTitle: '',
|
||||||
|
homepageSubtext: '',
|
||||||
|
claimingEnabled: true,
|
||||||
|
showQuantity: true,
|
||||||
|
});
|
||||||
|
const [editError, setEditError] = useState('');
|
||||||
|
const [isWishlistImageUploading, setIsWishlistImageUploading] = useState(false);
|
||||||
|
|
||||||
|
// Item management state
|
||||||
|
const [editingItemId, setEditingItemId] = useState<string | null>(null);
|
||||||
|
const [showAddItemForm, setShowAddItemForm] = useState(false);
|
||||||
|
const [newItemError, setNewItemError] = useState('');
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
await authApi.logout();
|
await authApi.logout();
|
||||||
router.push('/');
|
router.push('/');
|
||||||
};
|
};
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
||||||
const [createError, setCreateError] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchWishlists();
|
fetchData();
|
||||||
fetchSettings();
|
fetchAdminToken();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchAdminToken = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await settingsApi.getSettings();
|
const res = await fetch('/api/admin/access-link', { credentials: 'include' });
|
||||||
setSettings(data);
|
if (res.ok) {
|
||||||
} catch (error) {
|
const data = await res.json();
|
||||||
console.error('Failed to fetch settings:', error);
|
setAdminToken(data.token);
|
||||||
}
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchWishlists = async () => {
|
const copyAdminLink = async () => {
|
||||||
try {
|
if (!adminToken) return;
|
||||||
const data = await wishlistsApi.getAll();
|
const url = `${window.location.origin}/?adm=${adminToken}`;
|
||||||
setWishlists(data);
|
await navigator.clipboard.writeText(url);
|
||||||
|
setLinkCopied(true);
|
||||||
|
setTimeout(() => setLinkCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch item counts for each wishlist
|
const fetchData = async () => {
|
||||||
const counts: Record<string, number> = {};
|
try {
|
||||||
await Promise.all(
|
const [lists, settingsData] = await Promise.all([
|
||||||
data.map(async (w) => {
|
wishlistsApi.getAll(),
|
||||||
const items = await itemsApi.getAll(w.id);
|
settingsApi.getSettings(),
|
||||||
counts[w.id] = items.length;
|
]);
|
||||||
})
|
setSettings(settingsData);
|
||||||
);
|
if (lists.length > 0) {
|
||||||
setItemCounts(counts);
|
const w = lists[0];
|
||||||
|
setWishlist(w);
|
||||||
|
const itemsData = await itemsApi.getAll(w.id);
|
||||||
|
setItems(itemsData.sort((a, b) => a.sortOrder - b.sortOrder));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch wishlists:', error);
|
console.error('Failed to fetch data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateSettings = async (updatedSettings: Settings) => {
|
const refreshItems = async () => {
|
||||||
await settingsApi.updateSettings(updatedSettings);
|
if (!wishlist) return;
|
||||||
setSettings(updatedSettings);
|
const itemsData = await itemsApi.getAll(wishlist.id);
|
||||||
|
setItems(itemsData.sort((a, b) => a.sortOrder - b.sortOrder));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateWishlist = async (data: any) => {
|
// Wishlist edit handlers
|
||||||
setCreateError('');
|
const startEditingWishlist = () => {
|
||||||
|
if (!wishlist) return;
|
||||||
|
setEditForm({
|
||||||
|
name: wishlist.name,
|
||||||
|
slug: wishlist.slug,
|
||||||
|
description: wishlist.description || '',
|
||||||
|
preferences: wishlist.preferences || '',
|
||||||
|
imageUrl: wishlist.imageUrl || '',
|
||||||
|
isPublic: wishlist.isPublic,
|
||||||
|
siteTitle: settings.siteTitle,
|
||||||
|
homepageSubtext: settings.homepageSubtext,
|
||||||
|
claimingEnabled: settings.claimingEnabled,
|
||||||
|
showQuantity: settings.showQuantity,
|
||||||
|
});
|
||||||
|
setEditError('');
|
||||||
|
setIsEditingWishlist(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateWishlist = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!wishlist) return;
|
||||||
|
setEditError('');
|
||||||
try {
|
try {
|
||||||
await wishlistsApi.create(data);
|
const { siteTitle, homepageSubtext, claimingEnabled, showQuantity, ...wishlistFields } = editForm;
|
||||||
setShowCreateModal(false);
|
const [updated] = await Promise.all([
|
||||||
fetchWishlists();
|
wishlistsApi.update(wishlist.id, wishlistFields),
|
||||||
|
settingsApi.updateSettings({ siteTitle, homepageSubtext, claimingEnabled, showQuantity }),
|
||||||
|
]);
|
||||||
|
setWishlist(updated);
|
||||||
|
setSettings({ siteTitle, homepageSubtext, claimingEnabled, showQuantity });
|
||||||
|
setIsEditingWishlist(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setCreateError(error.message || 'Failed to create wishlist');
|
setEditError(error.message || 'Failed to update settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Item handlers
|
||||||
|
const handleCreateItem = async (itemData: Partial<Item>) => {
|
||||||
|
if (!wishlist) return;
|
||||||
|
setNewItemError('');
|
||||||
|
try {
|
||||||
|
await itemsApi.create(wishlist.id, itemData);
|
||||||
|
setShowAddItemForm(false);
|
||||||
|
await refreshItems();
|
||||||
|
} catch (error: any) {
|
||||||
|
setNewItemError(error.message || 'Failed to create item');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateWishlist = async (id: string, data: Partial<Wishlist>) => {
|
const handleUpdateItem = async (itemData: Partial<Item>) => {
|
||||||
await wishlistsApi.update(id, data);
|
if (!editingItemId) return;
|
||||||
fetchWishlists();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteWishlist = async (id: string) => {
|
|
||||||
if (!confirm('Are you sure you want to delete this wishlist?')) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await wishlistsApi.delete(id);
|
await itemsApi.update(editingItemId, itemData);
|
||||||
fetchWishlists();
|
setEditingItemId(null);
|
||||||
} catch (error) {
|
await refreshItems();
|
||||||
alert('Failed to delete wishlist');
|
} catch (error: any) {
|
||||||
|
alert(error.message || 'Failed to update item');
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMoveWishlistUp = async (wishlistId: string) => {
|
const handleDeleteItem = async (itemId: string) => {
|
||||||
const currentIndex = wishlists.findIndex((w) => w.id === wishlistId);
|
if (!confirm('Tem certeza que deseja excluir este item?')) return;
|
||||||
|
try {
|
||||||
|
await itemsApi.delete(itemId);
|
||||||
|
await refreshItems();
|
||||||
|
} catch {
|
||||||
|
alert('Failed to delete item');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveItemUp = async (itemId: string) => {
|
||||||
|
const currentIndex = items.findIndex((item) => item.id === itemId);
|
||||||
if (currentIndex <= 0) return;
|
if (currentIndex <= 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await wishlistsApi.reorder(wishlistId, currentIndex - 1);
|
await itemsApi.reorder(itemId, currentIndex - 1);
|
||||||
await fetchWishlists();
|
await refreshItems();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(`Error: ${error?.message || 'Failed to reorder wishlist'}`);
|
alert(error?.message || 'Failed to reorder item');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMoveWishlistDown = async (wishlistId: string) => {
|
const handleMoveItemDown = async (itemId: string) => {
|
||||||
const currentIndex = wishlists.findIndex((w) => w.id === wishlistId);
|
const currentIndex = items.findIndex((item) => item.id === itemId);
|
||||||
if (currentIndex === -1 || currentIndex === wishlists.length - 1) return;
|
if (currentIndex === -1 || currentIndex === items.length - 1) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await wishlistsApi.reorder(wishlistId, currentIndex + 1);
|
await itemsApi.reorder(itemId, currentIndex + 1);
|
||||||
await fetchWishlists();
|
await refreshItems();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(`Error: ${error?.message || 'Failed to reorder wishlist'}`);
|
alert(error?.message || 'Failed to reorder item');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stats = {
|
|
||||||
totalWishlists: wishlists.length,
|
const editingItem = items.find((item) => item.id === editingItemId);
|
||||||
publicWishlists: wishlists.filter((w) => w.isPublic).length,
|
|
||||||
totalItems: Object.values(itemCounts).reduce((sum, count) => sum + count, 0),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<Header
|
||||||
<Header
|
title="Dashboard"
|
||||||
title="Dashboard"
|
subtitle={wishlist ? `${items.length} itens` : undefined}
|
||||||
subtitle="Manage your wishlists and items"
|
actions={
|
||||||
actions={
|
<>
|
||||||
|
<Link
|
||||||
|
href="/admin/claims"
|
||||||
|
className="inline-flex items-center px-6 py-3 border-2 border-indigo-600 dark:border-indigo-500 text-base font-semibold rounded-lg text-indigo-600 dark:text-indigo-400 bg-white dark:bg-gray-800 hover:bg-indigo-50 dark:hover:bg-gray-700 transition-all"
|
||||||
|
>
|
||||||
|
Reservas
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/guests"
|
||||||
|
className="inline-flex items-center px-6 py-3 border-2 border-indigo-600 dark:border-indigo-500 text-base font-semibold rounded-lg text-indigo-600 dark:text-indigo-400 bg-white dark:bg-gray-800 hover:bg-indigo-50 dark:hover:bg-gray-700 transition-all"
|
||||||
|
>
|
||||||
|
Convidados
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="inline-flex items-center px-6 py-3 border-2 border-red-600 dark:border-red-500 text-base font-semibold rounded-lg text-red-600 dark:text-red-400 bg-white dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all cursor-pointer"
|
||||||
|
title="Logout"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
|
</svg>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto py-8 sm:px-6 lg:px-8">
|
||||||
|
<div className="px-4 sm:px-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">Carregando...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<ShareButton
|
{/* Unified Settings & Wishlist Config */}
|
||||||
title="Check out my wishlist site!"
|
{wishlist && (
|
||||||
text="I wanted to share my wishlist site with you."
|
<div className="mb-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
url="https://wishlist.tieso.co/"
|
<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>
|
||||||
<Link
|
{!isEditingWishlist && (
|
||||||
href="/"
|
<button
|
||||||
className="inline-flex items-center px-6 py-3 border-2 border-indigo-600 dark:border-indigo-500 text-base font-semibold rounded-lg text-indigo-600 dark:text-indigo-400 bg-white dark:bg-gray-800 hover:bg-indigo-50 dark:hover:bg-gray-700 transition-all"
|
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"
|
||||||
View Public Site
|
>
|
||||||
</Link>
|
Editar
|
||||||
<Link
|
</button>
|
||||||
href="/admin/guests"
|
)}
|
||||||
className="inline-flex items-center px-6 py-3 border-2 border-indigo-600 dark:border-indigo-500 text-base font-semibold rounded-lg text-indigo-600 dark:text-indigo-400 bg-white dark:bg-gray-800 hover:bg-indigo-50 dark:hover:bg-gray-700 transition-all"
|
</div>
|
||||||
>
|
<div className="p-5">
|
||||||
Convidados
|
{isEditingWishlist ? (
|
||||||
</Link>
|
<form onSubmit={handleUpdateWishlist} className="space-y-4">
|
||||||
<button
|
{editError && (
|
||||||
onClick={logout}
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded-lg text-base">
|
||||||
className="inline-flex items-center px-6 py-3 border-2 border-red-600 dark:border-red-500 text-base font-semibold rounded-lg text-red-600 dark:text-red-400 bg-white dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all cursor-pointer"
|
{editError}
|
||||||
title="Logout"
|
</div>
|
||||||
>
|
)}
|
||||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
<div>
|
||||||
</svg>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Logout
|
Título do site *
|
||||||
</button>
|
</label>
|
||||||
</>
|
<input
|
||||||
}
|
type="text"
|
||||||
/>
|
required
|
||||||
|
value={editForm.siteTitle}
|
||||||
|
onChange={(e) => setEditForm((prev) => ({ ...prev, siteTitle: 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>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Nome da lista *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
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>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
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>
|
||||||
|
<textarea
|
||||||
|
value={editForm.description}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Preferências
|
||||||
|
</label>
|
||||||
|
<RichTextEditor
|
||||||
|
value={editForm.preferences}
|
||||||
|
onChange={(html) => setEditForm((prev) => ({ ...prev, preferences: html }))}
|
||||||
|
placeholder="Interesses e preferências gerais..."
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditingWishlist(false)}
|
||||||
|
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 cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isWishlistImageUploading}
|
||||||
|
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{isWishlistImageUploading ? 'Enviando...' : 'Salvar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{wishlist.imageUrl && (
|
||||||
|
<img
|
||||||
|
src={wishlist.imageUrl}
|
||||||
|
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 grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2">
|
||||||
|
<div>
|
||||||
|
<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 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 className="max-w-7xl mx-auto py-8 sm:px-6 lg:px-8">
|
{/* Items Section */}
|
||||||
<div className="px-4 sm:px-0">
|
{wishlist && (
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Stats Grid */}
|
|
||||||
<StatsGrid stats={stats} />
|
|
||||||
|
|
||||||
{/* Settings Section */}
|
|
||||||
<SettingsSection settings={settings} onUpdate={handleUpdateSettings} />
|
|
||||||
|
|
||||||
{/* Wishlists Section */}
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
Your Wishlists
|
Itens ({items.length})
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => {
|
||||||
|
setShowAddItemForm(true);
|
||||||
|
setNewItemError('');
|
||||||
|
}}
|
||||||
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-semibold rounded-lg text-white bg-indigo-600 hover:bg-indigo-700 shadow-md hover:shadow-lg transition-all cursor-pointer"
|
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-semibold rounded-lg text-white bg-indigo-600 hover:bg-indigo-700 shadow-md hover:shadow-lg transition-all cursor-pointer"
|
||||||
>
|
>
|
||||||
+ Create Wishlist
|
+ Adicionar Item
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{wishlists.length === 0 ? (
|
|
||||||
|
{/* Add Item Form */}
|
||||||
|
{showAddItemForm && (
|
||||||
|
<ItemForm
|
||||||
|
mode="create"
|
||||||
|
onSubmit={handleCreateItem}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowAddItemForm(false);
|
||||||
|
setNewItemError('');
|
||||||
|
}}
|
||||||
|
error={newItemError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length === 0 && !showAddItemForm ? (
|
||||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
<p className="text-gray-500 dark:text-gray-400 mb-6 text-lg">
|
<p className="text-gray-500 dark:text-gray-400 mb-6 text-lg">
|
||||||
No wishlists yet
|
Nenhum item ainda
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => {
|
||||||
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-semibold rounded-lg text-white bg-indigo-600 hover:bg-indigo-700 shadow-md hover:shadow-lg transition-all"
|
setShowAddItemForm(true);
|
||||||
|
setNewItemError('');
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-semibold rounded-lg text-white bg-indigo-600 hover:bg-indigo-700 shadow-md hover:shadow-lg transition-all cursor-pointer"
|
||||||
>
|
>
|
||||||
Create Your First Wishlist
|
Adicionar Primeiro Item
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{wishlists.map((wishlist, index) => (
|
{items.map((item, index) => (
|
||||||
<WishlistCard
|
<div key={item.id}>
|
||||||
key={wishlist.id}
|
{editingItemId === item.id ? (
|
||||||
wishlist={wishlist}
|
<ItemForm
|
||||||
itemCount={itemCounts[wishlist.id] || 0}
|
mode="edit"
|
||||||
onUpdate={handleUpdateWishlist}
|
item={editingItem}
|
||||||
onDelete={handleDeleteWishlist}
|
onSubmit={handleUpdateItem}
|
||||||
onMoveUp={handleMoveWishlistUp}
|
onCancel={() => setEditingItemId(null)}
|
||||||
onMoveDown={handleMoveWishlistDown}
|
/>
|
||||||
isFirst={index === 0}
|
) : (
|
||||||
isLast={index === wishlists.length - 1}
|
<ItemCard
|
||||||
onItemsChange={fetchWishlists}
|
item={item}
|
||||||
/>
|
onEdit={() => setEditingItemId(item.id)}
|
||||||
|
onDelete={() => handleDeleteItem(item.id)}
|
||||||
|
onMoveUp={() => handleMoveItemUp(item.id)}
|
||||||
|
onMoveDown={() => handleMoveItemDown(item.id)}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === items.length - 1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create Modal */}
|
{!wishlist && !isLoading && (
|
||||||
<CreateWishlistModal
|
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
isOpen={showCreateModal}
|
<p className="text-gray-500 dark:text-gray-400 text-lg">
|
||||||
onClose={() => {
|
Nenhuma lista encontrada no banco de dados.
|
||||||
setShowCreateModal(false);
|
</p>
|
||||||
setCreateError('');
|
</div>
|
||||||
}}
|
)}
|
||||||
onCreate={handleCreateWishlist}
|
</>
|
||||||
error={createError}
|
)}
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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() });
|
||||||
|
}
|
||||||
33
app/api/admin/claims/route.ts
Normal file
33
app/api/admin/claims/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { desc, eq } from 'drizzle-orm';
|
||||||
|
import { db, itemClaims, wishlistItems, wishlists, guests } from '@/lib/db';
|
||||||
|
import { verifyAdminToken } from '@/lib/auth/tokens';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
claimId: itemClaims.id,
|
||||||
|
quantity: itemClaims.quantity,
|
||||||
|
note: itemClaims.note,
|
||||||
|
isPurchased: itemClaims.isPurchased,
|
||||||
|
claimedAt: itemClaims.claimedAt,
|
||||||
|
itemId: wishlistItems.id,
|
||||||
|
itemName: wishlistItems.name,
|
||||||
|
itemQuantity: wishlistItems.quantity,
|
||||||
|
wishlistSlug: wishlists.slug,
|
||||||
|
wishlistName: wishlists.name,
|
||||||
|
guestId: guests.id,
|
||||||
|
guestName: guests.name,
|
||||||
|
})
|
||||||
|
.from(itemClaims)
|
||||||
|
.innerJoin(wishlistItems, eq(itemClaims.itemId, wishlistItems.id))
|
||||||
|
.innerJoin(wishlists, eq(wishlistItems.wishlistId, wishlists.id))
|
||||||
|
.innerJoin(guests, eq(itemClaims.guestId, guests.id))
|
||||||
|
.orderBy(desc(itemClaims.claimedAt));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, claims: rows });
|
||||||
|
}
|
||||||
@@ -90,24 +90,25 @@ export async function POST(
|
|||||||
allItems.splice(newSortOrder, 0, movingItem);
|
allItems.splice(newSortOrder, 0, movingItem);
|
||||||
|
|
||||||
// Update all sortOrders in a transaction for atomicity
|
// Update all sortOrders in a transaction for atomicity
|
||||||
const updatedItem = await db.transaction(async (tx) => {
|
// Note: better-sqlite3 is synchronous, so no async/await in transaction
|
||||||
// Update all sortOrders
|
const updatedItem = db.transaction((tx) => {
|
||||||
for (let i = 0; i < allItems.length; i++) {
|
for (let i = 0; i < allItems.length; i++) {
|
||||||
await tx
|
tx.update(wishlistItems)
|
||||||
.update(wishlistItems)
|
|
||||||
.set({
|
.set({
|
||||||
sortOrder: i,
|
sortOrder: i,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(wishlistItems.id, allItems[i].id));
|
.where(eq(wishlistItems.id, allItems[i].id))
|
||||||
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the updated moving item
|
// Get the updated moving item
|
||||||
const result = await tx
|
const result = tx
|
||||||
.select()
|
.select()
|
||||||
.from(wishlistItems)
|
.from(wishlistItems)
|
||||||
.where(eq(wishlistItems.id, id))
|
.where(eq(wishlistItems.id, id))
|
||||||
.limit(1);
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
|
||||||
return result[0];
|
return result[0];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +107,7 @@ 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;
|
if (price !== undefined) updateData.price = price != null ? Number(price) : null;
|
||||||
if (currency !== undefined) updateData.currency = currency;
|
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;
|
||||||
|
|||||||
@@ -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,44 +26,74 @@ 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 });
|
||||||
|
|
||||||
// Sum quantities reserved by OTHER guests
|
const claimingSetting = await db.select().from(settings).where(eq(settings.key, 'claimingEnabled')).limit(1);
|
||||||
const otherSumRow = await db
|
if (claimingSetting[0]?.value === 'false') {
|
||||||
.select({ total: sql<number>`COALESCE(SUM(${itemClaims.quantity}), 0)` })
|
return NextResponse.json({ error: 'Reservas desativadas' }, { status: 403 });
|
||||||
.from(itemClaims)
|
}
|
||||||
.where(and(eq(itemClaims.itemId, id), ne(itemClaims.guestId, guest.id)));
|
|
||||||
const otherSum = Number(otherSumRow[0]?.total ?? 0);
|
|
||||||
|
|
||||||
if (otherSum + requestedQty > item.quantity) {
|
// Atomically check remaining quantity and upsert the claim inside a synchronous
|
||||||
const remaining = Math.max(0, item.quantity - otherSum);
|
// transaction to prevent race conditions (better-sqlite3 is synchronous).
|
||||||
|
type TxResult = { ok: true } | { ok: false; remaining: number };
|
||||||
|
|
||||||
|
const txResult: TxResult = db.transaction((tx) => {
|
||||||
|
// Re-read item quantity inside the transaction for an accurate snapshot
|
||||||
|
const itemSnap = tx
|
||||||
|
.select({ quantity: wishlistItems.quantity })
|
||||||
|
.from(wishlistItems)
|
||||||
|
.where(eq(wishlistItems.id, id))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (itemSnap.length === 0) return { ok: false, remaining: 0 };
|
||||||
|
const itemQty = itemSnap[0].quantity;
|
||||||
|
|
||||||
|
// Sum quantities reserved by OTHER guests
|
||||||
|
const otherSumRow = tx
|
||||||
|
.select({ total: sql<number>`COALESCE(SUM(${itemClaims.quantity}), 0)` })
|
||||||
|
.from(itemClaims)
|
||||||
|
.where(and(eq(itemClaims.itemId, id), ne(itemClaims.guestId, guest.id)))
|
||||||
|
.all();
|
||||||
|
const otherSum = Number(otherSumRow[0]?.total ?? 0);
|
||||||
|
|
||||||
|
if (otherSum + requestedQty > itemQty) {
|
||||||
|
const remaining = Math.max(0, itemQty - otherSum);
|
||||||
|
return { ok: false, remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert: replace existing claim for this (item, guest)
|
||||||
|
const existing = tx
|
||||||
|
.select({ id: itemClaims.id })
|
||||||
|
.from(itemClaims)
|
||||||
|
.where(and(eq(itemClaims.itemId, id), eq(itemClaims.guestId, guest.id)))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
tx.update(itemClaims)
|
||||||
|
.set({ quantity: requestedQty, note, updatedAt: new Date() })
|
||||||
|
.where(eq(itemClaims.id, existing[0].id))
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
tx.insert(itemClaims)
|
||||||
|
.values({ itemId: id, guestId: guest.id, quantity: requestedQty, note })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.update(wishlistItems)
|
||||||
|
.set({ updatedAt: new Date() })
|
||||||
|
.where(eq(wishlistItems.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!txResult.ok) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Apenas ${remaining} disponível(is)`, remaining },
|
{ error: `Apenas ${txResult.remaining} disponível(is)`, remaining: txResult.remaining },
|
||||||
{ status: 409 }
|
{ status: 409 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert: replace existing claim for this (item, guest)
|
|
||||||
const existing = await db
|
|
||||||
.select()
|
|
||||||
.from(itemClaims)
|
|
||||||
.where(and(eq(itemClaims.itemId, id), eq(itemClaims.guestId, guest.id)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existing.length > 0) {
|
|
||||||
await db
|
|
||||||
.update(itemClaims)
|
|
||||||
.set({ quantity: requestedQty, note, updatedAt: new Date() })
|
|
||||||
.where(eq(itemClaims.id, existing[0].id));
|
|
||||||
} else {
|
|
||||||
await db.insert(itemClaims).values({
|
|
||||||
itemId: id,
|
|
||||||
guestId: guest.id,
|
|
||||||
quantity: requestedQty,
|
|
||||||
note,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.update(wishlistItems).set({ updatedAt: new Date() }).where(eq(wishlistItems.id, id));
|
|
||||||
|
|
||||||
const updated = await fetchItemWithClaims(id);
|
const updated = await fetchItemWithClaims(id);
|
||||||
return NextResponse.json({ success: true, item: updated }, { status: 201 });
|
return NextResponse.json({ success: true, item: updated }, { status: 201 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,8 +125,8 @@ export async function POST(
|
|||||||
wishlistId: id,
|
wishlistId: id,
|
||||||
name,
|
name,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
price: price || null,
|
price: price != null ? Number(price) : null,
|
||||||
currency: currency || 'USD',
|
currency: currency || 'BRL',
|
||||||
quantity: quantity || 1,
|
quantity: quantity || 1,
|
||||||
imageUrl: imageUrl || null,
|
imageUrl: imageUrl || null,
|
||||||
purchaseUrls: purchaseUrls || null,
|
purchaseUrls: purchaseUrls || null,
|
||||||
|
|||||||
192
app/globals.css
192
app/globals.css
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
59
app/page.tsx
59
app/page.tsx
@@ -1,14 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { authApi, wishlistsApi, type Wishlist } from '@/lib/api';
|
import { authApi, wishlistsApi } from '@/lib/api';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [state, setState] = useState<'checking' | 'guest' | 'none'>('checking');
|
|
||||||
const [wishlists, setWishlists] = useState<Wishlist[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -31,65 +28,31 @@ export default function HomePage() {
|
|||||||
router.replace('/admin');
|
router.replace('/admin');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// For guests (and after usr token auth), redirect to the single wishlist
|
||||||
if (who.role === 'guest') {
|
if (who.role === 'guest') {
|
||||||
const lists = await wishlistsApi.getAllPublic();
|
const lists = await wishlistsApi.getAllPublic();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (lists.length === 1) {
|
if (lists.length > 0) {
|
||||||
router.replace(`/${lists[0].slug}`);
|
router.replace(`/${lists[0].slug}`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
setWishlists(lists);
|
|
||||||
setState('guest');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState('none');
|
// Unauthenticated — redirect to wishlist anyway (GuestGuard will block them there)
|
||||||
|
const lists = await wishlistsApi.getAllPublic();
|
||||||
|
if (!cancelled && lists.length > 0) {
|
||||||
|
router.replace(`/${lists[0].slug}`);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setState('none');
|
// ignore
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
if (state === 'checking') {
|
|
||||||
return <div className="min-h-screen flex items-center justify-center text-gray-500">Carregando…</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === 'guest') {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center px-6">
|
|
||||||
<div className="w-full max-w-2xl text-center">
|
|
||||||
<h1 className="text-3xl font-bold mb-6">Bem-vindo</h1>
|
|
||||||
{wishlists.length === 0 ? (
|
|
||||||
<p className="text-gray-500">Nenhuma lista disponível ainda.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-3 text-left">
|
|
||||||
{wishlists.map((w) => (
|
|
||||||
<li key={w.id}>
|
|
||||||
<Link
|
|
||||||
href={`/${w.slug}`}
|
|
||||||
className="block rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:border-indigo-400 hover:bg-indigo-50/30 dark:hover:bg-indigo-900/10 transition"
|
|
||||||
>
|
|
||||||
<div className="font-semibold text-lg">{w.name}</div>
|
|
||||||
{w.description && (
|
|
||||||
<div className="text-sm text-gray-500 mt-1">{w.description}</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center text-center px-6">
|
<div className="min-h-screen flex items-center justify-center text-center px-6">
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<h1 className="text-4xl font-bold mb-3">Lista de Presentes</h1>
|
<p className="text-lg text-gray-500">Carregando…</p>
|
||||||
<p className="text-lg text-gray-500">
|
|
||||||
Gerencie listas de presentes e convites com facilidade.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,16 +64,22 @@ 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}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{item.price && (
|
|
||||||
<p className="text-base text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
${item.price.toFixed(2)} {item.currency}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-16 border-l border-gray-200 dark:border-gray-700">
|
<div className="flex flex-col w-16 border-l border-gray-200 dark:border-gray-700">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function ItemForm({ item, onSubmit, onCancel, mode, error }: Item
|
|||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
price: null,
|
price: null,
|
||||||
currency: 'USD',
|
currency: 'BRL',
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
purchaseUrls: [],
|
purchaseUrls: [],
|
||||||
@@ -69,17 +69,34 @@ export default function ItemForm({ item, onSubmit, onCancel, mode, error }: Item
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<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">
|
||||||
Price
|
Quantidade
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
min={1}
|
||||||
|
required
|
||||||
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"
|
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 || ''}
|
value={formData.quantity ?? 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, quantity: Math.max(1, parseInt(e.target.value) || 1) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</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) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
price: e.target.value ? parseFloat(e.target.value) : null,
|
price: e.target.value === '' ? null : parseFloat(e.target.value) || null,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ export default function ItemForm({ initialData, onSubmit, onCancel, isEditing =
|
|||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: initialData?.name || '',
|
name: initialData?.name || '',
|
||||||
description: initialData?.description || '',
|
description: initialData?.description || '',
|
||||||
price: initialData?.price?.toString() || '',
|
|
||||||
currency: initialData?.currency || 'USD',
|
|
||||||
quantity: initialData?.quantity?.toString() || '1',
|
quantity: initialData?.quantity?.toString() || '1',
|
||||||
imageUrl: initialData?.imageUrl || '',
|
imageUrl: initialData?.imageUrl || '',
|
||||||
purchaseUrl: initialData?.purchaseUrls?.[0]?.url || '',
|
purchaseUrl: initialData?.purchaseUrls?.[0]?.url || '',
|
||||||
@@ -42,8 +40,6 @@ export default function ItemForm({ initialData, onSubmit, onCancel, isEditing =
|
|||||||
...prev,
|
...prev,
|
||||||
name: data.title || prev.name,
|
name: data.title || prev.name,
|
||||||
description: data.description || prev.description,
|
description: data.description || prev.description,
|
||||||
price: data.price?.toString() || prev.price,
|
|
||||||
currency: data.currency || prev.currency,
|
|
||||||
imageUrl: data.imageUrl || prev.imageUrl,
|
imageUrl: data.imageUrl || prev.imageUrl,
|
||||||
purchaseUrl: scrapeUrl,
|
purchaseUrl: scrapeUrl,
|
||||||
purchaseLabel: new URL(scrapeUrl).hostname.replace('www.', ''),
|
purchaseLabel: new URL(scrapeUrl).hostname.replace('www.', ''),
|
||||||
@@ -74,8 +70,6 @@ export default function ItemForm({ initialData, onSubmit, onCancel, isEditing =
|
|||||||
await onSubmit({
|
await onSubmit({
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
description: formData.description || null,
|
description: formData.description || null,
|
||||||
price: formData.price ? parseFloat(formData.price) : null,
|
|
||||||
currency: formData.currency,
|
|
||||||
quantity: parseInt(formData.quantity) || 1,
|
quantity: parseInt(formData.quantity) || 1,
|
||||||
imageUrl: formData.imageUrl || null,
|
imageUrl: formData.imageUrl || null,
|
||||||
purchaseUrls,
|
purchaseUrls,
|
||||||
@@ -155,49 +149,9 @@ export default function ItemForm({ initialData, onSubmit, onCancel, isEditing =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price & Quantity */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Price
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
|
|
||||||
value={formData.price}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
// Only allow empty string or valid numbers (including decimals)
|
|
||||||
if (value === '' || /^\d*\.?\d*$/.test(value)) {
|
|
||||||
setFormData((prev) => ({ ...prev, price: value }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Currency
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
|
|
||||||
value={formData.currency}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData((prev) => ({ ...prev, currency: e.target.value }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="USD">USD</option>
|
|
||||||
<option value="EUR">EUR</option>
|
|
||||||
<option value="GBP">GBP</option>
|
|
||||||
<option value="CAD">CAD</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Quantity
|
Quantidade
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
43
components/mars-planet.tsx
Normal file
43
components/mars-planet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
interface ShareButtonProps {
|
|
||||||
title?: string;
|
|
||||||
text?: string;
|
|
||||||
url?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ShareButton({
|
|
||||||
title = 'Veja a lista do chá de bebê!',
|
|
||||||
text = 'Dê uma olhada na lista de presentes do chá de bebê.',
|
|
||||||
url,
|
|
||||||
className = ''
|
|
||||||
}: ShareButtonProps) {
|
|
||||||
const [isSupported, setIsSupported] = useState(true);
|
|
||||||
|
|
||||||
const handleShare = async () => {
|
|
||||||
// Use current URL if not provided
|
|
||||||
const shareUrl = url || window.location.href;
|
|
||||||
|
|
||||||
// Check if Web Share API is supported
|
|
||||||
if (!navigator.share) {
|
|
||||||
setIsSupported(false);
|
|
||||||
// Fallback: copy to clipboard
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
|
||||||
alert('Link copiado!');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy:', err);
|
|
||||||
alert('Não foi possível compartilhar');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.share({
|
|
||||||
title,
|
|
||||||
text,
|
|
||||||
url: shareUrl,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
// User cancelled or share failed
|
|
||||||
if (err.name !== 'AbortError') {
|
|
||||||
console.error('Error sharing:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isSupported && typeof window !== 'undefined' && !navigator.clipboard) {
|
|
||||||
// Don't show button if neither share nor clipboard is supported
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleShare}
|
|
||||||
className={`inline-flex items-center px-6 py-3 border-2 border-indigo-600 dark:border-indigo-500 text-base font-semibold rounded-lg text-indigo-600 dark:text-indigo-400 bg-white dark:bg-gray-800 hover:bg-indigo-50 dark:hover:bg-gray-700 transition-all cursor-pointer ${className}`}
|
|
||||||
title="Compartilhar"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 mr-2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Compartilhar
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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
|
||||||
56
docs/skill-upload-lista.md
Normal file
56
docs/skill-upload-lista.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
name: upload-lista
|
||||||
|
description: Use when the user provides a list of products and quantities to add to the Chá do Martin wishlist at chadomartin.omeu.website. Triggers on "adicionar itens", "upload lista", "adicionar à lista do Martin", or when given a product/quantity table.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Upload Lista — Chá do Martin
|
||||||
|
|
||||||
|
Adds items to the "Chá do Martin" wishlist via the admin API.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
- **URL**: `https://chadomartin.omeu.website`
|
||||||
|
- **Wishlist ID**: `r7ug0ez62x7ljx8c870ofwcv`
|
||||||
|
- **Admin token**: read from `/home/adriano/chadebebe/.env` (key `ADMIN_TOKEN`)
|
||||||
|
- **Auth**: cookie `adm_token=<token>` sent with every request
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
The user will provide a list of items (name + quantity). For each item:
|
||||||
|
|
||||||
|
1. Load the admin token:
|
||||||
|
```bash
|
||||||
|
ADMIN_TOKEN=$(grep ADMIN_TOKEN /home/adriano/chadebebe/.env | cut -d= -f2)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. POST to the items endpoint:
|
||||||
|
```bash
|
||||||
|
curl -s -X POST \
|
||||||
|
"https://chadomartin.omeu.website/api/wishlists/r7ug0ez62x7ljx8c870ofwcv/items" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Cookie: adm_token=${ADMIN_TOKEN}" \
|
||||||
|
-d '{"name": "<PRODUCT NAME>", "quantity": <QTY>}'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check the response for `"id"` to confirm success. If it contains `"error"`, report it.
|
||||||
|
|
||||||
|
4. After all items are posted, print a summary table:
|
||||||
|
|
||||||
|
| Item | Qty | Status |
|
||||||
|
|------|-----|--------|
|
||||||
|
| ... | ... | ✓ / ✗ |
|
||||||
|
|
||||||
|
## Input formats accepted
|
||||||
|
|
||||||
|
- Free text: "3x fralda tamanho P, 2x body branco"
|
||||||
|
- Markdown table with Name/Quantidade columns
|
||||||
|
- Numbered list: "1. Fralda P — 3 unidades"
|
||||||
|
|
||||||
|
Parse whatever the user gives and map to `name` + `quantity` before calling the API.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `quantity` must be an integer ≥ 1 (defaults to 1 if omitted)
|
||||||
|
- `name` is required; `description`, `imageUrl`, `purchaseUrls` are optional — only include if the user provides them
|
||||||
|
- Items are appended to the end of the list (sortOrder is set automatically)
|
||||||
|
- Run all POSTs in parallel where possible to be fast
|
||||||
27
lib/api.ts
27
lib/api.ts
@@ -96,6 +96,29 @@ export const guestsApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface AdminClaim {
|
||||||
|
claimId: string;
|
||||||
|
quantity: number;
|
||||||
|
note: string | null;
|
||||||
|
isPurchased: boolean;
|
||||||
|
claimedAt: string;
|
||||||
|
itemId: string;
|
||||||
|
itemName: string;
|
||||||
|
itemQuantity: number;
|
||||||
|
wishlistSlug: string;
|
||||||
|
wishlistName: string;
|
||||||
|
guestId: string;
|
||||||
|
guestName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const claimsApi = {
|
||||||
|
async list() {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/admin/claims`, { credentials: 'include' });
|
||||||
|
const data = await handleResponse<{ success: true; claims: AdminClaim[] }>(response);
|
||||||
|
return data.claims;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Wishlist types
|
// Wishlist types
|
||||||
export interface Wishlist {
|
export interface Wishlist {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -310,8 +333,6 @@ export const claimingApi = {
|
|||||||
export interface ScrapedData {
|
export interface ScrapedData {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
price?: number;
|
|
||||||
currency?: string;
|
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,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