feat: sessão #3 — lib (db/auth/email/validations), API routes, NextAuth v5, middleware, páginas account/shelters/shelter-dashboard, Prisma v7 fix

This commit is contained in:
2026-05-21 09:01:59 +01:00
parent e6ebc0909c
commit e62dc9d6e6
44 changed files with 5341 additions and 273 deletions

View File

@@ -0,0 +1,575 @@
'use client';
import { useState, useEffect } from 'react';
import { notFound } from 'next/navigation';
import Image from 'next/image';
import Link from 'next/link';
import {
MapPin,
Clock,
CheckCircle2,
AlertTriangle,
ChevronRight,
ChevronLeft,
Heart,
X,
PawPrint,
Phone,
Mail,
ArrowLeft,
} from 'lucide-react';
import { MOCK_ANIMALS, formatAge, formatSex, Animal } from '@/lib/mock-data';
// TODO: substituir por query Prisma quando DATABASE_URL estiver configurada
function ConfirmationOverlay({ animal, onClose }: { animal: Animal; onClose: () => void }) {
const [pieces] = useState(() =>
Array.from({ length: 20 }, (_, i) => ({
id: i,
x: Math.random() * 100,
color: ['#C4501A', '#E8952A', '#3D6B4F', '#2D1B0E'][Math.floor(Math.random() * 4)],
delay: Math.random() * 0.8,
duration: 1.5 + Math.random() * 1.5,
size: 6 + Math.random() * 10,
}))
);
return (
<div className="confirmation-overlay" role="dialog" aria-modal="true" aria-label="Reserva confirmada">
{/* Confetti */}
{pieces.map(p => (
<div
key={p.id}
className="confetti-piece"
style={{
left: `${p.x}%`,
top: 0,
width: `${p.size}px`,
height: `${p.size}px`,
background: p.color,
animationDuration: `${p.duration}s`,
animationDelay: `${p.delay}s`,
}}
aria-hidden="true"
/>
))}
{/* Card */}
<div
style={{
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: '24px',
padding: 'clamp(32px, 5vw, 48px)',
maxWidth: '420px',
width: '90%',
textAlign: 'center',
position: 'relative',
zIndex: 1,
boxShadow: '0 24px 80px rgba(45,27,14,0.15)',
animation: 'bounceIn 500ms cubic-bezier(0.34, 1.56, 0.64, 1) both',
}}
>
{/* Paw icon */}
<div
style={{
width: '72px',
height: '72px',
background: 'var(--color-terra-light)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 20px',
animation: 'pawBounce 1.5s ease-in-out infinite',
}}
aria-hidden="true"
>
<PawPrint size={32} style={{ color: 'var(--color-terra)' }} />
</div>
<h2
style={{
fontFamily: 'var(--font-playfair, Georgia, serif)',
fontWeight: 900,
fontSize: '28px',
color: 'var(--color-text)',
marginBottom: '8px',
}}
>
Pedido enviado! 🎉
</h2>
<p
style={{
fontFamily: 'var(--font-body)',
fontSize: '16px',
color: 'var(--color-muted)',
lineHeight: 1.6,
marginBottom: '24px',
}}
>
O teu pedido de adopção do{' '}
<strong style={{ color: 'var(--color-text)', fontWeight: 600 }}>{animal.name}</strong>{' '}
foi enviado para o{' '}
<strong style={{ color: 'var(--color-text)', fontWeight: 600 }}>{animal.shelter.name}</strong>.
<br /><br />
Vais receber um email de confirmação em breve.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<button
className="btn-primary"
style={{ background: 'var(--color-sage)', width: '100%', justifyContent: 'center' }}
onClick={onClose}
id="confirmation-view-reservation"
>
<CheckCircle2 size={16} />
Ver a minha reserva
</button>
<button
className="btn-ghost"
style={{ width: '100%', justifyContent: 'center' }}
onClick={onClose}
>
Voltar à ficha
</button>
</div>
</div>
</div>
);
}
function Lightbox({ photos, initial, onClose }: { photos: string[]; initial: number; onClose: () => void }) {
const [current, setCurrent] = useState(initial);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowRight') setCurrent(c => (c + 1) % photos.length);
if (e.key === 'ArrowLeft') setCurrent(c => (c - 1 + photos.length) % photos.length);
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [photos.length, onClose]);
return (
<div
className="lightbox"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="Galeria de fotos"
>
<button
onClick={onClose}
style={{
position: 'absolute',
top: '16px',
right: '16px',
background: 'rgba(255,255,255,0.1)',
border: 'none',
borderRadius: '50%',
width: '44px',
height: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'white',
}}
aria-label="Fechar galeria"
>
<X size={20} />
</button>
{photos.length > 1 && (
<>
<button
onClick={e => { e.stopPropagation(); setCurrent(c => (c - 1 + photos.length) % photos.length); }}
style={{
position: 'absolute',
left: '16px',
top: '50%',
transform: 'translateY(-50%)',
background: 'rgba(255,255,255,0.1)',
border: 'none',
borderRadius: '50%',
width: '44px',
height: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'white',
}}
aria-label="Foto anterior"
>
<ChevronLeft size={22} />
</button>
<button
onClick={e => { e.stopPropagation(); setCurrent(c => (c + 1) % photos.length); }}
style={{
position: 'absolute',
right: '16px',
top: '50%',
transform: 'translateY(-50%)',
background: 'rgba(255,255,255,0.1)',
border: 'none',
borderRadius: '50%',
width: '44px',
height: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'white',
}}
aria-label="Próxima foto"
>
<ChevronRight size={22} />
</button>
</>
)}
<div
style={{ position: 'relative', maxWidth: '90vw', maxHeight: '85vh', width: '100%' }}
onClick={e => e.stopPropagation()}
>
<Image
src={photos[current]}
alt={`Foto ${current + 1} de ${photos.length}`}
width={900}
height={675}
style={{ objectFit: 'contain', borderRadius: '12px', maxHeight: '85vh', width: 'auto' }}
/>
</div>
</div>
);
}
export default function AnimalDetailPage({ params }: { params: { id: string } }) {
const animal = MOCK_ANIMALS.find(a => a.id === params.id);
const [mainPhoto, setMainPhoto] = useState(0);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxPhoto, setLightboxPhoto] = useState(0);
const [showConfirmation, setShowConfirmation] = useState(false);
if (!animal) return notFound();
const ageLabel = formatAge(animal.ageMonths);
const openLightbox = (index: number) => {
setLightboxPhoto(index);
setLightboxOpen(true);
};
return (
<>
{/* Lightbox */}
{lightboxOpen && (
<Lightbox
photos={animal.photos}
initial={lightboxPhoto}
onClose={() => setLightboxOpen(false)}
/>
)}
{/* Confirmation overlay */}
{showConfirmation && (
<ConfirmationOverlay animal={animal} onClose={() => setShowConfirmation(false)} />
)}
<div style={{ background: 'var(--color-bg)' }}>
{/* Breadcrumb */}
<div className="container" style={{ paddingTop: '24px', paddingBottom: '8px' }}>
<Link
href="/main/animals"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
fontFamily: 'var(--font-body)',
fontSize: '14px',
color: 'var(--color-muted)',
textDecoration: 'none',
transition: 'color 180ms ease',
}}
onMouseEnter={e => ((e.currentTarget as HTMLElement).style.color = 'var(--color-terra)')}
onMouseLeave={e => ((e.currentTarget as HTMLElement).style.color = 'var(--color-muted)')}
aria-label="Voltar à listagem de animais"
>
<ArrowLeft size={14} />
Todos os animais
</Link>
</div>
{/* Main content */}
<div className="container" style={{ padding: '16px 16px 80px' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr',
gap: '40px',
}}
className="lg:grid-cols-2"
>
{/* Left — Gallery */}
<div>
{/* Main image */}
<div
style={{
borderRadius: '16px',
overflow: 'hidden',
aspectRatio: '4/3',
position: 'relative',
cursor: 'zoom-in',
marginBottom: '12px',
background: 'var(--color-linen)',
}}
onClick={() => openLightbox(mainPhoto)}
role="button"
aria-label={`Ver foto ${mainPhoto + 1} em tamanho completo`}
tabIndex={0}
onKeyDown={e => e.key === 'Enter' && openLightbox(mainPhoto)}
>
<Image
src={animal.photos[mainPhoto]}
alt={`${animal.name}, ${animal.breed} ${animal.sex === 'MALE' ? 'macho' : 'fêmea'}, foto ${mainPhoto + 1}`}
fill
style={{ objectFit: 'cover', transition: 'transform 400ms ease' }}
priority
/>
{animal.urgent && (
<div style={{ position: 'absolute', top: '16px', left: '16px' }}>
<span className="badge-urgent">
<AlertTriangle size={10} />
Urgente
</span>
</div>
)}
</div>
{/* Thumbnails */}
{animal.photos.length > 1 && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{animal.photos.map((photo, i) => (
<button
key={i}
onClick={() => setMainPhoto(i)}
style={{
width: '72px',
height: '54px',
borderRadius: '8px',
overflow: 'hidden',
position: 'relative',
border: `2px solid ${i === mainPhoto ? 'var(--color-terra)' : 'var(--color-border)'}`,
cursor: 'pointer',
transition: 'border-color 180ms ease',
padding: 0,
background: 'none',
}}
aria-label={`Ver foto ${i + 1}`}
aria-pressed={i === mainPhoto}
>
<Image
src={photo}
alt={`Miniatura ${i + 1}`}
fill
style={{ objectFit: 'cover' }}
sizes="72px"
/>
</button>
))}
</div>
)}
</div>
{/* Right — Info */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
{/* Name & tags */}
<div>
<h1
style={{
fontFamily: 'var(--font-playfair, Georgia, serif)',
fontWeight: 900,
fontSize: 'clamp(36px, 5vw, 52px)',
color: 'var(--color-text)',
lineHeight: 1.05,
marginBottom: '12px',
}}
>
{animal.name}
</h1>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '16px' }}>
{[animal.breed, ageLabel, animal.sex === 'MALE' ? '♂ Macho' : '♀ Fêmea'].map((tag) => (
<span key={tag} className="tag">
{tag}
</span>
))}
{animal.sterilized && (
<span
className="tag"
style={{ color: 'var(--color-sage)', borderColor: 'var(--color-sage)', background: 'transparent' }}
>
<CheckCircle2 size={11} style={{ marginRight: '4px' }} />
Esterilizado/a
</span>
)}
</div>
</div>
{/* About section */}
<div>
<h2
style={{
fontFamily: 'var(--font-playfair, Georgia, serif)',
fontWeight: 700,
fontSize: '20px',
color: 'var(--color-text)',
marginBottom: '12px',
}}
>
<em style={{ fontStyle: 'italic' }}>Sobre</em> o {animal.name}
</h2>
<p
style={{
fontFamily: 'var(--font-body)',
fontSize: '16px',
lineHeight: 1.7,
color: 'var(--color-muted)',
}}
>
{animal.description}
</p>
</div>
{/* Shelter info */}
<div
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: '12px',
padding: '20px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<h3
style={{
fontFamily: 'var(--font-body)',
fontWeight: 600,
fontSize: '14px',
color: 'var(--color-text)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
}}
>
Canil responsável
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
<MapPin size={15} style={{ color: 'var(--color-terra)', flexShrink: 0, marginTop: '2px' }} />
<div>
<p style={{ fontWeight: 600, fontSize: '15px', color: 'var(--color-text)', fontFamily: 'var(--font-body)' }}>
{animal.shelter.name}
</p>
<p style={{ fontSize: '13px', color: 'var(--color-muted)', fontFamily: 'var(--font-body)' }}>
{animal.shelter.address}
</p>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<Clock size={15} style={{ color: 'var(--color-terra)', flexShrink: 0 }} />
<p style={{ fontSize: '14px', color: 'var(--color-muted)', fontFamily: 'var(--font-body)' }}>
{animal.shelter.openHours}
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<Phone size={15} style={{ color: 'var(--color-terra)', flexShrink: 0 }} />
<a
href={`tel:${animal.shelter.phone.replace(/\s/g, '')}`}
style={{ fontSize: '14px', color: 'var(--color-terra)', fontFamily: 'var(--font-body)', textDecoration: 'none' }}
>
{animal.shelter.phone}
</a>
</div>
</div>
</div>
{/* CTA — desktop */}
<div
id="adoptar"
style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}
className="hidden-mobile-cta"
>
<button
className="btn-primary"
style={{ justifyContent: 'center', fontSize: '15px', padding: '16px 32px' }}
onClick={() => setShowConfirmation(true)}
id="animal-adopt-btn"
aria-label={`Adoptar ${animal.name} — enviar pedido ao ${animal.shelter.name}`}
>
<Heart size={16} />
Adoptar o {animal.name}
<ChevronRight size={16} />
</button>
<p
style={{
fontFamily: 'var(--font-body)',
fontSize: '12px',
color: 'var(--color-muted)',
textAlign: 'center',
}}
>
Precisas de ter conta para adoptar. É gratuito e leva 2 minutos.
</p>
</div>
</div>
</div>
</div>
{/* Sticky CTA bar — mobile */}
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'var(--color-bg)',
borderTop: '1px solid var(--color-border)',
padding: '12px 16px',
display: 'flex',
gap: '10px',
zIndex: 50,
backdropFilter: 'blur(12px)',
}}
className="mobile-sticky-cta"
>
<button
className="btn-secondary"
style={{ flex: 1, justifyContent: 'center', minHeight: '48px' }}
aria-label={`Guardar ${animal.name} nos favoritos`}
>
<Heart size={16} />
Guardar
</button>
<button
className="btn-primary"
style={{ flex: 2, justifyContent: 'center', minHeight: '48px' }}
onClick={() => setShowConfirmation(true)}
id="animal-adopt-mobile-btn"
aria-label={`Adoptar ${animal.name}`}
>
Adoptar o {animal.name}
<ChevronRight size={16} />
</button>
</div>
</div>
</>
);
}

245
app/main/animals/page.tsx Normal file
View File

@@ -0,0 +1,245 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { PawPrint, SlidersHorizontal } from 'lucide-react';
import AnimalCard from '@/components/animals/AnimalCard';
import FilterChips, { FILTER_OPTIONS } from '@/components/animals/FilterChips';
import { MOCK_ANIMALS, Animal } from '@/lib/mock-data';
// TODO: substituir por query Prisma quando DATABASE_URL estiver configurada
function filterAnimals(animals: Animal[], filter: string): Animal[] {
switch (filter) {
case 'dog': return animals.filter(a => a.species === 'DOG');
case 'cat': return animals.filter(a => a.species === 'CAT');
case 'urgent': return animals.filter(a => a.urgent);
case 'lisboa': return animals.filter(a => a.shelter.district.toLowerCase() === 'lisboa');
case 'porto': return animals.filter(a => a.shelter.district.toLowerCase() === 'porto');
case 'braga': return animals.filter(a => a.shelter.district.toLowerCase() === 'braga');
case 'sintra': return animals.filter(a => a.shelter.district.toLowerCase() === 'sintra');
case 'male': return animals.filter(a => a.sex === 'MALE');
case 'female': return animals.filter(a => a.sex === 'FEMALE');
case 'sterilized': return animals.filter(a => a.sterilized);
default: return animals;
}
}
function SkeletonCard() {
return (
<div
style={{
borderRadius: '16px',
overflow: 'hidden',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
}}
>
<div className="skeleton" style={{ aspectRatio: '4/3', width: '100%' }} />
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div className="skeleton" style={{ height: '26px', width: '60%', borderRadius: '6px' }} />
<div className="skeleton" style={{ height: '16px', width: '80%', borderRadius: '6px' }} />
<div className="skeleton" style={{ height: '14px', width: '50%', borderRadius: '6px' }} />
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<div className="skeleton" style={{ height: '40px', flex: 1, borderRadius: '100px' }} />
<div className="skeleton" style={{ height: '40px', flex: 1, borderRadius: '100px' }} />
</div>
</div>
</div>
);
}
export default function AnimalsPage() {
const [activeFilter, setActiveFilter] = useState('all');
const [loading, setLoading] = useState(true);
// Simulate async load
useEffect(() => {
const t = setTimeout(() => setLoading(false), 700);
return () => clearTimeout(t);
}, []);
// Re-show skeleton briefly when filter changes
const handleFilterChange = (id: string) => {
setLoading(true);
setActiveFilter(id);
setTimeout(() => setLoading(false), 350);
};
const animals = useMemo(() => filterAnimals(MOCK_ANIMALS, activeFilter), [activeFilter]);
const urgentCount = MOCK_ANIMALS.filter(a => a.urgent).length;
return (
<div style={{ background: 'var(--color-bg)', minHeight: '100vh' }}>
{/* Page header */}
<div
style={{
borderBottom: '1px solid var(--color-border)',
padding: 'clamp(32px, 5vw, 56px) 0 0',
background: 'var(--color-bg)',
}}
>
<div className="container">
{/* Breadcrumb */}
<p
style={{
fontFamily: 'var(--font-mono, monospace)',
fontSize: '11px',
fontWeight: 500,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: 'var(--color-muted)',
marginBottom: '12px',
}}
>
PawLink · Adopção
</p>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '16px',
marginBottom: '24px',
}}
>
<div>
<h1
style={{
fontFamily: 'var(--font-playfair, Georgia, serif)',
fontWeight: 900,
fontSize: 'clamp(32px, 5vw, 48px)',
color: 'var(--color-text)',
lineHeight: 1.1,
marginBottom: '8px',
}}
>
Animais disponíveis
</h1>
<p
style={{
fontFamily: 'var(--font-body)',
fontSize: '16px',
color: 'var(--color-muted)',
lineHeight: 1.5,
}}
>
{MOCK_ANIMALS.length.toLocaleString('pt-PT')} animais à espera
{urgentCount > 0 && (
<span
style={{
marginLeft: '10px',
fontFamily: 'var(--font-mono, monospace)',
fontSize: '11px',
fontWeight: 500,
background: 'var(--color-amber)',
color: 'var(--color-soil)',
padding: '3px 8px',
borderRadius: '100px',
letterSpacing: '0.06em',
textTransform: 'uppercase',
}}
>
{urgentCount} urgentes
</span>
)}
</p>
</div>
<button
className="btn-ghost"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
border: '1.5px solid var(--color-border)',
borderRadius: '12px',
}}
aria-label="Abrir filtros avançados"
>
<SlidersHorizontal size={16} />
Filtros avançados
</button>
</div>
{/* Filter chips */}
<FilterChips
options={FILTER_OPTIONS}
active={activeFilter}
onChange={handleFilterChange}
/>
</div>
</div>
{/* Grid */}
<div className="container" style={{ padding: '40px 16px' }}>
{loading ? (
<div className="animal-grid">
{Array.from({ length: 6 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
) : animals.length > 0 ? (
<>
<div className="animal-grid">
{animals.map((animal, index) => (
<AnimalCard key={animal.id} animal={animal} index={index} />
))}
</div>
<p
style={{
textAlign: 'center',
marginTop: '40px',
fontFamily: 'var(--font-mono, monospace)',
fontSize: '12px',
color: 'var(--color-muted)',
letterSpacing: '0.06em',
}}
>
{animals.length} de {MOCK_ANIMALS.length} animais Dados de demonstração
</p>
</>
) : (
<div
style={{
textAlign: 'center',
padding: '80px 24px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '16px',
}}
>
<PawPrint
size={48}
style={{ color: 'var(--color-fog)', strokeWidth: 1.5 }}
aria-hidden="true"
/>
<h2
style={{
fontFamily: 'var(--font-display)',
fontSize: '24px',
fontWeight: 700,
color: 'var(--color-text)',
}}
>
Nenhum animal encontrado
</h2>
<p style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-body)', fontSize: '16px' }}>
Tenta um filtro diferente ou todos os animais disponíveis.
</p>
<button
onClick={() => handleFilterChange('all')}
className="btn-secondary"
style={{ marginTop: '8px' }}
>
Remover filtros
</button>
</div>
)}
</div>
</div>
);
}