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