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

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>
);
}