Compare commits
18 Commits
4864bf6304
...
2418ab64cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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)
|
||||
# Set a strong username and password for the admin account
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=changeme
|
||||
# Admin Token (REQUIRED)
|
||||
# Long random string used to authenticate the admin via ?adm=<token> in the URL.
|
||||
# Generate with: openssl rand -hex 32
|
||||
ADMIN_TOKEN=replace-with-32-byte-random-hex
|
||||
|
||||
# JWT Secret (Optional - auto-generated if not provided)
|
||||
# For production, generate a secure random string:
|
||||
# openssl rand -base64 32
|
||||
SECRET=
|
||||
# Public base URL used by the guest CLI when generating links
|
||||
PUBLIC_BASE_URL=http://localhost:3000
|
||||
|
||||
# Application Settings (Optional)
|
||||
NODE_ENV=production
|
||||
|
||||
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/[slug]/page.tsx` — `Intl.NumberFormat('pt-BR', ...)` for prices
|
||||
- `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.
|
||||
|
||||
### 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
|
||||
- ADMIN_USERNAME=your_username
|
||||
- ADMIN_PASSWORD=your_password
|
||||
- ADMIN_TOKEN=long-random-hex-at-least-16-chars
|
||||
```
|
||||
|
||||
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
|
||||
@@ -80,7 +91,7 @@ cd chadebebe
|
||||
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
|
||||
|
||||
|
||||
@@ -3,30 +3,55 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { wishlistsApi, itemsApi, claimingApi, type Wishlist, type Item } from '@/lib/api';
|
||||
import { authApi, wishlistsApi, itemsApi, claimingApi, type Wishlist, type Item } from '@/lib/api';
|
||||
import Header from '@/components/header';
|
||||
import Footer from '@/components/footer';
|
||||
import PasswordLockGuard from '@/components/password-lock-guard';
|
||||
import GuestGuard from '@/components/guest-guard';
|
||||
|
||||
export default function PublicWishlistPage() {
|
||||
return (
|
||||
<GuestGuard>
|
||||
<PublicWishlistContent />
|
||||
</GuestGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicWishlistContent() {
|
||||
const params = useParams();
|
||||
const [wishlist, setWishlist] = useState<Wishlist | null>(null);
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [showClaimed, setShowClaimed] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Current viewer
|
||||
const [currentGuestId, setCurrentGuestId] = useState<string | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
// Claim form state
|
||||
const [claimingItemId, setClaimingItemId] = useState<string | null>(null);
|
||||
const [claimNote, setClaimNote] = useState('');
|
||||
const [claimQty, setClaimQty] = useState(1);
|
||||
const [isClaiming, setIsClaiming] = useState(false);
|
||||
const [claimError, setClaimError] = useState('');
|
||||
const [justClaimedItemId, setJustClaimedItemId] = useState<string | null>(null);
|
||||
const [justClaimedNote, setJustClaimedNote] = useState('');
|
||||
|
||||
// Unclaim state
|
||||
const [isUnclaiming, setIsUnclaiming] = useState(false);
|
||||
const [unclaimError, setUnclaimError] = useState('');
|
||||
|
||||
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(() => {
|
||||
fetchWishlist();
|
||||
@@ -48,10 +73,14 @@ export default function PublicWishlistPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClaimItem = (itemId: string) => {
|
||||
setClaimingItemId(itemId);
|
||||
const myClaimFor = (item: Item) => item.claims.find((c) => c.guest.id === currentGuestId);
|
||||
|
||||
const handleStartClaim = (item: Item) => {
|
||||
const my = myClaimFor(item);
|
||||
setClaimingItemId(item.id);
|
||||
setClaimError('');
|
||||
setClaimNote('');
|
||||
setClaimNote(my?.note ?? '');
|
||||
setClaimQty(my?.quantity ?? 1);
|
||||
setJustClaimedItemId(null);
|
||||
};
|
||||
|
||||
@@ -62,12 +91,12 @@ export default function PublicWishlistPage() {
|
||||
setClaimError('');
|
||||
|
||||
try {
|
||||
await claimingApi.claim(itemId, undefined, claimNote);
|
||||
await claimingApi.claim(itemId, { quantity: claimQty, note: claimNote });
|
||||
|
||||
setJustClaimedItemId(itemId);
|
||||
setJustClaimedNote(claimNote);
|
||||
setClaimingItemId(null);
|
||||
setClaimNote('');
|
||||
setClaimQty(1);
|
||||
fetchWishlist();
|
||||
} catch (err: any) {
|
||||
setClaimError(err.message || 'Erro ao reservar item');
|
||||
@@ -76,27 +105,25 @@ export default function PublicWishlistPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnclaim = async (itemId: string) => {
|
||||
if (!confirm('Tem certeza que deseja cancelar a reserva deste item?')) {
|
||||
return;
|
||||
}
|
||||
const handleUnclaim = async (itemId: string, guestId?: string) => {
|
||||
if (!confirm('Cancelar a reserva?')) return;
|
||||
|
||||
setIsUnclaiming(true);
|
||||
setUnclaimError('');
|
||||
|
||||
try {
|
||||
await claimingApi.unclaim(itemId);
|
||||
await claimingApi.unclaim(itemId, guestId ? { guestId } : {});
|
||||
fetchWishlist();
|
||||
} catch (err: any) {
|
||||
setUnclaimError(err.message || 'Erro ao cancelar reserva');
|
||||
alert(err.message || 'Erro ao cancelar reserva');
|
||||
} finally {
|
||||
setIsUnclaiming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredItems = showClaimed
|
||||
? items
|
||||
: items.filter((item) => !item.claimedAt || item.id === justClaimedItemId);
|
||||
const filteredItems = items.filter((item) => {
|
||||
const my = myClaimFor(item);
|
||||
return item.remainingQuantity > 0 || my || item.id === justClaimedItemId;
|
||||
});
|
||||
|
||||
const formatPrice = (price: number | null, currency: string) => {
|
||||
if (!price) return null;
|
||||
@@ -126,8 +153,7 @@ export default function PublicWishlistPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PasswordLockGuard>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="min-h-screen bg-cosmic">
|
||||
<Header
|
||||
title={wishlist.name}
|
||||
subtitle={wishlist.description || undefined}
|
||||
@@ -168,7 +194,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"
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(wishlist.preferences) }}
|
||||
onClick={(e) => {
|
||||
// Make all links open in new tab
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'A') {
|
||||
e.preventDefault();
|
||||
@@ -180,18 +205,7 @@ export default function PublicWishlistPage() {
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<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="mb-6 flex items-center justify-end">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{filteredItems.length} de {items.length} itens
|
||||
</div>
|
||||
@@ -201,15 +215,21 @@ export default function PublicWishlistPage() {
|
||||
{filteredItems.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">
|
||||
{showClaimed ? 'Nenhum item nesta lista ainda' : 'Todos os itens já foram reservados!'}
|
||||
Todos os itens já foram reservados!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{filteredItems.map((item) => (
|
||||
{filteredItems.map((item) => {
|
||||
const myClaim = myClaimFor(item);
|
||||
const maxForMe = item.remainingQuantity + (myClaim?.quantity ?? 0);
|
||||
const showQuantitySummary = item.quantity > 1;
|
||||
const sold = item.remainingQuantity === 0 && !myClaim;
|
||||
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* Left: Image */}
|
||||
@@ -225,14 +245,51 @@ export default function PublicWishlistPage() {
|
||||
|
||||
{/* Middle: Item Details */}
|
||||
<div className="flex-1 p-6">
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
{item.name}
|
||||
</h3>
|
||||
{showQuantitySummary && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
{item.claimedQuantity} de {item.quantity} reservados
|
||||
</p>
|
||||
)}
|
||||
{item.description && (
|
||||
<p className="text-base text-gray-600 dark:text-gray-300 mb-4">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Existing claims list */}
|
||||
{item.claims.length > 0 && (
|
||||
<ul className="text-sm text-gray-700 dark:text-gray-300 space-y-1 mt-4 border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
{item.claims.map((c) => {
|
||||
const isMine = c.guest.id === currentGuestId;
|
||||
const canCancel = isMine || isAdmin;
|
||||
return (
|
||||
<li key={c.id} className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<span className="font-medium">{isMine ? 'Você' : 'Reservado'}</span>
|
||||
<span className="text-gray-500"> · {c.quantity} un.</span>
|
||||
{c.note && isMine && (
|
||||
<span className="block text-xs italic text-gray-500">
|
||||
"{c.note}"
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canCancel && (
|
||||
<button
|
||||
onClick={() => handleUnclaim(item.id, isMine ? undefined : c.guest.id)}
|
||||
disabled={isUnclaiming}
|
||||
className="text-xs text-red-600 hover:underline disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Action Area */}
|
||||
@@ -260,56 +317,12 @@ export default function PublicWishlistPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Claimed Badge, Success Message, or Claim Button/Form */}
|
||||
<div className="mt-auto">
|
||||
{justClaimedItemId === item.id ? (
|
||||
<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>
|
||||
)}
|
||||
{sold ? (
|
||||
<div className="bg-gray-100 dark:bg-gray-700 rounded p-3 text-center text-sm text-gray-700 dark:text-gray-300">
|
||||
Esgotado
|
||||
</div>
|
||||
) : claimingItemId === item.id ? (
|
||||
<div className="space-y-3">
|
||||
<form onSubmit={(e) => handleSubmitClaim(e, item.id)} className="space-y-3">
|
||||
{claimError && (
|
||||
<div className="p-2 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded text-xs">
|
||||
@@ -317,6 +330,23 @@ export default function PublicWishlistPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.quantity > 1 && (
|
||||
<div>
|
||||
<label htmlFor={`claim-qty-${item.id}`} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Quantidade (máx {maxForMe}):
|
||||
</label>
|
||||
<input
|
||||
id={`claim-qty-${item.id}`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxForMe}
|
||||
value={claimQty}
|
||||
onChange={(e) => setClaimQty(Math.max(1, Math.min(maxForMe, Number(e.target.value) || 1)))}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor={`claim-note-${item.id}`} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Deixe uma nota (opcional):
|
||||
@@ -324,7 +354,6 @@ export default function PublicWishlistPage() {
|
||||
<textarea
|
||||
id={`claim-note-${item.id}`}
|
||||
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"
|
||||
value={claimNote}
|
||||
onChange={(e) => setClaimNote(e.target.value)}
|
||||
@@ -336,30 +365,34 @@ export default function PublicWishlistPage() {
|
||||
disabled={isClaiming}
|
||||
className="w-full px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 font-medium disabled:opacity-50 transition-colors cursor-pointer"
|
||||
>
|
||||
{isClaiming ? 'Reservando...' : '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>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<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"
|
||||
>
|
||||
Vou dar este presente
|
||||
{myClaim ? 'Atualizar reserva' : 'Reservar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</PasswordLockGuard>
|
||||
);
|
||||
}
|
||||
|
||||
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,11 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import ProtectedRoute from '@/components/protected-route';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { wishlistsApi, itemsApi, settingsApi, type Wishlist, type Settings } from '@/lib/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import AdminGuard from '@/components/admin-guard';
|
||||
import { authApi, wishlistsApi, itemsApi, settingsApi, type Wishlist, type Settings } from '@/lib/api';
|
||||
import Header from '@/components/header';
|
||||
import Footer from '@/components/footer';
|
||||
import Link from 'next/link';
|
||||
import StatsGrid from '@/components/admin/StatsGrid';
|
||||
import SettingsSection from '@/components/admin/SettingsSection';
|
||||
@@ -14,15 +13,27 @@ import CreateWishlistModal from '@/components/admin/CreateWishlistModal';
|
||||
import ShareButton from '@/components/share-button';
|
||||
|
||||
export default function AdminPage() {
|
||||
const { logout } = useAuth();
|
||||
return (
|
||||
<AdminGuard>
|
||||
<AdminPageContent />
|
||||
</AdminGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminPageContent() {
|
||||
const router = useRouter();
|
||||
const [wishlists, setWishlists] = useState<Wishlist[]>([]);
|
||||
const [itemCounts, setItemCounts] = useState<Record<string, number>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
siteTitle: 'Wishlist',
|
||||
homepageSubtext: 'Browse and explore available wishlists',
|
||||
passwordLockEnabled: false,
|
||||
});
|
||||
|
||||
const logout = async () => {
|
||||
await authApi.logout();
|
||||
router.push('/');
|
||||
};
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [createError, setCreateError] = useState('');
|
||||
|
||||
@@ -125,7 +136,7 @@ export default function AdminPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header
|
||||
title="Dashboard"
|
||||
@@ -143,6 +154,12 @@ export default function AdminPage() {
|
||||
>
|
||||
View Public Site
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/guests"
|
||||
className="inline-flex items-center px-6 py-3 border-2 border-indigo-600 dark:border-indigo-500 text-base font-semibold rounded-lg text-indigo-600 dark:text-indigo-400 bg-white dark:bg-gray-800 hover:bg-indigo-50 dark:hover:bg-gray-700 transition-all"
|
||||
>
|
||||
Convidados
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="inline-flex items-center px-6 py-3 border-2 border-red-600 dark:border-red-500 text-base font-semibold rounded-lg text-red-600 dark:text-red-400 bg-white dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all cursor-pointer"
|
||||
@@ -230,9 +247,7 @@ export default function AdminPage() {
|
||||
onCreate={handleCreateWishlist}
|
||||
error={createError}
|
||||
/>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, wishlists } from '@/lib/db';
|
||||
import { getGuestFromRequest, verifyAdminToken } from '@/lib/auth/tokens';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
const isAdmin = verifyAdminToken(request);
|
||||
const guest = await getGuestFromRequest(request);
|
||||
if (!isAdmin && !guest) {
|
||||
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { slug } = await params;
|
||||
|
||||
const wishlist = await db
|
||||
@@ -22,8 +29,8 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
// Only return public wishlists
|
||||
if (!wishlist[0].isPublic) {
|
||||
// Only return public wishlists (admin can see all)
|
||||
if (!wishlist[0].isPublic && !isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Wishlist not found' },
|
||||
{ status: 404 }
|
||||
|
||||
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 { ADM_COOKIE, USR_COOKIE, buildClearCookie, isSecureCookie } from '@/lib/auth/cookies';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
message: 'Logged out successfully'
|
||||
});
|
||||
|
||||
response.cookies.delete('access_token');
|
||||
response.cookies.delete('refresh_token');
|
||||
|
||||
return response;
|
||||
const secure = isSecureCookie(request);
|
||||
const headers = new Headers();
|
||||
headers.append('Set-Cookie', buildClearCookie(ADM_COOKIE, secure));
|
||||
headers.append('Set-Cookie', buildClearCookie(USR_COOKIE, secure));
|
||||
return NextResponse.json({ success: true }, { headers });
|
||||
}
|
||||
|
||||
@@ -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 { eq } from 'drizzle-orm';
|
||||
import { db, wishlistItems } from '@/lib/db';
|
||||
import { verifyAccessToken } from '@/lib/auth/utils';
|
||||
import { verifyAdminToken } from '@/lib/auth/tokens';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
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 }
|
||||
);
|
||||
if (!verifyAdminToken(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
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(
|
||||
request: NextRequest,
|
||||
@@ -10,10 +11,11 @@ export async function GET(
|
||||
try {
|
||||
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;
|
||||
const isAdmin = verifyAdminToken(request);
|
||||
const guest = await getGuestFromRequest(request);
|
||||
if (!isAdmin && !guest) {
|
||||
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get item
|
||||
const item = await db
|
||||
@@ -43,17 +45,18 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
if (!wishlist[0].isPublic && !isAuthenticated) {
|
||||
if (!wishlist[0].isPublic && !isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This item is private' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const [withClaims] = await attachClaimsToItems(item);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
item: item[0],
|
||||
item: withClaims,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching item:', error);
|
||||
@@ -69,21 +72,8 @@ export async function PATCH(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
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 }
|
||||
);
|
||||
if (!verifyAdminToken(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
@@ -114,7 +104,7 @@ export async function PATCH(
|
||||
}
|
||||
|
||||
// Build update object (only include provided fields)
|
||||
const updateData: any = {
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
@@ -152,21 +142,8 @@ export async function DELETE(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
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 }
|
||||
);
|
||||
if (!verifyAdminToken(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
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,105 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, wishlistItems, wishlists } from '@/lib/db';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { and, eq, ne, sql } from 'drizzle-orm';
|
||||
import { db, wishlistItems, wishlists, itemClaims, guests } from '@/lib/db';
|
||||
import { getGuestFromRequest } from '@/lib/auth/tokens';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { name, note } = body;
|
||||
|
||||
// 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 }
|
||||
);
|
||||
const guest = await getGuestFromRequest(request);
|
||||
if (!guest) {
|
||||
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if item is already claimed
|
||||
if (item[0].claimedByToken) {
|
||||
const { id } = await params;
|
||||
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 });
|
||||
|
||||
// Sum quantities reserved by OTHER guests
|
||||
const otherSumRow = await db
|
||||
.select({ total: sql<number>`COALESCE(SUM(${itemClaims.quantity}), 0)` })
|
||||
.from(itemClaims)
|
||||
.where(and(eq(itemClaims.itemId, id), ne(itemClaims.guestId, guest.id)));
|
||||
const otherSum = Number(otherSumRow[0]?.total ?? 0);
|
||||
|
||||
if (otherSum + requestedQty > item.quantity) {
|
||||
const remaining = Math.max(0, item.quantity - otherSum);
|
||||
return NextResponse.json(
|
||||
{ error: 'Item is already claimed' },
|
||||
{ error: `Apenas ${remaining} disponível(is)`, remaining },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if wishlist is public
|
||||
const wishlist = await db
|
||||
// Upsert: replace existing claim for this (item, guest)
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(wishlists)
|
||||
.where(eq(wishlists.id, item[0].wishlistId))
|
||||
.from(itemClaims)
|
||||
.where(and(eq(itemClaims.itemId, id), eq(itemClaims.guestId, guest.id)))
|
||||
.limit(1);
|
||||
|
||||
if (wishlist.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Wishlist not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(itemClaims)
|
||||
.set({ quantity: requestedQty, note, updatedAt: new Date() })
|
||||
.where(eq(itemClaims.id, existing[0].id));
|
||||
} else {
|
||||
await db.insert(itemClaims).values({
|
||||
itemId: id,
|
||||
guestId: guest.id,
|
||||
quantity: requestedQty,
|
||||
note,
|
||||
});
|
||||
}
|
||||
|
||||
if (!wishlist[0].isPublic) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This wishlist is private' },
|
||||
{ status: 403 }
|
||||
);
|
||||
await db.update(wishlistItems).set({ updatedAt: new Date() }).where(eq(wishlistItems.id, id));
|
||||
|
||||
const updated = await fetchItemWithClaims(id);
|
||||
return NextResponse.json({ success: true, item: updated }, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error('Error claiming item:', err);
|
||||
return NextResponse.json({ error: 'Failed to claim item' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique claim token
|
||||
const claimToken = createId();
|
||||
|
||||
// Update item with claim information
|
||||
const updatedItem = await db
|
||||
.update(wishlistItems)
|
||||
.set({
|
||||
claimedByName: name || null,
|
||||
claimedByNote: note || null,
|
||||
claimedByToken: claimToken,
|
||||
claimedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
async function fetchItemWithClaims(itemId: string) {
|
||||
const itemRows = await db.select().from(wishlistItems).where(eq(wishlistItems.id, itemId)).limit(1);
|
||||
if (itemRows.length === 0) return null;
|
||||
const claims = await db
|
||||
.select({
|
||||
id: itemClaims.id,
|
||||
quantity: itemClaims.quantity,
|
||||
note: itemClaims.note,
|
||||
isPurchased: itemClaims.isPurchased,
|
||||
claimedAt: itemClaims.claimedAt,
|
||||
guestId: guests.id,
|
||||
guestName: guests.name,
|
||||
})
|
||||
.where(eq(wishlistItems.id, id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
claimToken,
|
||||
item: updatedItem[0],
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error claiming item:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to claim item' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
.from(itemClaims)
|
||||
.innerJoin(guests, eq(itemClaims.guestId, guests.id))
|
||||
.where(eq(itemClaims.itemId, itemId));
|
||||
const claimedQuantity = claims.reduce((s, c) => s + c.quantity, 0);
|
||||
return {
|
||||
...itemRows[0],
|
||||
claims: claims.map((c) => ({
|
||||
id: c.id,
|
||||
quantity: c.quantity,
|
||||
note: c.note,
|
||||
isPurchased: c.isPurchased,
|
||||
claimedAt: c.claimedAt,
|
||||
guest: { id: c.guestId, name: c.guestName },
|
||||
})),
|
||||
claimedQuantity,
|
||||
remainingQuantity: Math.max(0, itemRows[0].quantity - claimedQuantity),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,84 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, wishlistItems, wishlists } from '@/lib/db';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, wishlistItems, wishlists, itemClaims } from '@/lib/db';
|
||||
import { getGuestFromRequest, verifyAdminToken } from '@/lib/auth/tokens';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
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 body = await request.json().catch(() => ({}));
|
||||
|
||||
// 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 }
|
||||
);
|
||||
const targetGuestId = isAdmin && typeof body?.guestId === 'string' ? body.guestId : guest?.id;
|
||||
if (!targetGuestId) {
|
||||
return NextResponse.json({ error: 'Missing guest' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if item is actually claimed
|
||||
if (!item[0].claimedByToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Item is not claimed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
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 wl = await db.select().from(wishlists).where(eq(wishlists.id, itemRows[0].wishlistId)).limit(1);
|
||||
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 wishlist = await db
|
||||
.select()
|
||||
.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))
|
||||
const result = await db
|
||||
.delete(itemClaims)
|
||||
.where(and(eq(itemClaims.itemId, id), eq(itemClaims.guestId, targetGuestId)))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Item unclaimed successfully',
|
||||
item: updatedItem[0],
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error unclaiming item:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to unclaim item' },
|
||||
{ status: 500 }
|
||||
);
|
||||
if (result.length === 0) {
|
||||
return NextResponse.json({ error: 'Reserva não encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.update(wishlistItems).set({ updatedAt: new Date() }).where(eq(wishlistItems.id, id));
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Error unclaiming item:', err);
|
||||
return NextResponse.json({ error: 'Failed to unclaim item' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { db, wishlists } from '@/lib/db';
|
||||
import { getGuestFromRequest, verifyAdminToken } from '@/lib/auth/tokens';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const isAdmin = verifyAdminToken(request);
|
||||
const guest = await getGuestFromRequest(request);
|
||||
if (!isAdmin && !guest) {
|
||||
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Fetch only public wishlists
|
||||
const publicWishlists = await db
|
||||
.select()
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAccessToken } from '@/lib/auth/utils';
|
||||
import { verifyAdminToken } from '@/lib/auth/tokens';
|
||||
import { scrapeUrl } from '@/lib/scraping/service';
|
||||
|
||||
export async function POST(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 }
|
||||
);
|
||||
if (!verifyAdminToken(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, settings } from '@/lib/db';
|
||||
import { verifyAccessToken } from '@/lib/auth/utils';
|
||||
import crypto from 'crypto';
|
||||
import { verifyAdminToken } from '@/lib/auth/tokens';
|
||||
|
||||
// GET /api/settings - Get all settings (public endpoint for reading only)
|
||||
export async function GET(request: NextRequest) {
|
||||
export async function GET() {
|
||||
try {
|
||||
const allSettings = await db.select().from(settings);
|
||||
|
||||
// Convert to key-value object
|
||||
const settingsObj = allSettings.reduce((acc, setting) => {
|
||||
acc[setting.key] = setting.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string | boolean>);
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
// Set defaults if not found
|
||||
if (!settingsObj.siteTitle) {
|
||||
settingsObj.siteTitle = 'Wishlist';
|
||||
}
|
||||
@@ -23,12 +20,12 @@ export async function GET(request: NextRequest) {
|
||||
settingsObj.homepageSubtext = 'Browse and explore available wishlists';
|
||||
}
|
||||
|
||||
// Convert passwordLockEnabled to boolean
|
||||
(settingsObj as any).passwordLockEnabled = settingsObj.passwordLockEnabled === 'true';
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
settings: settingsObj,
|
||||
settings: {
|
||||
siteTitle: settingsObj.siteTitle,
|
||||
homepageSubtext: settingsObj.homepageSubtext,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
@@ -42,27 +39,13 @@ export async function GET(request: NextRequest) {
|
||||
// PUT /api/settings - Update settings (admin only)
|
||||
export async function PUT(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 }
|
||||
);
|
||||
if (!verifyAdminToken(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { siteTitle, homepageSubtext, passwordLockEnabled, passwordLock } = body;
|
||||
const { siteTitle, homepageSubtext } = body;
|
||||
|
||||
// Update or insert siteTitle
|
||||
if (siteTitle !== undefined) {
|
||||
const existing = await db
|
||||
.select()
|
||||
@@ -83,7 +66,6 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update or insert homepageSubtext
|
||||
if (homepageSubtext !== undefined) {
|
||||
const existing = await db
|
||||
.select()
|
||||
@@ -104,52 +86,6 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update or insert passwordLockEnabled
|
||||
if (passwordLockEnabled !== undefined) {
|
||||
const value = passwordLockEnabled ? 'true' : 'false';
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, 'passwordLockEnabled'))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(settings)
|
||||
.set({ value, updatedAt: new Date() })
|
||||
.where(eq(settings.key, 'passwordLockEnabled'));
|
||||
} else {
|
||||
await db.insert(settings).values({
|
||||
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({
|
||||
success: true,
|
||||
message: 'Settings updated successfully',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
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(
|
||||
request: NextRequest,
|
||||
@@ -10,10 +11,11 @@ export async function GET(
|
||||
try {
|
||||
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;
|
||||
const isAdmin = verifyAdminToken(request);
|
||||
const guest = await getGuestFromRequest(request);
|
||||
if (!isAdmin && !guest) {
|
||||
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if wishlist exists
|
||||
const wishlist = await db
|
||||
@@ -29,20 +31,20 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
if (!wishlist[0].isPublic && !isAuthenticated) {
|
||||
// Permissions: guest can only see public wishlists; admin sees all
|
||||
if (!wishlist[0].isPublic && !isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This wishlist is private' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get all items (exclude archived unless authenticated)
|
||||
const items = await db
|
||||
// Get all items (exclude archived unless admin)
|
||||
const raw = await db
|
||||
.select()
|
||||
.from(wishlistItems)
|
||||
.where(
|
||||
isAuthenticated
|
||||
isAdmin
|
||||
? eq(wishlistItems.wishlistId, id)
|
||||
: and(
|
||||
eq(wishlistItems.wishlistId, id),
|
||||
@@ -51,12 +53,11 @@ export async function GET(
|
||||
)
|
||||
.orderBy(wishlistItems.sortOrder);
|
||||
|
||||
// Return items
|
||||
const responseItems = items;
|
||||
const items = await attachClaimsToItems(raw);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
items: responseItems,
|
||||
items,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching items:', error);
|
||||
@@ -72,21 +73,8 @@ export async function POST(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
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 }
|
||||
);
|
||||
if (!verifyAdminToken(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, wishlists } from '@/lib/db';
|
||||
import { verifyAccessToken } from '@/lib/auth/utils';
|
||||
import { verifyAdminToken } from '@/lib/auth/tokens';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
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 }
|
||||
);
|
||||
if (!verifyAdminToken(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, wishlists } from '@/lib/db';
|
||||
import { verifyAccessToken } from '@/lib/auth/utils';
|
||||
import { verifyAdminToken } from '@/lib/auth/tokens';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
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 }
|
||||
);
|
||||
if (!verifyAdminToken(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
@@ -58,21 +45,8 @@ export async function PATCH(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
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 }
|
||||
);
|
||||
if (!verifyAdminToken(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
@@ -145,21 +119,8 @@ export async function DELETE(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
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 }
|
||||
);
|
||||
if (!verifyAdminToken(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { db, wishlists } from '@/lib/db';
|
||||
import { verifyAccessToken } from '@/lib/auth/utils';
|
||||
import { verifyAdminToken } from '@/lib/auth/tokens';
|
||||
|
||||
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 }
|
||||
);
|
||||
if (!verifyAdminToken(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const allWishlists = await db
|
||||
@@ -42,21 +29,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
export async function POST(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 }
|
||||
);
|
||||
if (!verifyAdminToken(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
118
app/globals.css
118
app/globals.css
@@ -1,20 +1,29 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@variant dark (.dark &);
|
||||
|
||||
/* Mars palette — warm rust, terracotta, dusty sand, deep crater shadows */
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--background: #fff5ee; /* warm sand mist */
|
||||
--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;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"],
|
||||
.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
@@ -25,38 +34,77 @@
|
||||
body {
|
||||
background: var(--background);
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.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 textarea,
|
||||
:root select {
|
||||
color: #171717;
|
||||
background-color: #ffffff;
|
||||
color: var(--ink);
|
||||
background-color: var(--card);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
:root input::placeholder,
|
||||
:root textarea::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { db, settings } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
async function getSettings() {
|
||||
try {
|
||||
@@ -14,12 +11,12 @@ async function getSettings() {
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
return {
|
||||
siteTitle: settingsObj.siteTitle || 'Chá de Bebê',
|
||||
siteTitle: settingsObj.siteTitle || 'Chá do Martin',
|
||||
homepageSubtext: settingsObj.homepageSubtext || 'Escolha um presente da lista!',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
siteTitle: 'Chá de Bebê',
|
||||
siteTitle: 'Chá do Martin',
|
||||
homepageSubtext: 'Escolha um presente da lista!',
|
||||
};
|
||||
}
|
||||
@@ -43,10 +40,16 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="pt-BR">
|
||||
<body className="font-sans antialiased bg-gray-50 dark:bg-gray-900">
|
||||
<AuthProvider>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</AuthProvider>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
153
app/page.tsx
153
app/page.tsx
@@ -2,106 +2,93 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { wishlistsApi, settingsApi, type Wishlist, type Settings } 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';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { authApi, wishlistsApi, type Wishlist } from '@/lib/api';
|
||||
|
||||
export default function Home() {
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const [state, setState] = useState<'checking' | 'guest' | 'none'>('checking');
|
||||
const [wishlists, setWishlists] = useState<Wishlist[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [settings, setSettings] = useState<Settings>({ siteTitle: 'Chá de Bebê', homepageSubtext: 'Escolha um presente da lista!' });
|
||||
|
||||
useEffect(() => {
|
||||
fetchWishlists();
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const adm = params.get('adm');
|
||||
const usr = params.get('usr');
|
||||
try {
|
||||
const data = await settingsApi.getSettings();
|
||||
setSettings(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
if (adm) {
|
||||
await authApi.session({ adm });
|
||||
router.replace('/admin');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
if (usr) {
|
||||
await authApi.session({ usr });
|
||||
}
|
||||
};
|
||||
const who = await authApi.whoami();
|
||||
if (cancelled) return;
|
||||
if (who.role === 'admin') {
|
||||
router.replace('/admin');
|
||||
return;
|
||||
}
|
||||
if (who.role === 'guest') {
|
||||
const lists = await wishlistsApi.getAllPublic();
|
||||
if (cancelled) return;
|
||||
if (lists.length === 1) {
|
||||
router.replace(`/${lists[0].slug}`);
|
||||
return;
|
||||
}
|
||||
setWishlists(lists);
|
||||
setState('guest');
|
||||
return;
|
||||
}
|
||||
setState('none');
|
||||
} catch {
|
||||
if (!cancelled) setState('none');
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [params, router]);
|
||||
|
||||
if (state === 'checking') {
|
||||
return <div className="min-h-screen flex items-center justify-center text-gray-500">Carregando…</div>;
|
||||
}
|
||||
|
||||
if (state === 'guest') {
|
||||
return (
|
||||
<PasswordLockGuard>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header
|
||||
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="min-h-screen flex items-center justify-center px-6">
|
||||
<div className="w-full max-w-2xl text-center">
|
||||
<h1 className="text-3xl font-bold mb-6">Bem-vindo</h1>
|
||||
{wishlists.length === 0 ? (
|
||||
<p className="text-gray-500">Nenhuma lista disponível ainda.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{wishlists.map((wishlist) => (
|
||||
<ul className="space-y-3 text-left">
|
||||
{wishlists.map((w) => (
|
||||
<li key={w.id}>
|
||||
<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"
|
||||
href={`/${w.slug}`}
|
||||
className="block rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:border-indigo-400 hover:bg-indigo-50/30 dark:hover:bg-indigo-900/10 transition"
|
||||
>
|
||||
<div className="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="font-semibold text-lg">{w.name}</div>
|
||||
{w.description && (
|
||||
<div className="text-sm text-gray-500 mt-1">{w.description}</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>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</PasswordLockGuard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-center px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Convite necessário</h1>
|
||||
<p className="text-gray-500">Esta página é por convite. Use o link que recebeu.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
42
components/admin-guard.tsx
Normal file
42
components/admin-guard.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { authApi } from '@/lib/api';
|
||||
|
||||
export default function AdminGuard({ children }: { children: React.ReactNode }) {
|
||||
const params = useSearchParams();
|
||||
const [state, setState] = useState<'checking' | 'ok' | 'denied'>('checking');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const adm = params.get('adm');
|
||||
try {
|
||||
if (adm) {
|
||||
await authApi.session({ adm });
|
||||
// strip the param from URL but keep route
|
||||
const url = new URL(window.location.href);
|
||||
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; };
|
||||
}, [params]);
|
||||
|
||||
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}</>;
|
||||
}
|
||||
@@ -97,44 +97,6 @@ export default function SettingsSection({ settings, onUpdate }: SettingsSectionP
|
||||
This appears below the title on the homepage
|
||||
</p>
|
||||
</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">
|
||||
<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-base text-gray-900 dark:text-white">{settings.homepageSubtext}</p>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
45
components/guest-guard.tsx
Normal file
45
components/guest-guard.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { authApi } from '@/lib/api';
|
||||
|
||||
type Status = 'checking' | { kind: 'ok'; guestName: string } | 'denied';
|
||||
|
||||
export default function GuestGuard({ children }: { children: React.ReactNode }) {
|
||||
const params = useSearchParams();
|
||||
const [status, setStatus] = useState<Status>('checking');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const usr = params.get('usr');
|
||||
try {
|
||||
if (usr) {
|
||||
await authApi.session({ usr });
|
||||
// user said leaving the token in URL is fine — do NOT strip
|
||||
}
|
||||
const who = await authApi.whoami();
|
||||
if (cancelled) return;
|
||||
if (who.role === 'admin' || who.role === 'guest') {
|
||||
setStatus({ kind: 'ok', guestName: who.guest?.name ?? 'admin' });
|
||||
} else {
|
||||
setStatus('denied');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setStatus('denied');
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [params]);
|
||||
|
||||
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,7 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
@@ -11,40 +9,38 @@ interface HeaderProps {
|
||||
}
|
||||
|
||||
export default function Header({ title, subtitle, imageUrl, actions, maxWidth = 'max-w-7xl' }: HeaderProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Admin Warning */}
|
||||
{isAuthenticated && (
|
||||
<div className="sticky top-0 z-50 bg-yellow-50 dark:bg-yellow-900 border-b border-yellow-200 dark:border-yellow-800">
|
||||
<div className={`${maxWidth} mx-auto py-3 px-4 sm:px-6 lg:px-8`}>
|
||||
<p className="text-center text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ Atenção: visualizar listas públicas pode estragar surpresas
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Distant atmospheric haze */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -top-16 -right-12 w-80 h-80 rounded-full opacity-50 blur-3xl"
|
||||
style={{ background: 'radial-gradient(circle, var(--halo-1), transparent 65%)' }}
|
||||
/>
|
||||
<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="bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div className={`${maxWidth} mx-auto py-16 px-4 sm:py-24 sm:px-6 lg:px-8`}>
|
||||
<div className={`${maxWidth} mx-auto relative py-14 px-4 sm:py-20 sm:px-6 lg:px-8`}>
|
||||
<div className="flex flex-col md:flex-row items-center md:items-start gap-8">
|
||||
{imageUrl && (
|
||||
<div className="md:w-64 flex-shrink-0">
|
||||
<div className="md:w-56 flex-shrink-0">
|
||||
<img
|
||||
src={imageUrl}
|
||||
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 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}
|
||||
</h1>
|
||||
{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 ${imageUrl ? '' : 'max-w-3xl mx-auto'}`}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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,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
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme}
|
||||
- ADMIN_TOKEN=${ADMIN_TOKEN:?ADMIN_TOKEN must be set}
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-1000}
|
||||
- COOKIE_SECURE=true
|
||||
@@ -16,7 +15,7 @@ services:
|
||||
- web
|
||||
labels:
|
||||
- "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.tls=true"
|
||||
- "traefik.http.routers.chadebebe.tls.certresolver=letsencrypt"
|
||||
|
||||
90
lib/api.ts
90
lib/api.ts
@@ -23,14 +23,23 @@ async function handleResponse<T>(response: Response): Promise<T> {
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
async login(username: string, password: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
async session(payload: { adm?: string; usr?: string }) {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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() {
|
||||
@@ -38,22 +47,52 @@ export const authApi = {
|
||||
method: 'POST',
|
||||
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() {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
|
||||
async create(name: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/admin/guests`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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() {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/me`, {
|
||||
async rename(id: string, name: string) {
|
||||
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',
|
||||
});
|
||||
return handleResponse<{ username: string }>(response);
|
||||
return handleResponse<{ success: true }>(response);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -71,6 +110,15 @@ export interface Wishlist {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ItemClaim {
|
||||
id: string;
|
||||
quantity: number;
|
||||
note: string | null;
|
||||
isPurchased: boolean;
|
||||
claimedAt: string;
|
||||
guest: { id: string; name: string };
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: string;
|
||||
wishlistId: string;
|
||||
@@ -82,10 +130,9 @@ export interface Item {
|
||||
imageUrl: string | null;
|
||||
purchaseUrls: Array<{ label: string; url: string }> | null;
|
||||
isArchived: boolean;
|
||||
claimedByName: string | null;
|
||||
claimedByNote: string | null;
|
||||
claimedAt: string | null;
|
||||
isPurchased: boolean;
|
||||
claims: ItemClaim[];
|
||||
claimedQuantity: number;
|
||||
remainingQuantity: number;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -238,23 +285,24 @@ export const itemsApi = {
|
||||
|
||||
// Claiming API (public)
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(opts.guestId ? { guestId: opts.guestId } : {}),
|
||||
});
|
||||
return handleResponse<{ success: boolean; message: string }>(response);
|
||||
return handleResponse<{ success: true }>(response);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -285,8 +333,6 @@ export const scrapingApi = {
|
||||
export interface Settings {
|
||||
siteTitle: string;
|
||||
homepageSubtext: string;
|
||||
passwordLockEnabled?: boolean;
|
||||
passwordLock?: string;
|
||||
}
|
||||
|
||||
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
|
||||
try {
|
||||
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');
|
||||
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) {
|
||||
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 { 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', {
|
||||
id: text('id').primaryKey().$defaultFn(() => createId()),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
description: text('description'),
|
||||
preferences: text('preferences'), // General interests/likes section
|
||||
preferences: text('preferences'),
|
||||
imageUrl: text('image_url'),
|
||||
isPublic: integer('is_public', { mode: 'boolean' }).notNull().default(false),
|
||||
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())`),
|
||||
});
|
||||
|
||||
// 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', {
|
||||
id: text('id').primaryKey().$defaultFn(() => createId()),
|
||||
wishlistId: text('wishlist_id').notNull().references(() => wishlists.id, { onDelete: 'cascade' }),
|
||||
@@ -34,27 +30,35 @@ export const wishlistItems = sqliteTable('wishlist_items', {
|
||||
price: real('price'),
|
||||
currency: text('currency').notNull().default('USD'),
|
||||
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<{
|
||||
label: string;
|
||||
url: string;
|
||||
}>>(),
|
||||
|
||||
isArchived: integer('is_archived', { mode: 'boolean' }).notNull().default(false),
|
||||
|
||||
// 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),
|
||||
|
||||
// claim ownership lives in item_claims (1 item -> many claims; unique per (item, guest))
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
createdAt: integer('created_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', {
|
||||
id: text('id').primaryKey().$defaultFn(() => createId()),
|
||||
key: text('key').notNull().unique(),
|
||||
@@ -62,7 +66,8 @@ export const settings = sqliteTable('settings', {
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Type exports
|
||||
export type Wishlist = typeof wishlists.$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;
|
||||
|
||||
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:push": "drizzle-kit push",
|
||||
"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": {
|
||||
"@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