Compare commits
42 Commits
4864bf6304
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a28a3a489d | ||
|
|
653121921e | ||
|
|
78bef85c96 | ||
|
|
6c8e11c851 | ||
|
|
7ef1065971 | ||
|
|
9f6a7c15d9 | ||
|
|
eebb183d36 | ||
|
|
a0349aa9c4 | ||
|
|
b19a3fdf48 | ||
|
|
5548f5000f | ||
|
|
3df9a67b97 | ||
|
|
21e8b7e137 | ||
|
|
4b428ce272 | ||
|
|
23114637ac | ||
|
|
2726be337e | ||
|
|
cac2c223dd | ||
|
|
1e513c318f | ||
|
|
4e031a9d4d | ||
|
|
007aa35521 | ||
|
|
96d38301c4 | ||
|
|
31c912fc3d | ||
|
|
17117ed7b2 | ||
|
|
e96181a6f3 | ||
|
|
d33feced1d | ||
|
|
2418ab64cc | ||
|
|
c959cc8829 | ||
|
|
ce7731cebb | ||
|
|
ea0d2c9370 | ||
|
|
0832c0f9e5 | ||
|
|
8d9e7c0709 | ||
|
|
4f3017a02d | ||
|
|
7b29e39e9f | ||
|
|
e518e28957 | ||
|
|
844951c832 | ||
|
|
32f9403bd8 | ||
|
|
7b2e2cc3c5 | ||
|
|
f03e7aaf19 | ||
|
|
5596e3fa19 | ||
|
|
44d4dcbf7e | ||
|
|
4d4cdee9eb | ||
|
|
282e475562 | ||
|
|
e38473e88d |
14
.env.example
14
.env.example
@@ -1,12 +1,10 @@
|
|||||||
# Admin Credentials (REQUIRED)
|
# Admin Token (REQUIRED)
|
||||||
# Set a strong username and password for the admin account
|
# Long random string used to authenticate the admin via ?adm=<token> in the URL.
|
||||||
ADMIN_USERNAME=admin
|
# Generate with: openssl rand -hex 32
|
||||||
ADMIN_PASSWORD=changeme
|
ADMIN_TOKEN=replace-with-32-byte-random-hex
|
||||||
|
|
||||||
# JWT Secret (Optional - auto-generated if not provided)
|
# Public base URL used by the guest CLI when generating links
|
||||||
# For production, generate a secure random string:
|
PUBLIC_BASE_URL=http://localhost:3000
|
||||||
# openssl rand -base64 32
|
|
||||||
SECRET=
|
|
||||||
|
|
||||||
# Application Settings (Optional)
|
# Application Settings (Optional)
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|||||||
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.
|
||||||
23
CLAUDE.md
23
CLAUDE.md
@@ -49,17 +49,28 @@ UI strings are hardcoded in the source. The PT-BR localization lives in:
|
|||||||
- `app/layout.tsx` — `<html lang="pt-BR">` and metadata description
|
- `app/layout.tsx` — `<html lang="pt-BR">` and metadata description
|
||||||
- `app/[slug]/page.tsx` — `Intl.NumberFormat('pt-BR', ...)` for prices
|
- `app/[slug]/page.tsx` — `Intl.NumberFormat('pt-BR', ...)` for prices
|
||||||
- `components/share-button.tsx` — "Compartilhar" label
|
- `components/share-button.tsx` — "Compartilhar" label
|
||||||
- `app/lock/page.tsx`, `app/not-found.tsx`, `app/page.tsx` — page copy
|
- `app/not-found.tsx`, `app/page.tsx` — page copy
|
||||||
|
|
||||||
To switch language, update those files and rebuild the image.
|
To switch language, update those files and rebuild the image.
|
||||||
|
|
||||||
### Admin credentials
|
### Admin token
|
||||||
|
|
||||||
Set via environment variables in `docker-compose.yml`:
|
The admin authenticates by visiting the site with `?adm=<token>` once. The token is set as an env var:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- ADMIN_USERNAME=your_username
|
- ADMIN_TOKEN=long-random-hex-at-least-16-chars
|
||||||
- ADMIN_PASSWORD=your_password
|
```
|
||||||
|
|
||||||
|
The token is also accepted from the cookie `adm_token` after first visit.
|
||||||
|
|
||||||
|
### Guests
|
||||||
|
|
||||||
|
Each guest gets their own URL: `https://chadebebe.omeu.website/<slug>?usr=<token>`. Manage guests at `/admin/guests` or via CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run guest:create -- --name="Martin"
|
||||||
|
npm run guest:list
|
||||||
|
npm run guest:delete -- --id=<guest-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
@@ -80,7 +91,7 @@ cd chadebebe
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The `data/db/wishlist.db` snapshot committed in the repo will be used as the initial database. On first run the app writes `data/secrets.json` (JWT keys) — this file is gitignored and must not be committed.
|
The `data/db/wishlist.db` snapshot committed in the repo will be used as the initial database.
|
||||||
|
|
||||||
### Updating the app
|
### Updating the app
|
||||||
|
|
||||||
|
|||||||
@@ -3,32 +3,59 @@
|
|||||||
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 { 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 Footer from '@/components/footer';
|
import GuestGuard from '@/components/guest-guard';
|
||||||
import PasswordLockGuard from '@/components/password-lock-guard';
|
|
||||||
|
|
||||||
export default function PublicWishlistPage() {
|
export default function PublicWishlistPage() {
|
||||||
|
return (
|
||||||
|
<GuestGuard>
|
||||||
|
<PublicWishlistContent />
|
||||||
|
</GuestGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [showClaimed, setShowClaimed] = useState(false);
|
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('');
|
||||||
|
|
||||||
|
// Current viewer
|
||||||
|
const [currentGuestId, setCurrentGuestId] = useState<string | null>(null);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
|
||||||
// Claim form state
|
// Claim form state
|
||||||
const [claimingItemId, setClaimingItemId] = useState<string | null>(null);
|
const [claimingItemId, setClaimingItemId] = useState<string | null>(null);
|
||||||
const [claimNote, setClaimNote] = useState('');
|
const [claimNote, setClaimNote] = useState('');
|
||||||
|
const [claimQty, setClaimQty] = useState(1);
|
||||||
const [isClaiming, setIsClaiming] = useState(false);
|
const [isClaiming, setIsClaiming] = useState(false);
|
||||||
const [claimError, setClaimError] = useState('');
|
const [claimError, setClaimError] = useState('');
|
||||||
const [justClaimedItemId, setJustClaimedItemId] = useState<string | null>(null);
|
const [justClaimedItemId, setJustClaimedItemId] = useState<string | null>(null);
|
||||||
const [justClaimedNote, setJustClaimedNote] = useState('');
|
|
||||||
|
|
||||||
// Unclaim state
|
// Unclaim state
|
||||||
const [isUnclaiming, setIsUnclaiming] = useState(false);
|
const [isUnclaiming, setIsUnclaiming] = useState(false);
|
||||||
const [unclaimError, setUnclaimError] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const who = await authApi.whoami();
|
||||||
|
if (who.role === 'admin') {
|
||||||
|
setIsAdmin(true);
|
||||||
|
if (who.guest) setCurrentGuestId(who.guest.id);
|
||||||
|
} else if (who.role === 'guest') {
|
||||||
|
setCurrentGuestId(who.guest.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
settingsApi.getSettings().then(setSiteSettings).catch(() => {});
|
||||||
fetchWishlist();
|
fetchWishlist();
|
||||||
}, [params.slug]);
|
}, [params.slug]);
|
||||||
|
|
||||||
@@ -48,10 +75,14 @@ export default function PublicWishlistPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClaimItem = (itemId: string) => {
|
const myClaimFor = (item: Item) => item.claims.find((c) => c.guest.id === currentGuestId);
|
||||||
setClaimingItemId(itemId);
|
|
||||||
|
const handleStartClaim = (item: Item) => {
|
||||||
|
const my = myClaimFor(item);
|
||||||
|
setClaimingItemId(item.id);
|
||||||
setClaimError('');
|
setClaimError('');
|
||||||
setClaimNote('');
|
setClaimNote(my?.note ?? '');
|
||||||
|
setClaimQty(my?.quantity ?? 1);
|
||||||
setJustClaimedItemId(null);
|
setJustClaimedItemId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,12 +93,12 @@ export default function PublicWishlistPage() {
|
|||||||
setClaimError('');
|
setClaimError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await claimingApi.claim(itemId, undefined, claimNote);
|
await claimingApi.claim(itemId, { quantity: claimQty, note: claimNote });
|
||||||
|
|
||||||
setJustClaimedItemId(itemId);
|
setJustClaimedItemId(itemId);
|
||||||
setJustClaimedNote(claimNote);
|
|
||||||
setClaimingItemId(null);
|
setClaimingItemId(null);
|
||||||
setClaimNote('');
|
setClaimNote('');
|
||||||
|
setClaimQty(1);
|
||||||
fetchWishlist();
|
fetchWishlist();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setClaimError(err.message || 'Erro ao reservar item');
|
setClaimError(err.message || 'Erro ao reservar item');
|
||||||
@@ -76,35 +107,26 @@ export default function PublicWishlistPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnclaim = async (itemId: string) => {
|
const handleUnclaim = async (itemId: string, guestId?: string) => {
|
||||||
if (!confirm('Tem certeza que deseja cancelar a reserva deste item?')) {
|
if (!confirm('Cancelar a reserva?')) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsUnclaiming(true);
|
setIsUnclaiming(true);
|
||||||
setUnclaimError('');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await claimingApi.unclaim(itemId);
|
await claimingApi.unclaim(itemId, guestId ? { guestId } : {});
|
||||||
fetchWishlist();
|
fetchWishlist();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setUnclaimError(err.message || 'Erro ao cancelar reserva');
|
alert(err.message || 'Erro ao cancelar reserva');
|
||||||
} finally {
|
} finally {
|
||||||
setIsUnclaiming(false);
|
setIsUnclaiming(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredItems = showClaimed
|
const filteredItems = items.filter((item) => {
|
||||||
? items
|
const my = myClaimFor(item);
|
||||||
: items.filter((item) => !item.claimedAt || 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 (
|
||||||
@@ -126,8 +148,7 @@ export default function PublicWishlistPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PasswordLockGuard>
|
<div className="min-h-screen bg-cosmic">
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
|
||||||
<Header
|
<Header
|
||||||
title={wishlist.name}
|
title={wishlist.name}
|
||||||
subtitle={wishlist.description || undefined}
|
subtitle={wishlist.description || undefined}
|
||||||
@@ -138,26 +159,6 @@ export default function PublicWishlistPage() {
|
|||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="max-w-5xl mx-auto py-12 sm:px-6 lg:px-8">
|
<div className="max-w-5xl mx-auto py-12 sm:px-6 lg:px-8">
|
||||||
<div className="px-4 sm:px-0">
|
<div className="px-4 sm:px-0">
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
className="inline-flex items-center text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 mb-6 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<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="M10 19l-7-7m0 0l7-7m-7 7h18"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Voltar ao início
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{/* Preferences Section */}
|
{/* Preferences Section */}
|
||||||
{wishlist.preferences && (
|
{wishlist.preferences && (
|
||||||
<div className="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
@@ -168,7 +169,6 @@ export default function PublicWishlistPage() {
|
|||||||
className="prose prose-indigo dark:prose-invert max-w-none text-gray-700 dark:text-gray-300 [&_a]:text-indigo-600 [&_a]:dark:text-indigo-400 [&_a]:hover:underline"
|
className="prose prose-indigo dark:prose-invert max-w-none text-gray-700 dark:text-gray-300 [&_a]:text-indigo-600 [&_a]:dark:text-indigo-400 [&_a]:hover:underline"
|
||||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(wishlist.preferences) }}
|
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(wishlist.preferences) }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
// Make all links open in new tab
|
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName === 'A') {
|
if (target.tagName === 'A') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -180,64 +180,104 @@ export default function PublicWishlistPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="mb-6 flex items-center justify-between">
|
{siteSettings.claimingEnabled && (
|
||||||
<div className="flex items-center space-x-4">
|
<div className="mb-6 flex items-center justify-end">
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showClaimed}
|
|
||||||
onChange={(e) => setShowClaimed(e.target.checked)}
|
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
|
||||||
/>
|
|
||||||
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">Mostrar itens reservados</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{filteredItems.length} de {items.length} itens
|
{filteredItems.length} de {items.length} itens
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Items List */}
|
{/* Items List */}
|
||||||
{filteredItems.length === 0 ? (
|
{filteredItems.length === 0 ? (
|
||||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
{showClaimed ? 'Nenhum item nesta lista ainda' : 'Todos os itens já foram reservados!'}
|
Todos os itens já foram reservados!
|
||||||
</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 maxForMe = item.remainingQuantity + (myClaim?.quantity ?? 0);
|
||||||
|
const showQuantitySummary = item.quantity > 1 && siteSettings.showQuantity;
|
||||||
|
const sold = item.remainingQuantity === 0 && !myClaim;
|
||||||
|
const canClaim = siteSettings.claimingEnabled && Boolean(currentGuestId);
|
||||||
|
|
||||||
|
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 hover:scale-105 overflow-hidden"
|
className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-all duration-300 overflow-hidden flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col md:flex-row">
|
{/* Top: Image */}
|
||||||
{/* Left: Image */}
|
|
||||||
{item.imageUrl && (
|
{item.imageUrl && (
|
||||||
<div className="md:w-48 md:flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
src={item.imageUrl}
|
src={item.imageUrl}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="w-full h-48 md:h-full object-cover"
|
className="w-full h-48 object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Middle: Item Details */}
|
{/* Middle: Item Details */}
|
||||||
<div className="flex-1 p-6">
|
<div className="flex-1 p-5">
|
||||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-1">
|
||||||
{item.name}
|
{item.name}
|
||||||
</h3>
|
</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 && (
|
{item.description && (
|
||||||
<p className="text-base text-gray-600 dark:text-gray-300 mb-4">
|
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Existing claims list */}
|
||||||
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Right: Action Area */}
|
{/* Bottom: Action Area */}
|
||||||
<div className="md:w-80 md:flex-shrink-0 p-6 bg-gray-50 dark:bg-gray-900/50 border-t md:border-t-0 md:border-l border-gray-200 dark:border-gray-700 flex flex-col">
|
<div className="p-5 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex flex-col gap-3">
|
||||||
<div className="mb-4">
|
|
||||||
{item.purchaseUrls && item.purchaseUrls.length > 0 && (
|
{item.purchaseUrls && item.purchaseUrls.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{item.purchaseUrls.map((url, idx) => (
|
{item.purchaseUrls.map((url, idx) => (
|
||||||
@@ -246,70 +286,21 @@ export default function PublicWishlistPage() {
|
|||||||
href={url.url}
|
href={url.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center justify-between text-base px-4 py-3 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors cursor-pointer border border-gray-200 dark:border-gray-700"
|
className="flex items-center justify-between text-sm px-3 py-2 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors cursor-pointer border border-gray-200 dark:border-gray-700"
|
||||||
>
|
>
|
||||||
<span className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-medium">
|
<span className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-medium">
|
||||||
{url.label}
|
{url.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-900 dark:text-white font-bold text-lg">
|
|
||||||
{item.price && formatPrice(item.price, item.currency)}
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Claimed Badge, Success Message, or Claim Button/Form */}
|
{!canClaim ? null : sold ? (
|
||||||
<div className="mt-auto">
|
<div className="bg-gray-100 dark:bg-gray-700 rounded p-3 text-center text-sm text-gray-700 dark:text-gray-300">
|
||||||
{justClaimedItemId === item.id ? (
|
Esgotado
|
||||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
|
||||||
<div className="flex items-center justify-center mb-2">
|
|
||||||
<div className="w-12 h-12 bg-green-500 dark:bg-green-600 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-center text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
|
||||||
Item reservado!
|
|
||||||
</p>
|
|
||||||
<p className="text-center text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
O status está confirmado.
|
|
||||||
</p>
|
|
||||||
{justClaimedNote && (
|
|
||||||
<p className="text-center text-xs text-gray-600 dark:text-gray-400 italic">
|
|
||||||
Sua nota: "{justClaimedNote}"
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : item.claimedAt ? (
|
|
||||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded p-3">
|
|
||||||
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
|
||||||
Reservado por {item.claimedByName}
|
|
||||||
</p>
|
|
||||||
{item.claimedByNote && (
|
|
||||||
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
|
|
||||||
Nota: {item.claimedByNote}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{item.isPurchased && (
|
|
||||||
<p className="text-xs text-green-700 dark:text-green-300 mt-1 font-medium">
|
|
||||||
✓ Comprado
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{showClaimed && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleUnclaim(item.id)}
|
|
||||||
disabled={isUnclaiming}
|
|
||||||
className="mt-3 w-full px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 font-medium disabled:opacity-50 transition-colors cursor-pointer text-sm"
|
|
||||||
>
|
|
||||||
{isUnclaiming ? 'Cancelando...' : 'Cancelar reserva'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : claimingItemId === item.id ? (
|
) : claimingItemId === item.id ? (
|
||||||
<div className="space-y-3">
|
|
||||||
<form onSubmit={(e) => handleSubmitClaim(e, item.id)} className="space-y-3">
|
<form onSubmit={(e) => handleSubmitClaim(e, item.id)} className="space-y-3">
|
||||||
{claimError && (
|
{claimError && (
|
||||||
<div className="p-2 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded text-xs">
|
<div className="p-2 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded text-xs">
|
||||||
@@ -317,6 +308,23 @@ export default function PublicWishlistPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{item.quantity > 1 && siteSettings.showQuantity && (
|
||||||
|
<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>
|
<div>
|
||||||
<label htmlFor={`claim-note-${item.id}`} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<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):
|
Deixe uma nota (opcional):
|
||||||
@@ -324,7 +332,6 @@ export default function PublicWishlistPage() {
|
|||||||
<textarea
|
<textarea
|
||||||
id={`claim-note-${item.id}`}
|
id={`claim-note-${item.id}`}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Ex: 'Vou comprar na semana que vem' ou 'Achei uma boa promoção'"
|
|
||||||
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"
|
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}
|
value={claimNote}
|
||||||
onChange={(e) => setClaimNote(e.target.value)}
|
onChange={(e) => setClaimNote(e.target.value)}
|
||||||
@@ -336,30 +343,32 @@ export default function PublicWishlistPage() {
|
|||||||
disabled={isClaiming}
|
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"
|
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...' : 'Confirmar reserva'}
|
{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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleClaimItem(item.id)}
|
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"
|
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 font-medium transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
Vou dar este presente
|
{myClaim ? 'Atualizar reserva' : 'Reservar'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</div>
|
</div>
|
||||||
</PasswordLockGuard>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
app/admin/guests/page.tsx
Normal file
96
app/admin/guests/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import AdminGuard from '@/components/admin-guard';
|
||||||
|
import { guestsApi, type Guest } from '@/lib/api';
|
||||||
|
|
||||||
|
export default function AdminGuestsPage() {
|
||||||
|
return (
|
||||||
|
<AdminGuard>
|
||||||
|
<GuestsManager />
|
||||||
|
</AdminGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuestsManager() {
|
||||||
|
const [guests, setGuests] = useState<Guest[]>([]);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
const load = async () => setGuests(await guestsApi.list());
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const linkFor = (g: Guest) => {
|
||||||
|
const base = typeof window === 'undefined' ? '' : window.location.origin;
|
||||||
|
return `${base}/?usr=${g.id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErr('');
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await guestsApi.create(name.trim());
|
||||||
|
setName('');
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e.message || 'Erro ao criar');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRename = async (g: Guest) => {
|
||||||
|
const next = prompt('Novo nome', g.name);
|
||||||
|
if (!next || next.trim() === g.name) return;
|
||||||
|
await guestsApi.rename(g.id, next.trim());
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = async (g: Guest) => {
|
||||||
|
if (!confirm(`Excluir o convidado ${g.name}? As reservas dele(a) ficarão sem dono.`)) return;
|
||||||
|
await guestsApi.delete(g.id);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCopy = async (g: Guest) => {
|
||||||
|
await navigator.clipboard.writeText(linkFor(g));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Convidados</h1>
|
||||||
|
|
||||||
|
<form onSubmit={onCreate} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Nome do convidado (opcional)"
|
||||||
|
className="flex-1 border rounded px-3 py-2"
|
||||||
|
/>
|
||||||
|
<button disabled={busy} className="bg-indigo-600 text-white rounded px-4 py-2 disabled:opacity-50">
|
||||||
|
{busy ? 'Criando…' : 'Criar link'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{err && <div className="text-red-600 text-sm">{err}</div>}
|
||||||
|
|
||||||
|
<ul className="divide-y border rounded">
|
||||||
|
{guests.map((g) => (
|
||||||
|
<li key={g.id} className="p-3 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{g.name}</div>
|
||||||
|
<div className="text-xs text-gray-500 truncate">{linkFor(g)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<button onClick={() => onCopy(g)} className="text-sm border rounded px-2 py-1">Copiar link</button>
|
||||||
|
<button onClick={() => onRename(g)} className="text-sm border rounded px-2 py-1">Renomear</button>
|
||||||
|
<button onClick={() => onDelete(g)} className="text-sm border rounded px-2 py-1 text-red-600">Excluir</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{guests.length === 0 && <li className="p-3 text-gray-500 text-sm">Nenhum convidado ainda</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useAuth } from '@/lib/auth-context';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import type { ApiError } from '@/lib/api';
|
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const { login, isAuthenticated, isLoading: authLoading } = useAuth();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Redirect if already logged in
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authLoading && isAuthenticated) {
|
|
||||||
router.push('/admin');
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, authLoading, router]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError('');
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await login(username, password);
|
|
||||||
router.push('/admin');
|
|
||||||
} catch (err) {
|
|
||||||
const apiError = err as ApiError;
|
|
||||||
setError(apiError.message || 'Login failed');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show loading while checking auth status
|
|
||||||
if (authLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't render login form if already authenticated (will redirect)
|
|
||||||
if (isAuthenticated) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
|
||||||
{/* Hero Section */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm">
|
|
||||||
<div className="max-w-7xl mx-auto py-16 px-4 sm:py-24 sm:px-6 lg:px-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-gray-900 dark:text-white mb-4">
|
|
||||||
Admin Login
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl sm:text-2xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto mb-6">
|
|
||||||
Sign in to your account
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="inline-flex items-center text-base font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 transition-colors"
|
|
||||||
>
|
|
||||||
<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="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
||||||
</svg>
|
|
||||||
Back to Home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="max-w-4xl mx-auto py-12 sm:px-6 lg:px-8">
|
|
||||||
<div className="px-4 sm:px-0">
|
|
||||||
<div className="max-w-md mx-auto">
|
|
||||||
<form className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-100 dark:border-gray-700 p-8 space-y-6" onSubmit={handleSubmit}>
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-4">
|
|
||||||
<p className="text-sm text-red-800 dark:text-red-400">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="username" className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Username
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
autoComplete="username"
|
|
||||||
required
|
|
||||||
className="appearance-none block w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-white text-base"
|
|
||||||
placeholder="admin"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
className="appearance-none block w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-white text-base"
|
|
||||||
placeholder="Password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full flex justify-center px-6 py-3 border border-transparent text-base font-semibold rounded-lg text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg transition-all"
|
|
||||||
>
|
|
||||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,147 +1,242 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import ProtectedRoute from '@/components/protected-route';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import AdminGuard from '@/components/admin-guard';
|
||||||
import { 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 Footer from '@/components/footer';
|
|
||||||
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() {
|
||||||
const { logout } = useAuth();
|
return (
|
||||||
const [wishlists, setWishlists] = useState<Wishlist[]>([]);
|
<AdminGuard>
|
||||||
const [itemCounts, setItemCounts] = useState<Record<string, number>>({});
|
<AdminPageContent />
|
||||||
|
</AdminGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminPageContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 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: '',
|
||||||
passwordLockEnabled: false,
|
claimingEnabled: true,
|
||||||
|
showQuantity: true,
|
||||||
});
|
});
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [adminToken, setAdminToken] = useState<string | null>(null);
|
||||||
const [createError, setCreateError] = useState('');
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
// Unified config edit state
|
||||||
fetchWishlists();
|
const [isEditingWishlist, setIsEditingWishlist] = useState(false);
|
||||||
fetchSettings();
|
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);
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
// Item management state
|
||||||
try {
|
const [editingItemId, setEditingItemId] = useState<string | null>(null);
|
||||||
const data = await settingsApi.getSettings();
|
const [showAddItemForm, setShowAddItemForm] = useState(false);
|
||||||
setSettings(data);
|
const [newItemError, setNewItemError] = useState('');
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch settings:', error);
|
const logout = async () => {
|
||||||
}
|
await authApi.logout();
|
||||||
|
router.push('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchWishlists = async () => {
|
useEffect(() => {
|
||||||
try {
|
fetchData();
|
||||||
const data = await wishlistsApi.getAll();
|
fetchAdminToken();
|
||||||
setWishlists(data);
|
}, []);
|
||||||
|
|
||||||
// Fetch item counts for each wishlist
|
const fetchAdminToken = async () => {
|
||||||
const counts: Record<string, number> = {};
|
try {
|
||||||
await Promise.all(
|
const res = await fetch('/api/admin/access-link', { credentials: 'include' });
|
||||||
data.map(async (w) => {
|
if (res.ok) {
|
||||||
const items = await itemsApi.getAll(w.id);
|
const data = await res.json();
|
||||||
counts[w.id] = items.length;
|
setAdminToken(data.token);
|
||||||
})
|
}
|
||||||
);
|
} catch { /* ignore */ }
|
||||||
setItemCounts(counts);
|
};
|
||||||
|
|
||||||
|
const copyAdminLink = async () => {
|
||||||
|
if (!adminToken) return;
|
||||||
|
const url = `${window.location.origin}/?adm=${adminToken}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setLinkCopied(true);
|
||||||
|
setTimeout(() => setLinkCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const [lists, settingsData] = await Promise.all([
|
||||||
|
wishlistsApi.getAll(),
|
||||||
|
settingsApi.getSettings(),
|
||||||
|
]);
|
||||||
|
setSettings(settingsData);
|
||||||
|
if (lists.length > 0) {
|
||||||
|
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 (
|
||||||
<ProtectedRoute>
|
|
||||||
<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="Manage your wishlists and items"
|
subtitle={wishlist ? `${items.length} itens` : undefined}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<ShareButton
|
|
||||||
title="Check out my wishlist site!"
|
|
||||||
text="I wanted to share my wishlist site with you."
|
|
||||||
url="https://wishlist.tieso.co/"
|
|
||||||
/>
|
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
View Public Site
|
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>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
@@ -161,78 +256,283 @@ export default function AdminPage() {
|
|||||||
<div className="px-4 sm:px-0">
|
<div className="px-4 sm:px-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
|
<p className="text-gray-600 dark:text-gray-400">Carregando...</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Stats Grid */}
|
{/* Unified Settings & Wishlist Config */}
|
||||||
<StatsGrid stats={stats} />
|
{wishlist && (
|
||||||
|
<div className="mb-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="p-5 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Configurações</h2>
|
||||||
|
{!isEditingWishlist && (
|
||||||
|
<button
|
||||||
|
onClick={startEditingWishlist}
|
||||||
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
{isEditingWishlist ? (
|
||||||
|
<form onSubmit={handleUpdateWishlist} className="space-y-4">
|
||||||
|
{editError && (
|
||||||
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded-lg text-base">
|
||||||
|
{editError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Título do site *
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Settings Section */}
|
{/* Items Section */}
|
||||||
<SettingsSection settings={settings} onUpdate={handleUpdateSettings} />
|
{wishlist && (
|
||||||
|
|
||||||
{/* 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}
|
|
||||||
onItemsChange={fetchWishlists}
|
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<ItemCard
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!wishlist && !isLoading && (
|
||||||
|
<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 text-lg">
|
||||||
|
Nenhuma lista encontrada no banco de dados.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Modal */}
|
|
||||||
<CreateWishlistModal
|
|
||||||
isOpen={showCreateModal}
|
|
||||||
onClose={() => {
|
|
||||||
setShowCreateModal(false);
|
|
||||||
setCreateError('');
|
|
||||||
}}
|
|
||||||
onCreate={handleCreateWishlist}
|
|
||||||
error={createError}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</div>
|
</div>
|
||||||
</ProtectedRoute>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } 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(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -8,6 +9,8 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
const isAdmin = verifyAdminToken(request);
|
||||||
|
const guest = await getGuestFromRequest(request);
|
||||||
|
|
||||||
const wishlist = await db
|
const wishlist = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -22,11 +25,11 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only return public wishlists
|
// Public wishlists can be viewed anonymously; private wishlists require admin or guest access.
|
||||||
if (!wishlist[0].isPublic) {
|
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 });
|
||||||
|
}
|
||||||
39
app/api/admin/guests/[id]/route.ts
Normal file
39
app/api/admin/guests/[id]/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db, guests } from '@/lib/db';
|
||||||
|
import { verifyAdminToken } from '@/lib/auth/tokens';
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const name = (body?.name ?? '').toString().trim();
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json({ error: 'name is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const [row] = await db
|
||||||
|
.update(guests)
|
||||||
|
.set({ name, updatedAt: new Date() })
|
||||||
|
.where(eq(guests.id, id))
|
||||||
|
.returning();
|
||||||
|
if (!row) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
return NextResponse.json({ success: true, guest: row });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
const result = await db.delete(guests).where(eq(guests.id, id)).returning();
|
||||||
|
if (result.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
22
app/api/admin/guests/route.ts
Normal file
22
app/api/admin/guests/route.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { desc } from 'drizzle-orm';
|
||||||
|
import { db, 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().from(guests).orderBy(desc(guests.createdAt));
|
||||||
|
return NextResponse.json({ success: true, guests: rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const name = (body?.name ?? '').toString().trim() || 'Convidado';
|
||||||
|
const [row] = await db.insert(guests).values({ name }).returning();
|
||||||
|
return NextResponse.json({ success: true, guest: row }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { generateAccessToken, generateRefreshToken, validateAdminCredentials, isSecureCookie } from '@/lib/auth/utils';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const { username, password } = body;
|
|
||||||
|
|
||||||
if (!username || !password) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Username and password are required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate credentials
|
|
||||||
if (!validateAdminCredentials(username, password)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid credentials' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate tokens
|
|
||||||
const accessToken = generateAccessToken(username);
|
|
||||||
const refreshToken = generateRefreshToken(username);
|
|
||||||
|
|
||||||
// Create response
|
|
||||||
const response = NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
user: { username },
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set cookies
|
|
||||||
const cookieOptions = {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: isSecureCookie(request),
|
|
||||||
sameSite: 'lax' as const,
|
|
||||||
path: '/',
|
|
||||||
};
|
|
||||||
|
|
||||||
response.cookies.set('access_token', accessToken, {
|
|
||||||
...cookieOptions,
|
|
||||||
maxAge: 72 * 60 * 60, // 72 hours
|
|
||||||
});
|
|
||||||
|
|
||||||
response.cookies.set('refresh_token', refreshToken, {
|
|
||||||
...cookieOptions,
|
|
||||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Login failed' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { ADM_COOKIE, USR_COOKIE, buildClearCookie, isSecureCookie } from '@/lib/auth/cookies';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const response = NextResponse.json({
|
const secure = isSecureCookie(request);
|
||||||
success: true,
|
const headers = new Headers();
|
||||||
message: 'Logged out successfully'
|
headers.append('Set-Cookie', buildClearCookie(ADM_COOKIE, secure));
|
||||||
});
|
headers.append('Set-Cookie', buildClearCookie(USR_COOKIE, secure));
|
||||||
|
return NextResponse.json({ success: true }, { headers });
|
||||||
response.cookies.delete('access_token');
|
|
||||||
response.cookies.delete('refresh_token');
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { verifyAccessToken } from '@/lib/auth/utils';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const token = request.cookies.get('access_token')?.value;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Not authenticated' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = verifyAccessToken(token);
|
|
||||||
if (!payload) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid or expired token' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
user: { username: payload.username },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Auth error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Authentication failed' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { generateAccessToken, generateRefreshToken, verifyRefreshToken, isSecureCookie } from '@/lib/auth/utils';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Get refresh token from cookie or body
|
|
||||||
let refreshToken: string | undefined;
|
|
||||||
|
|
||||||
const cookieToken = request.cookies.get('refresh_token')?.value;
|
|
||||||
if (cookieToken) {
|
|
||||||
refreshToken = cookieToken;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
refreshToken = body.refreshToken;
|
|
||||||
} catch {
|
|
||||||
// No body or invalid JSON, continue without it
|
|
||||||
refreshToken = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!refreshToken) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'No refresh token provided' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify refresh token
|
|
||||||
const payload = verifyRefreshToken(refreshToken);
|
|
||||||
if (!payload) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid or expired refresh token' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new tokens
|
|
||||||
const newAccessToken = generateAccessToken(payload.username);
|
|
||||||
const newRefreshToken = generateRefreshToken(payload.username);
|
|
||||||
|
|
||||||
// Create response
|
|
||||||
const response = NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
accessToken: newAccessToken,
|
|
||||||
refreshToken: newRefreshToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set new cookies
|
|
||||||
const cookieOptions = {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: isSecureCookie(request),
|
|
||||||
sameSite: 'lax' as const,
|
|
||||||
path: '/',
|
|
||||||
};
|
|
||||||
|
|
||||||
response.cookies.set('access_token', newAccessToken, {
|
|
||||||
...cookieOptions,
|
|
||||||
maxAge: 72 * 60 * 60, // 72 hours
|
|
||||||
});
|
|
||||||
|
|
||||||
response.cookies.set('refresh_token', newRefreshToken, {
|
|
||||||
...cookieOptions,
|
|
||||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Refresh error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Token refresh failed' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
50
app/api/auth/session/route.ts
Normal file
50
app/api/auth/session/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { isAdminTokenValue, isGuestTokenValue, getGuestFromRequest, verifyAdminToken } from '@/lib/auth/tokens';
|
||||||
|
import { ADM_COOKIE, USR_COOKIE, buildCookie, isSecureCookie } from '@/lib/auth/cookies';
|
||||||
|
|
||||||
|
const ONE_YEAR = 60 * 60 * 24 * 365;
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const { adm, usr } = body as { adm?: string; usr?: string };
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
const secure = isSecureCookie(request);
|
||||||
|
const cookies: string[] = [];
|
||||||
|
let role: 'admin' | 'guest' | 'none' = 'none';
|
||||||
|
let guestId: string | null = null;
|
||||||
|
|
||||||
|
if (adm) {
|
||||||
|
if (!isAdminTokenValue(adm)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid admin token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
cookies.push(buildCookie(ADM_COOKIE, adm, secure, ONE_YEAR));
|
||||||
|
role = 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usr) {
|
||||||
|
const guest = await isGuestTokenValue(usr);
|
||||||
|
if (!guest) {
|
||||||
|
return NextResponse.json({ error: 'Invalid guest token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
cookies.push(buildCookie(USR_COOKIE, usr, secure, ONE_YEAR));
|
||||||
|
if (role !== 'admin') role = 'guest';
|
||||||
|
guestId = guest.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cookies.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No token provided' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of cookies) headers.append('Set-Cookie', c);
|
||||||
|
return NextResponse.json({ success: true, role, guestId }, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const isAdmin = verifyAdminToken(request);
|
||||||
|
const guest = await getGuestFromRequest(request);
|
||||||
|
|
||||||
|
if (isAdmin) return NextResponse.json({ role: 'admin', guest: guest ? { id: guest.id, name: guest.name } : null });
|
||||||
|
if (guest) return NextResponse.json({ role: 'guest', guest: { id: guest.id, name: guest.name } });
|
||||||
|
return NextResponse.json({ role: 'none' });
|
||||||
|
}
|
||||||
@@ -1,28 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db, wishlistItems } from '@/lib/db';
|
import { db, wishlistItems } from '@/lib/db';
|
||||||
import { verifyAccessToken } from '@/lib/auth/utils';
|
import { verifyAdminToken } from '@/lib/auth/tokens';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const token = request.cookies.get('access_token')?.value;
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Not authenticated' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = verifyAccessToken(token);
|
|
||||||
if (!payload) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid or expired token' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -103,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];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db, wishlistItems, wishlists } from '@/lib/db';
|
import { db, wishlistItems, wishlists } from '@/lib/db';
|
||||||
import { verifyAccessToken } from '@/lib/auth/utils';
|
import { verifyAdminToken, getGuestFromRequest } from '@/lib/auth/tokens';
|
||||||
|
import { attachClaimsToItems } from '@/lib/items-with-claims';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -10,11 +11,6 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
// Check for auth token
|
|
||||||
const token = request.cookies.get('access_token')?.value;
|
|
||||||
const payload = token ? verifyAccessToken(token) : null;
|
|
||||||
const isAuthenticated = payload !== null;
|
|
||||||
|
|
||||||
// Get item
|
// Get item
|
||||||
const item = await db
|
const item = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -43,17 +39,21 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check permissions
|
const isAdmin = verifyAdminToken(request);
|
||||||
if (!wishlist[0].isPublic && !isAuthenticated) {
|
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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [withClaims] = await attachClaimsToItems(item);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
item: item[0],
|
item: withClaims,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching item:', error);
|
console.error('Error fetching item:', error);
|
||||||
@@ -69,21 +69,8 @@ export async function PATCH(
|
|||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const token = request.cookies.get('access_token')?.value;
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Not authenticated' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = verifyAccessToken(token);
|
|
||||||
if (!payload) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid or expired token' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -114,13 +101,13 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build update object (only include provided fields)
|
// Build update object (only include provided fields)
|
||||||
const updateData: any = {
|
const updateData: Record<string, unknown> = {
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
@@ -152,21 +139,8 @@ export async function DELETE(
|
|||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const token = request.cookies.get('access_token')?.value;
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Not authenticated' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = verifyAccessToken(token);
|
|
||||||
if (!payload) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid or expired token' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { db, settings } from '@/lib/db';
|
|
||||||
import crypto from 'crypto';
|
|
||||||
import { isSecureCookie } from '@/lib/auth/utils';
|
|
||||||
|
|
||||||
// POST /api/lock - Verify password
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const { password } = body;
|
|
||||||
|
|
||||||
if (!password) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Password is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the stored password hash
|
|
||||||
const hashSetting = await db
|
|
||||||
.select()
|
|
||||||
.from(settings)
|
|
||||||
.where(eq(settings.key, 'passwordLockHash'))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (hashSetting.length === 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Password lock not configured' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash the provided password
|
|
||||||
const hash = crypto.createHash('sha256').update(password).digest('hex');
|
|
||||||
|
|
||||||
// Compare hashes
|
|
||||||
if (hash === hashSetting[0].value) {
|
|
||||||
// Password correct - set a cookie
|
|
||||||
const response = NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Password verified',
|
|
||||||
});
|
|
||||||
|
|
||||||
response.cookies.set('site_unlocked', 'true', {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: isSecureCookie(request),
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: 60 * 60 * 24,
|
|
||||||
path: '/',
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} else {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Incorrect password' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error verifying password:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to verify password' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +1,135 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { eq } from 'drizzle-orm';
|
import { and, eq, ne, sql } from 'drizzle-orm';
|
||||||
import { db, wishlistItems, wishlists } from '@/lib/db';
|
import { db, wishlistItems, wishlists, itemClaims, guests, settings } from '@/lib/db';
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
import { getGuestFromRequest } from '@/lib/auth/tokens';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const guest = await getGuestFromRequest(request);
|
||||||
const body = await request.json();
|
if (!guest) {
|
||||||
const { name, note } = body;
|
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
|
||||||
|
|
||||||
// Get the item
|
|
||||||
const item = await db
|
|
||||||
.select()
|
|
||||||
.from(wishlistItems)
|
|
||||||
.where(eq(wishlistItems.id, id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (item.length === 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Item not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if item is already claimed
|
const { id } = await params;
|
||||||
if (item[0].claimedByToken) {
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const requestedQty = Math.max(1, Math.floor(Number(body?.quantity ?? 1)));
|
||||||
|
const note = (body?.note ?? '').toString().trim() || null;
|
||||||
|
|
||||||
|
const itemRows = await db.select().from(wishlistItems).where(eq(wishlistItems.id, id)).limit(1);
|
||||||
|
if (itemRows.length === 0) return NextResponse.json({ error: 'Item not found' }, { status: 404 });
|
||||||
|
const item = itemRows[0];
|
||||||
|
|
||||||
|
const wl = await db.select().from(wishlists).where(eq(wishlists.id, item.wishlistId)).limit(1);
|
||||||
|
if (wl.length === 0) return NextResponse.json({ error: 'Wishlist not found' }, { status: 404 });
|
||||||
|
if (!wl[0].isPublic) return NextResponse.json({ error: 'This wishlist is private' }, { status: 403 });
|
||||||
|
|
||||||
|
const claimingSetting = await db.select().from(settings).where(eq(settings.key, 'claimingEnabled')).limit(1);
|
||||||
|
if (claimingSetting[0]?.value === 'false') {
|
||||||
|
return NextResponse.json({ error: 'Reservas desativadas' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically check remaining quantity and upsert the claim inside a synchronous
|
||||||
|
// transaction to prevent race conditions (better-sqlite3 is synchronous).
|
||||||
|
type TxResult = { ok: true } | { ok: false; remaining: number };
|
||||||
|
|
||||||
|
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: 'Item is already claimed' },
|
{ error: `Apenas ${txResult.remaining} disponível(is)`, remaining: txResult.remaining },
|
||||||
{ status: 409 }
|
{ status: 409 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if wishlist is public
|
const updated = await fetchItemWithClaims(id);
|
||||||
const wishlist = await db
|
return NextResponse.json({ success: true, item: updated }, { status: 201 });
|
||||||
.select()
|
} catch (err) {
|
||||||
.from(wishlists)
|
console.error('Error claiming item:', err);
|
||||||
.where(eq(wishlists.id, item[0].wishlistId))
|
return NextResponse.json({ error: 'Failed to claim item' }, { status: 500 });
|
||||||
.limit(1);
|
}
|
||||||
|
|
||||||
if (wishlist.length === 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Wishlist not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!wishlist[0].isPublic) {
|
async function fetchItemWithClaims(itemId: string) {
|
||||||
return NextResponse.json(
|
const itemRows = await db.select().from(wishlistItems).where(eq(wishlistItems.id, itemId)).limit(1);
|
||||||
{ error: 'This wishlist is private' },
|
if (itemRows.length === 0) return null;
|
||||||
{ status: 403 }
|
const claims = await db
|
||||||
);
|
.select({
|
||||||
}
|
id: itemClaims.id,
|
||||||
|
quantity: itemClaims.quantity,
|
||||||
// Generate unique claim token
|
note: itemClaims.note,
|
||||||
const claimToken = createId();
|
isPurchased: itemClaims.isPurchased,
|
||||||
|
claimedAt: itemClaims.claimedAt,
|
||||||
// Update item with claim information
|
guestId: guests.id,
|
||||||
const updatedItem = await db
|
guestName: guests.name,
|
||||||
.update(wishlistItems)
|
|
||||||
.set({
|
|
||||||
claimedByName: name || null,
|
|
||||||
claimedByNote: note || null,
|
|
||||||
claimedByToken: claimToken,
|
|
||||||
claimedAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
})
|
||||||
.where(eq(wishlistItems.id, id))
|
.from(itemClaims)
|
||||||
.returning();
|
.innerJoin(guests, eq(itemClaims.guestId, guests.id))
|
||||||
|
.where(eq(itemClaims.itemId, itemId));
|
||||||
return NextResponse.json(
|
const claimedQuantity = claims.reduce((s, c) => s + c.quantity, 0);
|
||||||
{
|
return {
|
||||||
success: true,
|
...itemRows[0],
|
||||||
claimToken,
|
claims: claims.map((c) => ({
|
||||||
item: updatedItem[0],
|
id: c.id,
|
||||||
},
|
quantity: c.quantity,
|
||||||
{ status: 201 }
|
note: c.note,
|
||||||
);
|
isPurchased: c.isPurchased,
|
||||||
} catch (error) {
|
claimedAt: c.claimedAt,
|
||||||
console.error('Error claiming item:', error);
|
guest: { id: c.guestId, name: c.guestName },
|
||||||
return NextResponse.json(
|
})),
|
||||||
{ error: 'Failed to claim item' },
|
claimedQuantity,
|
||||||
{ status: 500 }
|
remainingQuantity: Math.max(0, itemRows[0].quantity - claimedQuantity),
|
||||||
);
|
};
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +1,50 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { db, wishlistItems, wishlists } from '@/lib/db';
|
import { db, wishlistItems, wishlists, itemClaims } from '@/lib/db';
|
||||||
|
import { getGuestFromRequest, verifyAdminToken } from '@/lib/auth/tokens';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const guest = await getGuestFromRequest(request);
|
||||||
|
const isAdmin = verifyAdminToken(request);
|
||||||
|
if (!guest && !isAdmin) {
|
||||||
|
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
|
||||||
// Get the item
|
const targetGuestId = isAdmin && typeof body?.guestId === 'string' ? body.guestId : guest?.id;
|
||||||
const item = await db
|
if (!targetGuestId) {
|
||||||
.select()
|
return NextResponse.json({ error: 'Missing guest' }, { status: 400 });
|
||||||
.from(wishlistItems)
|
|
||||||
.where(eq(wishlistItems.id, id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (item.length === 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Item not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if item is actually claimed
|
const itemRows = await db.select().from(wishlistItems).where(eq(wishlistItems.id, id)).limit(1);
|
||||||
if (!item[0].claimedByToken) {
|
if (itemRows.length === 0) return NextResponse.json({ error: 'Item not found' }, { status: 404 });
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Item is not claimed' },
|
const wl = await db.select().from(wishlists).where(eq(wishlists.id, itemRows[0].wishlistId)).limit(1);
|
||||||
{ status: 400 }
|
if (wl.length === 0) return NextResponse.json({ error: 'Wishlist not found' }, { status: 404 });
|
||||||
);
|
if (!wl[0].isPublic && !isAdmin) {
|
||||||
|
return NextResponse.json({ error: 'This wishlist is private' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if wishlist is public
|
const result = await db
|
||||||
const wishlist = await db
|
.delete(itemClaims)
|
||||||
.select()
|
.where(and(eq(itemClaims.itemId, id), eq(itemClaims.guestId, targetGuestId)))
|
||||||
.from(wishlists)
|
|
||||||
.where(eq(wishlists.id, item[0].wishlistId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (wishlist.length === 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Wishlist not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!wishlist[0].isPublic) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'This wishlist is private' },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove claim information (honor system - no verification)
|
|
||||||
const updatedItem = await db
|
|
||||||
.update(wishlistItems)
|
|
||||||
.set({
|
|
||||||
claimedByName: null,
|
|
||||||
claimedByNote: null,
|
|
||||||
claimedByToken: null,
|
|
||||||
claimedAt: null,
|
|
||||||
isPurchased: false,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(wishlistItems.id, id))
|
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return NextResponse.json(
|
if (result.length === 0) {
|
||||||
{
|
return NextResponse.json({ error: 'Reserva não encontrada' }, { status: 404 });
|
||||||
success: true,
|
}
|
||||||
message: 'Item unclaimed successfully',
|
|
||||||
item: updatedItem[0],
|
await db.update(wishlistItems).set({ updatedAt: new Date() }).where(eq(wishlistItems.id, id));
|
||||||
},
|
|
||||||
{ status: 200 }
|
return NextResponse.json({ success: true });
|
||||||
);
|
} catch (err) {
|
||||||
} catch (error) {
|
console.error('Error unclaiming item:', err);
|
||||||
console.error('Error unclaiming item:', error);
|
return NextResponse.json({ error: 'Failed to unclaim item' }, { status: 500 });
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to unclaim item' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
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';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
// Fetch only public wishlists
|
// Fetch only public wishlists
|
||||||
const publicWishlists = await db
|
const publicWishlists = await db
|
||||||
|
|||||||
@@ -1,24 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { verifyAccessToken } from '@/lib/auth/utils';
|
import { verifyAdminToken } from '@/lib/auth/tokens';
|
||||||
import { scrapeUrl } from '@/lib/scraping/service';
|
import { scrapeUrl } from '@/lib/scraping/service';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const token = request.cookies.get('access_token')?.value;
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Not authenticated' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = verifyAccessToken(token);
|
|
||||||
if (!payload) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid or expired token' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|||||||
@@ -1,34 +1,31 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db, settings } from '@/lib/db';
|
import { db, settings } from '@/lib/db';
|
||||||
import { verifyAccessToken } from '@/lib/auth/utils';
|
import { verifyAdminToken } from '@/lib/auth/tokens';
|
||||||
import crypto from 'crypto';
|
|
||||||
|
|
||||||
// GET /api/settings - Get all settings (public endpoint for reading only)
|
// GET /api/settings - Get all settings (public endpoint for reading only)
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const allSettings = await db.select().from(settings);
|
const allSettings = await db.select().from(settings);
|
||||||
|
|
||||||
// Convert to key-value object
|
|
||||||
const settingsObj = allSettings.reduce((acc, setting) => {
|
const settingsObj = allSettings.reduce((acc, setting) => {
|
||||||
acc[setting.key] = setting.value;
|
acc[setting.key] = setting.value;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, string | boolean>);
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
// Set defaults if not found
|
if (!settingsObj.siteTitle) settingsObj.siteTitle = 'Wishlist';
|
||||||
if (!settingsObj.siteTitle) {
|
if (!settingsObj.homepageSubtext) settingsObj.homepageSubtext = 'Browse and explore available wishlists';
|
||||||
settingsObj.siteTitle = 'Wishlist';
|
if (settingsObj.claimingEnabled === undefined) settingsObj.claimingEnabled = 'true';
|
||||||
}
|
if (settingsObj.showQuantity === undefined) settingsObj.showQuantity = 'true';
|
||||||
if (!settingsObj.homepageSubtext) {
|
|
||||||
settingsObj.homepageSubtext = 'Browse and explore available wishlists';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert passwordLockEnabled to boolean
|
|
||||||
(settingsObj as any).passwordLockEnabled = settingsObj.passwordLockEnabled === 'true';
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
settings: settingsObj,
|
settings: {
|
||||||
|
siteTitle: settingsObj.siteTitle,
|
||||||
|
homepageSubtext: settingsObj.homepageSubtext,
|
||||||
|
claimingEnabled: settingsObj.claimingEnabled !== 'false',
|
||||||
|
showQuantity: settingsObj.showQuantity !== 'false',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching settings:', error);
|
console.error('Error fetching settings:', error);
|
||||||
@@ -42,27 +39,13 @@ export async function GET(request: NextRequest) {
|
|||||||
// PUT /api/settings - Update settings (admin only)
|
// PUT /api/settings - Update settings (admin only)
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const token = request.cookies.get('access_token')?.value;
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Not authenticated' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = verifyAccessToken(token);
|
|
||||||
if (!payload) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid or expired token' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { siteTitle, homepageSubtext, passwordLockEnabled, passwordLock } = body;
|
const { siteTitle, homepageSubtext, claimingEnabled, showQuantity } = body;
|
||||||
|
|
||||||
// Update or insert siteTitle
|
|
||||||
if (siteTitle !== undefined) {
|
if (siteTitle !== undefined) {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -83,7 +66,6 @@ export async function PUT(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update or insert homepageSubtext
|
|
||||||
if (homepageSubtext !== undefined) {
|
if (homepageSubtext !== undefined) {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -104,50 +86,16 @@ export async function PUT(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update or insert passwordLockEnabled
|
for (const [key, val] of [['claimingEnabled', claimingEnabled], ['showQuantity', showQuantity]] as const) {
|
||||||
if (passwordLockEnabled !== undefined) {
|
if (val !== undefined) {
|
||||||
const value = passwordLockEnabled ? 'true' : 'false';
|
const strVal = val ? 'true' : 'false';
|
||||||
const existing = await db
|
const existing = await db.select().from(settings).where(eq(settings.key, key)).limit(1);
|
||||||
.select()
|
|
||||||
.from(settings)
|
|
||||||
.where(eq(settings.key, 'passwordLockEnabled'))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
await db
|
await db.update(settings).set({ value: strVal, updatedAt: new Date() }).where(eq(settings.key, key));
|
||||||
.update(settings)
|
|
||||||
.set({ value, updatedAt: new Date() })
|
|
||||||
.where(eq(settings.key, 'passwordLockEnabled'));
|
|
||||||
} else {
|
} else {
|
||||||
await db.insert(settings).values({
|
await db.insert(settings).values({ key, value: strVal });
|
||||||
key: 'passwordLockEnabled',
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update password hash if provided
|
|
||||||
if (passwordLock && passwordLock.trim() !== '') {
|
|
||||||
// Hash the password using SHA-256
|
|
||||||
const hash = crypto.createHash('sha256').update(passwordLock).digest('hex');
|
|
||||||
|
|
||||||
const existing = await db
|
|
||||||
.select()
|
|
||||||
.from(settings)
|
|
||||||
.where(eq(settings.key, 'passwordLockHash'))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existing.length > 0) {
|
|
||||||
await db
|
|
||||||
.update(settings)
|
|
||||||
.set({ value: hash, updatedAt: new Date() })
|
|
||||||
.where(eq(settings.key, 'passwordLockHash'));
|
|
||||||
} else {
|
|
||||||
await db.insert(settings).values({
|
|
||||||
key: 'passwordLockHash',
|
|
||||||
value: hash,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { eq, and, desc } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
import { db, wishlistItems, wishlists } from '@/lib/db';
|
import { db, wishlistItems, wishlists } from '@/lib/db';
|
||||||
import { verifyAccessToken } from '@/lib/auth/utils';
|
import { verifyAdminToken, getGuestFromRequest } from '@/lib/auth/tokens';
|
||||||
|
import { attachClaimsToItems } from '@/lib/items-with-claims';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -10,11 +11,6 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
// Check for auth token
|
|
||||||
const token = request.cookies.get('access_token')?.value;
|
|
||||||
const payload = token ? verifyAccessToken(token) : null;
|
|
||||||
const isAuthenticated = payload !== null;
|
|
||||||
|
|
||||||
// Check if wishlist exists
|
// Check if wishlist exists
|
||||||
const wishlist = await db
|
const wishlist = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -29,20 +25,23 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check permissions
|
const isAdmin = verifyAdminToken(request);
|
||||||
if (!wishlist[0].isPublic && !isAuthenticated) {
|
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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all items (exclude archived unless authenticated)
|
// Get all items (exclude archived unless admin)
|
||||||
const items = await db
|
const raw = await db
|
||||||
.select()
|
.select()
|
||||||
.from(wishlistItems)
|
.from(wishlistItems)
|
||||||
.where(
|
.where(
|
||||||
isAuthenticated
|
isAdmin
|
||||||
? eq(wishlistItems.wishlistId, id)
|
? eq(wishlistItems.wishlistId, id)
|
||||||
: and(
|
: and(
|
||||||
eq(wishlistItems.wishlistId, id),
|
eq(wishlistItems.wishlistId, id),
|
||||||
@@ -51,12 +50,11 @@ export async function GET(
|
|||||||
)
|
)
|
||||||
.orderBy(wishlistItems.sortOrder);
|
.orderBy(wishlistItems.sortOrder);
|
||||||
|
|
||||||
// Return items
|
const items = await attachClaimsToItems(raw);
|
||||||
const responseItems = items;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
items: responseItems,
|
items,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching items:', error);
|
console.error('Error fetching items:', error);
|
||||||
@@ -72,21 +70,8 @@ export async function POST(
|
|||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const token = request.cookies.get('access_token')?.value;
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Not authenticated' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = verifyAccessToken(token);
|
|
||||||
if (!payload) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid or expired token' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -140,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,
|
||||||
|
|||||||
@@ -1,28 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db, wishlists } from '@/lib/db';
|
import { db, wishlists } from '@/lib/db';
|
||||||
import { verifyAccessToken } from '@/lib/auth/utils';
|
import { verifyAdminToken } from '@/lib/auth/tokens';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const token = request.cookies.get('access_token')?.value;
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Not authenticated' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = verifyAccessToken(token);
|
|
||||||
if (!payload) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid or expired token' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|||||||
@@ -1,28 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db, wishlists } from '@/lib/db';
|
import { db, wishlists } from '@/lib/db';
|
||||||
import { verifyAccessToken } from '@/lib/auth/utils';
|
import { verifyAdminToken } from '@/lib/auth/tokens';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const token = request.cookies.get('access_token')?.value;
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Not authenticated' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = verifyAccessToken(token);
|
|
||||||
if (!payload) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid or expired token' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -58,21 +45,8 @@ export async function PATCH(
|
|||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const token = request.cookies.get('access_token')?.value;
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Not authenticated' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = verifyAccessToken(token);
|
|
||||||
if (!payload) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid or expired token' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -145,21 +119,8 @@ export async function DELETE(
|
|||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const token = request.cookies.get('access_token')?.value;
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Not authenticated' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = verifyAccessToken(token);
|
|
||||||
if (!payload) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid or expired token' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|||||||
@@ -1,25 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, 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 { verifyAccessToken } from '@/lib/auth/utils';
|
import { verifyAdminToken } from '@/lib/auth/tokens';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const token = request.cookies.get('access_token')?.value;
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Not authenticated' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = verifyAccessToken(token);
|
|
||||||
if (!payload) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid or expired token' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allWishlists = await db
|
const allWishlists = await db
|
||||||
@@ -42,21 +29,8 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const token = request.cookies.get('access_token')?.value;
|
if (!verifyAdminToken(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Not authenticated' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = verifyAccessToken(token);
|
|
||||||
if (!payload) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid or expired token' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|||||||
310
app/globals.css
310
app/globals.css
@@ -1,20 +1,29 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@variant dark (.dark &);
|
/* Mars palette — warm rust, terracotta, dusty sand, deep crater shadows */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #fff5ee; /* warm sand mist */
|
||||||
--foreground: #171717;
|
--foreground: #3a1f15;
|
||||||
|
--ink: #3a1f15; /* deep crater */
|
||||||
|
--ink-soft: #6b3a26; /* baked clay */
|
||||||
|
--muted: #a07560;
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-soft: #fdebdc; /* dust film */
|
||||||
|
--border: #f3d8c3;
|
||||||
|
--accent: #c1502c; /* mars-rust */
|
||||||
|
--accent-soft: #fde0d0;
|
||||||
|
--success: #7a8c4d; /* olive — softer than green on warm bg */
|
||||||
|
--success-soft: #f0eedb;
|
||||||
|
--success-border: #d6d4a4;
|
||||||
|
--success-ink: #4f5a30;
|
||||||
|
--halo-1: rgba(231, 111, 65, 0.45); /* burnt orange */
|
||||||
|
--halo-2: rgba(255, 188, 138, 0.50); /* peach dust */
|
||||||
|
--halo-3: rgba(196, 76, 39, 0.32); /* deep rust */
|
||||||
|
--planet-core: #c1502c;
|
||||||
|
--planet-rim: #8a2f15;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"],
|
|
||||||
.dark {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
@@ -25,38 +34,269 @@
|
|||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: 'Quicksand', 'Nunito', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light mode form elements */
|
/*
|
||||||
|
* Mars-scape background:
|
||||||
|
* - warm dusty gradient base
|
||||||
|
* - distant orange/peach halos (atmospheric haze)
|
||||||
|
* - sparse pinpoint stars (deep space)
|
||||||
|
*/
|
||||||
|
.bg-cosmic {
|
||||||
|
background-color: var(--background);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 12% 18%, var(--halo-1) 0%, transparent 38%),
|
||||||
|
radial-gradient(circle at 88% 14%, var(--halo-2) 0%, transparent 44%),
|
||||||
|
radial-gradient(circle at 50% 92%, var(--halo-3) 0%, transparent 50%),
|
||||||
|
radial-gradient(1px 1px at 22% 30%, rgba(255, 235, 215, 0.8) 50%, transparent 51%),
|
||||||
|
radial-gradient(1px 1px at 70% 22%, rgba(255, 235, 215, 0.7) 50%, transparent 51%),
|
||||||
|
radial-gradient(1px 1px at 40% 70%, rgba(255, 235, 215, 0.7) 50%, transparent 51%),
|
||||||
|
radial-gradient(1px 1px at 82% 60%, rgba(255, 235, 215, 0.8) 50%, transparent 51%),
|
||||||
|
radial-gradient(1px 1px at 18% 82%, rgba(255, 235, 215, 0.7) 50%, transparent 51%),
|
||||||
|
linear-gradient(180deg, var(--background) 0%, color-mix(in oklab, var(--background), var(--halo-2) 18%) 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mars-disk glyph: a soft layered radial gradient that reads as a planet.
|
||||||
|
* Used as a decorative ::before in the header.
|
||||||
|
*/
|
||||||
|
.mars-disk {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 35% 32%, color-mix(in oklab, var(--planet-core), white 25%) 0%, var(--planet-core) 38%, var(--planet-rim) 78%, transparent 100%),
|
||||||
|
radial-gradient(circle at 70% 70%, rgba(0,0,0,0.18), transparent 60%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 60px 10px color-mix(in oklab, var(--planet-core), transparent 70%),
|
||||||
|
inset -8px -8px 30px color-mix(in oklab, var(--planet-rim), transparent 50%);
|
||||||
|
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 {
|
||||||
|
background-color: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-card-soft {
|
||||||
|
background-color: var(--card-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-soft {
|
||||||
|
box-shadow:
|
||||||
|
0 6px 22px -10px color-mix(in oklab, var(--accent), transparent 70%),
|
||||||
|
0 2px 6px -2px rgba(58, 31, 21, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-lifted {
|
||||||
|
box-shadow:
|
||||||
|
0 22px 44px -22px color-mix(in oklab, var(--accent), transparent 55%),
|
||||||
|
0 8px 16px -8px rgba(58, 31, 21, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form elements */
|
||||||
:root input,
|
:root input,
|
||||||
:root textarea,
|
:root textarea,
|
||||||
:root select {
|
:root select {
|
||||||
color: #171717;
|
color: var(--ink);
|
||||||
background-color: #ffffff;
|
background-color: var(--card);
|
||||||
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root input::placeholder,
|
:root input::placeholder,
|
||||||
:root textarea::placeholder {
|
:root textarea::placeholder {
|
||||||
color: #9ca3af;
|
color: var(--muted);
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode form elements */
|
|
||||||
:root[data-theme="dark"] input,
|
|
||||||
:root[data-theme="dark"] textarea,
|
|
||||||
:root[data-theme="dark"] select,
|
|
||||||
.dark input,
|
|
||||||
.dark textarea,
|
|
||||||
.dark select {
|
|
||||||
color: #ededed;
|
|
||||||
background-color: #1f2937;
|
|
||||||
border-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"] input::placeholder,
|
|
||||||
:root[data-theme="dark"] textarea::placeholder,
|
|
||||||
.dark input::placeholder,
|
|
||||||
.dark textarea::placeholder {
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AuthProvider } from "@/lib/auth-context";
|
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
|
||||||
import { db, settings } from "@/lib/db";
|
import { db, settings } from "@/lib/db";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
|
|
||||||
async function getSettings() {
|
async function getSettings() {
|
||||||
try {
|
try {
|
||||||
@@ -14,12 +11,12 @@ async function getSettings() {
|
|||||||
}, {} as Record<string, string>);
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
siteTitle: settingsObj.siteTitle || 'Chá de Bebê',
|
siteTitle: settingsObj.siteTitle || 'Chá do Martin',
|
||||||
homepageSubtext: settingsObj.homepageSubtext || 'Escolha um presente da lista!',
|
homepageSubtext: settingsObj.homepageSubtext || 'Escolha um presente da lista!',
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
siteTitle: 'Chá de Bebê',
|
siteTitle: 'Chá do Martin',
|
||||||
homepageSubtext: 'Escolha um presente da lista!',
|
homepageSubtext: 'Escolha um presente da lista!',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -43,10 +40,16 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="pt-BR">
|
<html lang="pt-BR">
|
||||||
<body className="font-sans antialiased bg-gray-50 dark:bg-gray-900">
|
<head>
|
||||||
<AuthProvider>
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<ThemeProvider>{children}</ThemeProvider>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
</AuthProvider>
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="font-sans antialiased bg-cosmic">
|
||||||
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Header from '@/components/header';
|
|
||||||
import Footer from '@/components/footer';
|
|
||||||
|
|
||||||
export default function LockPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError('');
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/lock', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ password }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Password verified, redirect to home
|
|
||||||
router.push('/');
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
setError(data.error || 'Senha incorreta');
|
|
||||||
setPassword('');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Erro ao verificar senha. Tente novamente.');
|
|
||||||
console.error('Lock verification error:', err);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
|
||||||
<Header
|
|
||||||
title="Senha necessária"
|
|
||||||
subtitle="Digite a senha para acessar o site"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="max-w-md mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
||||||
<div className="p-6">
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded-lg text-base">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Senha
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
className="w-full px-4 py-3 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 text-lg"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="Digite a senha"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="w-full px-6 py-3 text-lg font-semibold text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Verificando...' : 'Entrar'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
138
app/page.tsx
138
app/page.tsx
@@ -1,107 +1,59 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import { useRouter } from 'next/navigation';
|
||||||
import { wishlistsApi, settingsApi, type Wishlist, type Settings } from '@/lib/api';
|
import { authApi, wishlistsApi } from '@/lib/api';
|
||||||
import Header from '@/components/header';
|
|
||||||
import Footer from '@/components/footer';
|
|
||||||
import PasswordLockGuard from '@/components/password-lock-guard';
|
|
||||||
import ShareButton from '@/components/share-button';
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function HomePage() {
|
||||||
const [wishlists, setWishlists] = useState<Wishlist[]>([]);
|
const router = useRouter();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [settings, setSettings] = useState<Settings>({ siteTitle: 'Chá de Bebê', homepageSubtext: 'Escolha um presente da lista!' });
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchWishlists();
|
let cancelled = false;
|
||||||
fetchSettings();
|
(async () => {
|
||||||
}, []);
|
const params = new URL(window.location.href).searchParams;
|
||||||
|
const adm = params.get('adm');
|
||||||
const fetchSettings = async () => {
|
const usr = params.get('usr');
|
||||||
try {
|
try {
|
||||||
const data = await settingsApi.getSettings();
|
if (adm) {
|
||||||
setSettings(data);
|
await authApi.session({ adm });
|
||||||
} catch (error) {
|
router.replace('/admin');
|
||||||
console.error('Failed to fetch settings:', error);
|
return;
|
||||||
}
|
}
|
||||||
};
|
if (usr) {
|
||||||
|
await authApi.session({ usr });
|
||||||
const fetchWishlists = async () => {
|
|
||||||
try {
|
|
||||||
const data = await wishlistsApi.getAllPublic();
|
|
||||||
setWishlists(data);
|
|
||||||
// Item counts removed - requires authentication
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch wishlists:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
const who = await authApi.whoami();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (who.role === 'admin') {
|
||||||
|
router.replace('/admin');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// For guests (and after usr token auth), redirect to the single wishlist
|
||||||
|
if (who.role === 'guest') {
|
||||||
|
const lists = await wishlistsApi.getAllPublic();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (lists.length > 0) {
|
||||||
|
router.replace(`/${lists[0].slug}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 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 {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PasswordLockGuard>
|
<div className="min-h-screen flex items-center justify-center text-center px-6">
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="max-w-xl">
|
||||||
<Header
|
<p className="text-lg text-gray-500">Carregando…</p>
|
||||||
title={settings.siteTitle}
|
|
||||||
subtitle={settings.homepageSubtext}
|
|
||||||
actions={
|
|
||||||
<ShareButton
|
|
||||||
title="Veja a lista do chá de bebê!"
|
|
||||||
text="Dê uma olhada na lista de presentes do chá de bebê."
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="max-w-4xl mx-auto py-12 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>
|
|
||||||
) : wishlists.length === 0 ? (
|
|
||||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow">
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">Nenhuma lista disponível ainda</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 gap-6">
|
|
||||||
{wishlists.map((wishlist) => (
|
|
||||||
<Link
|
|
||||||
key={wishlist.id}
|
|
||||||
href={`/${wishlist.slug}`}
|
|
||||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-md hover:shadow-xl transition-all duration-300 hover:scale-105 border border-gray-100 dark:border-gray-700 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col md:flex-row">
|
|
||||||
{wishlist.imageUrl && (
|
|
||||||
<div className="md:w-64 md:flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src={wishlist.imageUrl}
|
|
||||||
alt={wishlist.name}
|
|
||||||
className="w-full h-48 md:h-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="p-8 flex-1">
|
|
||||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
|
||||||
{wishlist.name}
|
|
||||||
</h3>
|
|
||||||
{wishlist.description && (
|
|
||||||
<p className="text-base text-gray-600 dark:text-gray-300 mb-4 line-clamp-3">
|
|
||||||
{wishlist.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
</PasswordLockGuard>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
39
components/admin-guard.tsx
Normal file
39
components/admin-guard.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { authApi } from '@/lib/api';
|
||||||
|
|
||||||
|
export default function AdminGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const [state, setState] = useState<'checking' | 'ok' | 'denied'>('checking');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const adm = url.searchParams.get('adm');
|
||||||
|
try {
|
||||||
|
if (adm) {
|
||||||
|
await authApi.session({ adm });
|
||||||
|
url.searchParams.delete('adm');
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
}
|
||||||
|
const who = await authApi.whoami();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (who.role === 'admin') setState('ok');
|
||||||
|
else setState('denied');
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setState('denied');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (state === 'checking') return <div className="min-h-screen flex items-center justify-center text-gray-500">Verificando…</div>;
|
||||||
|
if (state === 'denied') return <div className="min-h-screen flex items-center justify-center text-center px-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-2">Acesso restrito</h1>
|
||||||
|
<p className="text-gray-500">Adicione <code>?adm=<token></code> à URL para entrar.</p>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -97,44 +97,6 @@ export default function SettingsSection({ settings, onUpdate }: SettingsSectionP
|
|||||||
This appears below the title on the homepage
|
This appears below the title on the homepage
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex items-center mb-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="passwordLockEnabled"
|
|
||||||
className="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
|
||||||
checked={settingsForm.passwordLockEnabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettingsForm((prev) => ({ ...prev, passwordLockEnabled: e.target.checked }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<label htmlFor="passwordLockEnabled" className="ml-2 block text-base font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Enable Password Lock
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
|
||||||
When enabled, visitors must enter a password to access the website
|
|
||||||
</p>
|
|
||||||
{settingsForm.passwordLockEnabled && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Site Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
value={settingsForm.passwordLock || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettingsForm((prev) => ({ ...prev, passwordLock: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Enter password (leave blank to keep current)"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Leave blank to keep the current password unchanged
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -161,20 +123,6 @@ export default function SettingsSection({ settings, onUpdate }: SettingsSectionP
|
|||||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Homepage Subtext</p>
|
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Homepage Subtext</p>
|
||||||
<p className="text-base text-gray-900 dark:text-white">{settings.homepageSubtext}</p>
|
<p className="text-base text-gray-900 dark:text-white">{settings.homepageSubtext}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Password Lock</p>
|
|
||||||
<p className="text-base text-gray-900 dark:text-white">
|
|
||||||
{settings.passwordLockEnabled ? (
|
|
||||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-base font-medium bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200">
|
|
||||||
Enabled
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-base font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300">
|
|
||||||
Disabled
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useTheme } from '@/components/theme-provider';
|
|
||||||
|
|
||||||
export default function Footer() {
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="flex flex-col items-center gap-2 text-sm">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors text-gray-700 dark:text-gray-300 font-medium"
|
|
||||||
aria-label="Alternar tema"
|
|
||||||
>
|
|
||||||
{theme === 'light' ? (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Modo escuro</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Modo claro</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
51
components/guest-guard.tsx
Normal file
51
components/guest-guard.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { authApi, wishlistsApi } from '@/lib/api';
|
||||||
|
|
||||||
|
type Status = 'checking' | { kind: 'ok'; guestName: string } | 'denied';
|
||||||
|
|
||||||
|
export default function GuestGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const [status, setStatus] = useState<Status>('checking');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const usr = new URL(window.location.href).searchParams.get('usr');
|
||||||
|
try {
|
||||||
|
if (usr) {
|
||||||
|
await authApi.session({ usr });
|
||||||
|
}
|
||||||
|
const who = await authApi.whoami();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (who.role === 'admin' || who.role === 'guest') {
|
||||||
|
setStatus({ kind: 'ok', guestName: who.guest?.name ?? 'admin' });
|
||||||
|
} 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');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setStatus('denied');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (status === 'checking') return <div className="min-h-screen flex items-center justify-center text-gray-500">Verificando convite…</div>;
|
||||||
|
if (status === 'denied') return <div className="min-h-screen flex items-center justify-center text-center px-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-2">Convite necessário</h1>
|
||||||
|
<p className="text-gray-500">Esta lista é por convite. Use o link que recebeu.</p>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import MarsPlanet from '@/components/mars-planet';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -11,40 +11,39 @@ interface HeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({ title, subtitle, imageUrl, actions, maxWidth = 'max-w-7xl' }: HeaderProps) {
|
export default function Header({ title, subtitle, imageUrl, actions, maxWidth = 'max-w-7xl' }: HeaderProps) {
|
||||||
const { isAuthenticated } = useAuth();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Admin Warning */}
|
<div className="relative overflow-hidden">
|
||||||
{isAuthenticated && (
|
{/* Distant atmospheric haze */}
|
||||||
<div className="sticky top-0 z-50 bg-yellow-50 dark:bg-yellow-900 border-b border-yellow-200 dark:border-yellow-800">
|
<div
|
||||||
<div className={`${maxWidth} mx-auto py-3 px-4 sm:px-6 lg:px-8`}>
|
aria-hidden
|
||||||
<p className="text-center text-sm text-yellow-800 dark:text-yellow-200">
|
className="pointer-events-none absolute -top-16 -right-12 w-80 h-80 rounded-full opacity-50 blur-3xl"
|
||||||
⚠️ Atenção: visualizar listas públicas pode estragar surpresas
|
style={{ background: 'radial-gradient(circle, var(--halo-1), transparent 65%)' }}
|
||||||
</p>
|
/>
|
||||||
</div>
|
<div
|
||||||
</div>
|
aria-hidden
|
||||||
)}
|
className="pointer-events-none absolute -bottom-16 -left-10 w-72 h-72 rounded-full opacity-50 blur-3xl"
|
||||||
|
style={{ background: 'radial-gradient(circle, var(--halo-3), transparent 60%)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Hero Section */}
|
<div className={`${maxWidth} mx-auto relative py-14 px-4 sm:py-20 sm:px-6 lg:px-8`}>
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm">
|
<MarsPlanet className="absolute right-4 top-4 sm:right-8 sm:top-7 lg:right-10 lg:top-9" />
|
||||||
<div className={`${maxWidth} mx-auto py-16 px-4 sm:py-24 sm:px-6 lg:px-8`}>
|
|
||||||
<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-64 flex-shrink-0">
|
<div className="md:w-56 flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={title}
|
alt={title}
|
||||||
className="w-64 h-64 object-cover rounded-lg shadow-lg"
|
className="w-56 h-56 object-cover rounded-3xl shadow-lifted ring-1 ring-[color:var(--border)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={imageUrl ? 'flex-1 text-left' : 'flex-1 text-center'}>
|
<div className={imageUrl ? 'flex-1 text-left' : 'flex-1 text-center'}>
|
||||||
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-gray-900 dark:text-white mb-4">
|
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-[color:var(--ink)] mb-4 leading-tight">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className={`text-xl sm:text-2xl text-gray-600 dark:text-gray-300 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>
|
<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">
|
||||||
Currency
|
Quantidade
|
||||||
</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>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Quantity
|
|
||||||
</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,56 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
|
||||||
|
|
||||||
export default function PasswordLockGuard({ children }: { children: React.ReactNode }) {
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [isChecking, setIsChecking] = useState(true);
|
|
||||||
const [isLocked, setIsLocked] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip check for admin and lock pages
|
|
||||||
if (pathname.startsWith('/admin') || pathname === '/lock') {
|
|
||||||
setIsChecking(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkPasswordLock = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/settings');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.settings.passwordLockEnabled) {
|
|
||||||
setIsLocked(true);
|
|
||||||
// Redirect to lock page
|
|
||||||
router.push('/lock');
|
|
||||||
} else {
|
|
||||||
setIsChecking(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking password lock:', error);
|
|
||||||
// On error, allow access to prevent site lockout
|
|
||||||
setIsChecking(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkPasswordLock();
|
|
||||||
}, [pathname, router]);
|
|
||||||
|
|
||||||
// Show loading or nothing while checking
|
|
||||||
if (isChecking) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If locked, don't render children (redirect is happening)
|
|
||||||
if (isLocked) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useAuth } from '@/lib/auth-context';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoading && !isAuthenticated) {
|
|
||||||
router.push('/admin/login');
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, isLoading, router]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<div className="text-gray-600">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
|
||||||
|
|
||||||
interface ThemeContextType {
|
|
||||||
theme: Theme;
|
|
||||||
toggleTheme: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [theme, setTheme] = useState<Theme>('light');
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
// Load theme from localStorage
|
|
||||||
const savedTheme = localStorage.getItem('theme') as Theme | null;
|
|
||||||
const initialTheme = savedTheme || 'light';
|
|
||||||
|
|
||||||
setTheme(initialTheme);
|
|
||||||
document.documentElement.setAttribute('data-theme', initialTheme);
|
|
||||||
|
|
||||||
if (initialTheme === 'dark') {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
const newTheme = theme === 'light' ? 'dark' : 'light';
|
|
||||||
setTheme(newTheme);
|
|
||||||
localStorage.setItem('theme', newTheme);
|
|
||||||
document.documentElement.setAttribute('data-theme', newTheme);
|
|
||||||
if (newTheme === 'dark') {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTheme() {
|
|
||||||
const context = useContext(ThemeContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useTheme must be used within a ThemeProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -5,8 +5,7 @@ services:
|
|||||||
container_name: chadebebe
|
container_name: chadebebe
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
- ADMIN_TOKEN=${ADMIN_TOKEN:?ADMIN_TOKEN must be set}
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme}
|
|
||||||
- PUID=${PUID:-1000}
|
- PUID=${PUID:-1000}
|
||||||
- PGID=${PGID:-1000}
|
- PGID=${PGID:-1000}
|
||||||
- COOKIE_SECURE=true
|
- COOKIE_SECURE=true
|
||||||
@@ -16,7 +15,7 @@ services:
|
|||||||
- web
|
- web
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.chadebebe.rule=Host(`chadebebe.omeu.website`)"
|
- "traefik.http.routers.chadebebe.rule=Host(`chadomartin.omeu.website`)"
|
||||||
- "traefik.http.routers.chadebebe.entrypoints=https"
|
- "traefik.http.routers.chadebebe.entrypoints=https"
|
||||||
- "traefik.http.routers.chadebebe.tls=true"
|
- "traefik.http.routers.chadebebe.tls=true"
|
||||||
- "traefik.http.routers.chadebebe.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.chadebebe.tls.certresolver=letsencrypt"
|
||||||
|
|||||||
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
|
||||||
117
lib/api.ts
117
lib/api.ts
@@ -23,14 +23,23 @@ async function handleResponse<T>(response: Response): Promise<T> {
|
|||||||
|
|
||||||
// Auth API
|
// Auth API
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
async login(username: string, password: string) {
|
async session(payload: { adm?: string; usr?: string }) {
|
||||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
const response = await fetch(`${API_BASE_URL}/auth/session`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
return handleResponse<{ accessToken: string; refreshToken: string }>(response);
|
return handleResponse<{ success: true; role: 'admin' | 'guest'; guestId: string | null }>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async whoami() {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: 'include' });
|
||||||
|
return handleResponse<
|
||||||
|
| { role: 'admin'; guest: { id: string; name: string } | null }
|
||||||
|
| { role: 'guest'; guest: { id: string; name: string } }
|
||||||
|
| { role: 'none' }
|
||||||
|
>(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
@@ -38,22 +47,75 @@ export const authApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
return handleResponse<void>(response);
|
return handleResponse<{ success: true }>(response);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Guest {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const guestsApi = {
|
||||||
|
async list() {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/admin/guests`, { credentials: 'include' });
|
||||||
|
const data = await handleResponse<{ success: true; guests: Guest[] }>(response);
|
||||||
|
return data.guests;
|
||||||
},
|
},
|
||||||
|
|
||||||
async refresh() {
|
async create(name: string) {
|
||||||
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
|
const response = await fetch(`${API_BASE_URL}/admin/guests`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
});
|
});
|
||||||
return handleResponse<{ accessToken: string }>(response);
|
const data = await handleResponse<{ success: true; guest: Guest }>(response);
|
||||||
|
return data.guest;
|
||||||
},
|
},
|
||||||
|
|
||||||
async me() {
|
async rename(id: string, name: string) {
|
||||||
const response = await fetch(`${API_BASE_URL}/auth/me`, {
|
const response = await fetch(`${API_BASE_URL}/admin/guests/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
const data = await handleResponse<{ success: true; guest: Guest }>(response);
|
||||||
|
return data.guest;
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/admin/guests/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
return handleResponse<{ username: string }>(response);
|
return handleResponse<{ success: true }>(response);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,6 +133,15 @@ export interface Wishlist {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ItemClaim {
|
||||||
|
id: string;
|
||||||
|
quantity: number;
|
||||||
|
note: string | null;
|
||||||
|
isPurchased: boolean;
|
||||||
|
claimedAt: string;
|
||||||
|
guest: { id: string; name: string };
|
||||||
|
}
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
id: string;
|
id: string;
|
||||||
wishlistId: string;
|
wishlistId: string;
|
||||||
@@ -82,10 +153,9 @@ export interface Item {
|
|||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
purchaseUrls: Array<{ label: string; url: string }> | null;
|
purchaseUrls: Array<{ label: string; url: string }> | null;
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
claimedByName: string | null;
|
claims: ItemClaim[];
|
||||||
claimedByNote: string | null;
|
claimedQuantity: number;
|
||||||
claimedAt: string | null;
|
remainingQuantity: number;
|
||||||
isPurchased: boolean;
|
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -238,23 +308,24 @@ export const itemsApi = {
|
|||||||
|
|
||||||
// Claiming API (public)
|
// Claiming API (public)
|
||||||
export const claimingApi = {
|
export const claimingApi = {
|
||||||
async claim(itemId: string, name?: string, note?: string) {
|
async claim(itemId: string, opts: { quantity?: number; note?: string } = {}) {
|
||||||
const response = await fetch(`${API_BASE_URL}/public/items/${itemId}/claim`, {
|
const response = await fetch(`${API_BASE_URL}/public/items/${itemId}/claim`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ name, note }),
|
body: JSON.stringify({ quantity: opts.quantity ?? 1, note: opts.note }),
|
||||||
});
|
});
|
||||||
return handleResponse<{ claimToken: string; message: string }>(response);
|
return handleResponse<{ success: true; item: Item }>(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
async unclaim(itemId: string) {
|
async unclaim(itemId: string, opts: { guestId?: string } = {}) {
|
||||||
const response = await fetch(`${API_BASE_URL}/public/items/${itemId}/unclaim`, {
|
const response = await fetch(`${API_BASE_URL}/public/items/${itemId}/unclaim`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(opts.guestId ? { guestId: opts.guestId } : {}),
|
||||||
});
|
});
|
||||||
return handleResponse<{ success: boolean; message: string }>(response);
|
return handleResponse<{ success: true }>(response);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -262,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,8 +354,8 @@ export const scrapingApi = {
|
|||||||
export interface Settings {
|
export interface Settings {
|
||||||
siteTitle: string;
|
siteTitle: string;
|
||||||
homepageSubtext: string;
|
homepageSubtext: string;
|
||||||
passwordLockEnabled?: boolean;
|
claimingEnabled: boolean;
|
||||||
passwordLock?: string;
|
showQuantity: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
|
||||||
import { authApi } from './api';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
interface AuthContextType {
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
username: string | null;
|
|
||||||
login: (username: string, password: string) => Promise<void>;
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
||||||
const [username, setUsername] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Check authentication on mount using httpOnly cookies
|
|
||||||
useEffect(() => {
|
|
||||||
const initAuth = async () => {
|
|
||||||
try {
|
|
||||||
// Call /api/auth/me - cookies are sent automatically
|
|
||||||
const user = await authApi.me();
|
|
||||||
setIsAuthenticated(true);
|
|
||||||
setUsername(user.username);
|
|
||||||
} catch (error) {
|
|
||||||
// Not authenticated or session expired
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
setUsername(null);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initAuth();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const login = async (username: string, password: string) => {
|
|
||||||
// Server sets httpOnly cookies automatically
|
|
||||||
await authApi.login(username, password);
|
|
||||||
setIsAuthenticated(true);
|
|
||||||
setUsername(username);
|
|
||||||
};
|
|
||||||
|
|
||||||
const logout = async () => {
|
|
||||||
try {
|
|
||||||
// Server clears httpOnly cookies
|
|
||||||
await authApi.logout();
|
|
||||||
} catch (error) {
|
|
||||||
// Continue with logout even if API call fails
|
|
||||||
}
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
setUsername(null);
|
|
||||||
router.push('/');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider
|
|
||||||
value={{
|
|
||||||
isAuthenticated,
|
|
||||||
isLoading,
|
|
||||||
username,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
39
lib/auth/cookies.ts
Normal file
39
lib/auth/cookies.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export const ADM_COOKIE = 'adm_token';
|
||||||
|
export const USR_COOKIE = 'usr_token';
|
||||||
|
|
||||||
|
export function isSecureCookie(request: { headers: { get(name: string): string | null }; url: string }): boolean {
|
||||||
|
if (process.env.COOKIE_SECURE !== undefined) {
|
||||||
|
return process.env.COOKIE_SECURE === 'true';
|
||||||
|
}
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return (
|
||||||
|
request.headers.get('x-forwarded-proto') === 'https' ||
|
||||||
|
request.url.startsWith('https://')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCookie(name: string, value: string, secure: boolean, maxAgeSeconds: number): string {
|
||||||
|
const parts = [
|
||||||
|
`${name}=${value}`,
|
||||||
|
'Path=/',
|
||||||
|
'HttpOnly',
|
||||||
|
'SameSite=Lax',
|
||||||
|
`Max-Age=${maxAgeSeconds}`,
|
||||||
|
];
|
||||||
|
if (secure) parts.push('Secure');
|
||||||
|
return parts.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildClearCookie(name: string, secure: boolean): string {
|
||||||
|
const parts = [
|
||||||
|
`${name}=`,
|
||||||
|
'Path=/',
|
||||||
|
'HttpOnly',
|
||||||
|
'SameSite=Lax',
|
||||||
|
'Max-Age=0',
|
||||||
|
];
|
||||||
|
if (secure) parts.push('Secure');
|
||||||
|
return parts.join('; ');
|
||||||
|
}
|
||||||
51
lib/auth/tokens.ts
Normal file
51
lib/auth/tokens.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { db, guests, type Guest } from '@/lib/db';
|
||||||
|
import { ADM_COOKIE, USR_COOKIE } from './cookies';
|
||||||
|
|
||||||
|
function constantTimeEquals(a: string, b: string): boolean {
|
||||||
|
const ab = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
if (ab.length !== bb.length) return false;
|
||||||
|
return crypto.timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminToken(): string {
|
||||||
|
const t = process.env.ADMIN_TOKEN;
|
||||||
|
if (!t || t.length < 16) {
|
||||||
|
throw new Error('ADMIN_TOKEN env var must be set and at least 16 chars');
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyAdminToken(req: NextRequest): boolean {
|
||||||
|
const cookie = req.cookies.get(ADM_COOKIE)?.value;
|
||||||
|
if (!cookie) return false;
|
||||||
|
try {
|
||||||
|
return constantTimeEquals(cookie, getAdminToken());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGuestFromRequest(req: NextRequest): Promise<Guest | null> {
|
||||||
|
const cookie = req.cookies.get(USR_COOKIE)?.value;
|
||||||
|
if (!cookie) return null;
|
||||||
|
const rows = await db.select().from(guests).where(eq(guests.id, cookie)).limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdminTokenValue(token: string): boolean {
|
||||||
|
try {
|
||||||
|
return constantTimeEquals(token, getAdminToken());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isGuestTokenValue(token: string): Promise<Guest | null> {
|
||||||
|
if (!token) return null;
|
||||||
|
const rows = await db.select().from(guests).where(eq(guests.id, token)).limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
|
||||||
import crypto from 'crypto';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
// Initialize secrets (auto-generate if not provided)
|
|
||||||
const secrets = initializeSecrets();
|
|
||||||
|
|
||||||
// Token expiry times
|
|
||||||
const TOKEN_EXPIRY = '72h';
|
|
||||||
const REFRESH_TOKEN_EXPIRY = '30d';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize JWT secrets - auto-generate and persist if not provided in environment
|
|
||||||
*/
|
|
||||||
function initializeSecrets(): { secret: string; refreshSecret: string } {
|
|
||||||
const dataDir = path.join(process.cwd(), 'data');
|
|
||||||
const secretsFile = path.join(dataDir, 'secrets.json');
|
|
||||||
|
|
||||||
// If provided in environment, use those
|
|
||||||
if (process.env.SECRET) {
|
|
||||||
return {
|
|
||||||
secret: process.env.SECRET,
|
|
||||||
refreshSecret: process.env.REFRESH_SECRET || process.env.SECRET, // Use same secret if refresh not provided
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure data directory exists
|
|
||||||
if (!fs.existsSync(dataDir)) {
|
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to load existing secrets
|
|
||||||
if (fs.existsSync(secretsFile)) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(secretsFile, 'utf-8'));
|
|
||||||
return data;
|
|
||||||
} catch {
|
|
||||||
// Failed to load, will generate new ones
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new cryptographically secure secrets (512 bits each)
|
|
||||||
const newSecrets = {
|
|
||||||
secret: crypto.randomBytes(64).toString('hex'),
|
|
||||||
refreshSecret: crypto.randomBytes(64).toString('hex'),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save to file with restricted permissions
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(secretsFile, JSON.stringify(newSecrets, null, 2), { mode: 0o600 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('⚠️ Failed to save secrets file:', error);
|
|
||||||
console.error('⚠️ WARNING: Using in-memory secrets - tokens will be invalid after restart!');
|
|
||||||
}
|
|
||||||
|
|
||||||
return newSecrets;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenPayload {
|
|
||||||
username: string;
|
|
||||||
type: 'access' | 'refresh';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate an access token
|
|
||||||
*/
|
|
||||||
export function generateAccessToken(username: string): string {
|
|
||||||
return jwt.sign(
|
|
||||||
{ username, type: 'access' } as TokenPayload,
|
|
||||||
secrets.secret,
|
|
||||||
{ expiresIn: TOKEN_EXPIRY } as jwt.SignOptions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a refresh token
|
|
||||||
*/
|
|
||||||
export function generateRefreshToken(username: string): string {
|
|
||||||
return jwt.sign(
|
|
||||||
{ username, type: 'refresh' } as TokenPayload,
|
|
||||||
secrets.refreshSecret,
|
|
||||||
{ expiresIn: REFRESH_TOKEN_EXPIRY } as jwt.SignOptions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify an access token
|
|
||||||
*/
|
|
||||||
export function verifyAccessToken(token: string): TokenPayload | null {
|
|
||||||
try {
|
|
||||||
const payload = jwt.verify(token, secrets.secret) as TokenPayload;
|
|
||||||
if (payload.type !== 'access') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify a refresh token
|
|
||||||
*/
|
|
||||||
export function verifyRefreshToken(token: string): TokenPayload | null {
|
|
||||||
try {
|
|
||||||
const payload = jwt.verify(token, secrets.refreshSecret) as TokenPayload;
|
|
||||||
if (payload.type !== 'refresh') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate admin credentials against environment variables
|
|
||||||
*/
|
|
||||||
export function validateAdminCredentials(username: string, password: string): boolean {
|
|
||||||
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
|
|
||||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
|
||||||
|
|
||||||
if (!adminPassword) {
|
|
||||||
console.error('❌ ADMIN_PASSWORD not set in environment variables');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return username === adminUsername && password === adminPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSecureCookie(request: { headers: { get(name: string): string | null }; url: string }): boolean {
|
|
||||||
if (process.env.COOKIE_SECURE !== undefined) {
|
|
||||||
return process.env.COOKIE_SECURE === 'true';
|
|
||||||
}
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
return (
|
|
||||||
request.headers.get('x-forwarded-proto') === 'https' ||
|
|
||||||
request.url.startsWith('https://')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -103,6 +103,35 @@ export async function initializeDatabase() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Create guests table
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS guests (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch()) NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create item_claims table
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS item_claims (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
guest_id TEXT NOT NULL,
|
||||||
|
quantity INTEGER DEFAULT 1 NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
is_purchased INTEGER DEFAULT 0 NOT NULL,
|
||||||
|
claimed_at INTEGER DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
FOREIGN KEY (item_id) REFERENCES wishlist_items(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (guest_id) REFERENCES guests(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(item_id, guest_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
sqlite.exec('CREATE INDEX IF NOT EXISTS idx_item_claims_item ON item_claims(item_id)');
|
||||||
|
sqlite.exec('CREATE INDEX IF NOT EXISTS idx_item_claims_guest ON item_claims(guest_id)');
|
||||||
|
|
||||||
// Run migrations for existing databases
|
// Run migrations for existing databases
|
||||||
try {
|
try {
|
||||||
const columns = sqlite.pragma('table_info(wishlists)') as Array<{ name: string }>;
|
const columns = sqlite.pragma('table_info(wishlists)') as Array<{ name: string }>;
|
||||||
@@ -127,6 +156,52 @@ export async function initializeDatabase() {
|
|||||||
sqlite.exec('ALTER TABLE wishlists ADD COLUMN preferences TEXT');
|
sqlite.exec('ALTER TABLE wishlists ADD COLUMN preferences TEXT');
|
||||||
console.log('✅ Added preferences column to wishlists table');
|
console.log('✅ Added preferences column to wishlists table');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemColumns = sqlite.pragma('table_info(wishlist_items)') as Array<{ name: string }>;
|
||||||
|
|
||||||
|
const hasClaimedByGuestId = itemColumns.some((col) => col.name === 'claimed_by_guest_id');
|
||||||
|
if (!hasClaimedByGuestId) {
|
||||||
|
sqlite.exec('ALTER TABLE wishlist_items ADD COLUMN claimed_by_guest_id TEXT REFERENCES guests(id) ON DELETE SET NULL');
|
||||||
|
console.log('✅ Added claimed_by_guest_id column to wishlist_items');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasClaimedByName = itemColumns.some((col) => col.name === 'claimed_by_name');
|
||||||
|
const hasClaimedByToken = itemColumns.some((col) => col.name === 'claimed_by_token');
|
||||||
|
if (hasClaimedByName || hasClaimedByToken) {
|
||||||
|
if (hasClaimedByName) {
|
||||||
|
sqlite.exec('UPDATE wishlist_items SET claimed_by_name = NULL');
|
||||||
|
}
|
||||||
|
if (hasClaimedByToken) {
|
||||||
|
sqlite.exec('UPDATE wishlist_items SET claimed_by_token = NULL');
|
||||||
|
}
|
||||||
|
console.log('ℹ️ Legacy claim columns nullified (claimed_by_name / claimed_by_token)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move any existing inline claims into item_claims (forward-migration), then nullify the inline columns
|
||||||
|
const itemColsAfter = sqlite.pragma('table_info(wishlist_items)') as Array<{ name: string }>;
|
||||||
|
const stillHasClaimedByGuestId = itemColsAfter.some((c) => c.name === 'claimed_by_guest_id');
|
||||||
|
if (stillHasClaimedByGuestId) {
|
||||||
|
sqlite.exec(`
|
||||||
|
INSERT INTO item_claims (id, item_id, guest_id, quantity, note, is_purchased, claimed_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
lower(hex(randomblob(16))),
|
||||||
|
wi.id,
|
||||||
|
wi.claimed_by_guest_id,
|
||||||
|
1,
|
||||||
|
wi.claimed_by_note,
|
||||||
|
wi.is_purchased,
|
||||||
|
COALESCE(wi.claimed_at, unixepoch()),
|
||||||
|
unixepoch()
|
||||||
|
FROM wishlist_items wi
|
||||||
|
WHERE wi.claimed_by_guest_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM item_claims ic
|
||||||
|
WHERE ic.item_id = wi.id AND ic.guest_id = wi.claimed_by_guest_id
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
sqlite.exec('UPDATE wishlist_items SET claimed_by_guest_id = NULL, claimed_by_note = NULL, claimed_at = NULL, is_purchased = 0');
|
||||||
|
console.log('ℹ️ Migrated inline claims into item_claims and cleared inline claim columns');
|
||||||
|
}
|
||||||
} catch (migrationError) {
|
} catch (migrationError) {
|
||||||
console.log('Migration already applied or not needed');
|
console.log('Migration already applied or not needed');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,13 @@
|
|||||||
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
|
import { sqliteTable, text, integer, real, unique } from 'drizzle-orm/sqlite-core';
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
/**
|
|
||||||
* Database schema for family wishlist app
|
|
||||||
*
|
|
||||||
* Simplified for self-hosted family use:
|
|
||||||
* - No Settings table (use env vars instead)
|
|
||||||
* - Image URLs only (no upload handling in MVP)
|
|
||||||
* - Simplified purchase URLs (no usage tracking)
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Wishlists table
|
|
||||||
export const wishlists = sqliteTable('wishlists', {
|
export const wishlists = sqliteTable('wishlists', {
|
||||||
id: text('id').primaryKey().$defaultFn(() => createId()),
|
id: text('id').primaryKey().$defaultFn(() => createId()),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
slug: text('slug').notNull().unique(),
|
slug: text('slug').notNull().unique(),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
preferences: text('preferences'), // General interests/likes section
|
preferences: text('preferences'),
|
||||||
imageUrl: text('image_url'),
|
imageUrl: text('image_url'),
|
||||||
isPublic: integer('is_public', { mode: 'boolean' }).notNull().default(false),
|
isPublic: integer('is_public', { mode: 'boolean' }).notNull().default(false),
|
||||||
sortOrder: integer('sort_order').notNull().default(0),
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
@@ -25,7 +15,13 @@ export const wishlists = sqliteTable('wishlists', {
|
|||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wishlist items table
|
export const guests = sqliteTable('guests', {
|
||||||
|
id: text('id').primaryKey().$defaultFn(() => createId()),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
export const wishlistItems = sqliteTable('wishlist_items', {
|
export const wishlistItems = sqliteTable('wishlist_items', {
|
||||||
id: text('id').primaryKey().$defaultFn(() => createId()),
|
id: text('id').primaryKey().$defaultFn(() => createId()),
|
||||||
wishlistId: text('wishlist_id').notNull().references(() => wishlists.id, { onDelete: 'cascade' }),
|
wishlistId: text('wishlist_id').notNull().references(() => wishlists.id, { onDelete: 'cascade' }),
|
||||||
@@ -34,27 +30,35 @@ export const wishlistItems = sqliteTable('wishlist_items', {
|
|||||||
price: real('price'),
|
price: real('price'),
|
||||||
currency: text('currency').notNull().default('USD'),
|
currency: text('currency').notNull().default('USD'),
|
||||||
quantity: integer('quantity').notNull().default(1),
|
quantity: integer('quantity').notNull().default(1),
|
||||||
imageUrl: text('images'), // Stored as 'images' in DB but exposed as imageUrl
|
imageUrl: text('images'),
|
||||||
purchaseUrls: text('purchase_urls', { mode: 'json' }).$type<Array<{
|
purchaseUrls: text('purchase_urls', { mode: 'json' }).$type<Array<{
|
||||||
label: string;
|
label: string;
|
||||||
url: string;
|
url: string;
|
||||||
}>>(),
|
}>>(),
|
||||||
|
|
||||||
isArchived: integer('is_archived', { mode: 'boolean' }).notNull().default(false),
|
isArchived: integer('is_archived', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
// claim ownership lives in item_claims (1 item -> many claims; unique per (item, guest))
|
||||||
// Claim information
|
|
||||||
claimedByName: text('claimed_by_name'),
|
|
||||||
claimedByNote: text('claimed_by_note'),
|
|
||||||
claimedByToken: text('claimed_by_token').unique(),
|
|
||||||
claimedAt: integer('claimed_at', { mode: 'timestamp' }),
|
|
||||||
isPurchased: integer('is_purchased', { mode: 'boolean' }).notNull().default(false),
|
|
||||||
|
|
||||||
sortOrder: integer('sort_order').notNull().default(0),
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Settings table
|
export const itemClaims = sqliteTable(
|
||||||
|
'item_claims',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey().$defaultFn(() => createId()),
|
||||||
|
itemId: text('item_id').notNull().references(() => wishlistItems.id, { onDelete: 'cascade' }),
|
||||||
|
guestId: text('guest_id').notNull().references(() => guests.id, { onDelete: 'cascade' }),
|
||||||
|
quantity: integer('quantity').notNull().default(1),
|
||||||
|
note: text('note'),
|
||||||
|
isPurchased: integer('is_purchased', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
claimedAt: integer('claimed_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
uniqueItemGuest: unique('item_claims_item_guest_unique').on(t.itemId, t.guestId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export const settings = sqliteTable('settings', {
|
export const settings = sqliteTable('settings', {
|
||||||
id: text('id').primaryKey().$defaultFn(() => createId()),
|
id: text('id').primaryKey().$defaultFn(() => createId()),
|
||||||
key: text('key').notNull().unique(),
|
key: text('key').notNull().unique(),
|
||||||
@@ -62,7 +66,8 @@ export const settings = sqliteTable('settings', {
|
|||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Type exports
|
|
||||||
export type Wishlist = typeof wishlists.$inferSelect;
|
export type Wishlist = typeof wishlists.$inferSelect;
|
||||||
export type WishlistItem = typeof wishlistItems.$inferSelect;
|
export type WishlistItem = typeof wishlistItems.$inferSelect;
|
||||||
|
export type Guest = typeof guests.$inferSelect;
|
||||||
|
export type ItemClaim = typeof itemClaims.$inferSelect;
|
||||||
export type Setting = typeof settings.$inferSelect;
|
export type Setting = typeof settings.$inferSelect;
|
||||||
|
|||||||
55
lib/items-with-claims.ts
Normal file
55
lib/items-with-claims.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
|
import { db, wishlistItems, itemClaims, guests } from '@/lib/db';
|
||||||
|
|
||||||
|
type RawItem = typeof wishlistItems.$inferSelect;
|
||||||
|
|
||||||
|
export async function attachClaimsToItems(items: RawItem[]) {
|
||||||
|
if (items.length === 0) return [];
|
||||||
|
const itemIds = items.map((i) => i.id);
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: itemClaims.id,
|
||||||
|
itemId: itemClaims.itemId,
|
||||||
|
quantity: itemClaims.quantity,
|
||||||
|
note: itemClaims.note,
|
||||||
|
isPurchased: itemClaims.isPurchased,
|
||||||
|
claimedAt: itemClaims.claimedAt,
|
||||||
|
guestId: guests.id,
|
||||||
|
guestName: guests.name,
|
||||||
|
})
|
||||||
|
.from(itemClaims)
|
||||||
|
.innerJoin(guests, eq(itemClaims.guestId, guests.id))
|
||||||
|
.where(inArray(itemClaims.itemId, itemIds));
|
||||||
|
|
||||||
|
const byItem = new Map<string, ReturnType<typeof shapeClaim>[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const arr = byItem.get(r.itemId) ?? [];
|
||||||
|
arr.push(shapeClaim(r));
|
||||||
|
byItem.set(r.itemId, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((it) => {
|
||||||
|
const claims = byItem.get(it.id) ?? [];
|
||||||
|
const claimedQuantity = claims.reduce((s, c) => s + c.quantity, 0);
|
||||||
|
return {
|
||||||
|
...it,
|
||||||
|
claims,
|
||||||
|
claimedQuantity,
|
||||||
|
remainingQuantity: Math.max(0, it.quantity - claimedQuantity),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeClaim(r: {
|
||||||
|
id: string; quantity: number; note: string | null; isPurchased: boolean;
|
||||||
|
claimedAt: Date; guestId: string; guestName: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
quantity: r.quantity,
|
||||||
|
note: r.note,
|
||||||
|
isPurchased: r.isPurchased,
|
||||||
|
claimedAt: r.claimedAt,
|
||||||
|
guest: { id: r.guestId, name: r.guestName },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,7 +11,11 @@
|
|||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:seed": "tsx lib/db/seed.ts"
|
"db:seed": "tsx lib/db/seed.ts",
|
||||||
|
"guest": "tsx scripts/guest.ts",
|
||||||
|
"guest:create": "tsx scripts/guest.ts create",
|
||||||
|
"guest:list": "tsx scripts/guest.ts list",
|
||||||
|
"guest:delete": "tsx scripts/guest.ts delete"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lexical/html": "^0.39.0",
|
"@lexical/html": "^0.39.0",
|
||||||
|
|||||||
44
scripts/guest.ts
Normal file
44
scripts/guest.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db, guests, initializeDatabase } from '../lib/db';
|
||||||
|
|
||||||
|
function arg(name: string): string | undefined {
|
||||||
|
const idx = process.argv.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
|
||||||
|
if (idx < 0) return undefined;
|
||||||
|
const a = process.argv[idx];
|
||||||
|
if (a.includes('=')) return a.split('=')[1];
|
||||||
|
return process.argv[idx + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await initializeDatabase();
|
||||||
|
const cmd = process.argv[2];
|
||||||
|
|
||||||
|
if (cmd === 'create') {
|
||||||
|
const name = (arg('name') ?? '').trim() || 'Convidado';
|
||||||
|
const [row] = await db.insert(guests).values({ name }).returning();
|
||||||
|
const base = process.env.PUBLIC_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
console.log(JSON.stringify({ id: row.id, name: row.name, link: `${base}/?usr=${row.id}` }, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd === 'list') {
|
||||||
|
const rows = await db.select().from(guests);
|
||||||
|
console.log(JSON.stringify(rows, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd === 'delete') {
|
||||||
|
const id = arg('id');
|
||||||
|
if (!id) { console.error('--id is required'); process.exit(1); }
|
||||||
|
const out = await db.delete(guests).where(eq(guests.id, id)).returning();
|
||||||
|
if (out.length === 0) { console.error('not found'); process.exit(1); }
|
||||||
|
console.log('deleted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('usage: guest <create --name=NAME | list | delete --id=ID>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error(e); process.exit(1); });
|
||||||
Reference in New Issue
Block a user