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:
493
app/page.tsx
493
app/page.tsx
@@ -1,65 +1,438 @@
|
||||
import Image from "next/image";
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ChevronRight, PawPrint, Heart, ArrowRight } from 'lucide-react';
|
||||
import Header from '@/components/layout/Header';
|
||||
import Footer from '@/components/layout/Footer';
|
||||
import AnimalCard from '@/components/animals/AnimalCard';
|
||||
import FilterChips, { FILTER_OPTIONS } from '@/components/animals/FilterChips';
|
||||
import { MOCK_ANIMALS, Animal } from '@/lib/mock-data';
|
||||
|
||||
function useCountUp(target: number, duration = 1800) {
|
||||
const [count, setCount] = useState(0);
|
||||
const [started, setStarted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!started) return;
|
||||
const start = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const elapsed = now - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setCount(Math.floor(eased * target));
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}, [started, target, duration]);
|
||||
|
||||
return { count, setStarted };
|
||||
}
|
||||
|
||||
function AnimatedCounter({ target, label }: { target: number; label: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { count, setStarted } = useCountUp(target);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => { if (entry.isIntersecting) setStarted(true); },
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
if (ref.current) observer.observe(ref.current);
|
||||
return () => observer.disconnect();
|
||||
}, [setStarted]);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<div ref={ref} style={{ textAlign: 'center' }} role="status" aria-label={`${count} ${label}`}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-accent)',
|
||||
fontSize: 'clamp(32px, 6vw, 48px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--terra)',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{count.toLocaleString('pt-PT')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-accent)',
|
||||
fontSize: '11px',
|
||||
color: 'var(--soil-faint)',
|
||||
marginTop: '6px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
const [displayed, setDisplayed] = useState<Animal[]>(MOCK_ANIMALS.slice(0, 8));
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = filterAnimals(MOCK_ANIMALS, activeFilter);
|
||||
setDisplayed(filtered.slice(0, 8));
|
||||
}, [activeFilter]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
|
||||
<main style={{ flex: 1 }}>
|
||||
{/* ── Hero ───────────────────────────────────────────────── */}
|
||||
<section className="hero" aria-labelledby="hero-heading">
|
||||
<div className="hero-grain" aria-hidden="true" />
|
||||
<div className="hero-glow" aria-hidden="true" />
|
||||
|
||||
<div className="hero-content container">
|
||||
<p className="hero-eyebrow">Portugal · Adopção responsável</p>
|
||||
|
||||
<h1
|
||||
id="hero-heading"
|
||||
className="hero-title"
|
||||
>
|
||||
Encontra o teu<br />
|
||||
companheiro<br />
|
||||
para a <em>vida.</em>
|
||||
</h1>
|
||||
|
||||
<p className="hero-sub">
|
||||
Mais de 1.200 animais à espera de uma família em canis por todo o país.
|
||||
</p>
|
||||
|
||||
<div className="hero-actions">
|
||||
<Link
|
||||
href="/main/animals"
|
||||
className="btn btn-primary"
|
||||
id="hero-cta-explore"
|
||||
aria-label="Explorar animais disponíveis para adopção"
|
||||
>
|
||||
<PawPrint size={15} />
|
||||
Explorar animais
|
||||
</Link>
|
||||
<Link
|
||||
href="#como-funciona"
|
||||
className="btn btn-ghost"
|
||||
id="hero-cta-how"
|
||||
>
|
||||
Como funciona →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 'var(--space-8)',
|
||||
paddingTop: 'var(--space-7)',
|
||||
borderTop: '1px solid var(--parchment)',
|
||||
marginTop: 'var(--space-7)',
|
||||
animation: 'heroReveal 600ms 550ms ease both',
|
||||
}}
|
||||
aria-label="Estatísticas da plataforma"
|
||||
>
|
||||
<AnimatedCounter target={1247} label="animais à espera" />
|
||||
<div style={{ width: '1px', background: 'var(--parchment)', alignSelf: 'stretch' }} aria-hidden="true" />
|
||||
<AnimatedCounter target={38} label="canis parceiros" />
|
||||
<div style={{ width: '1px', background: 'var(--parchment)', alignSelf: 'stretch' }} aria-hidden="true" />
|
||||
<AnimatedCounter target={892} label="adopções este ano" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Animais em Destaque ────────────────────────────────── */}
|
||||
<section
|
||||
style={{ padding: 'var(--space-9) 0', background: 'var(--cream)' }}
|
||||
aria-labelledby="animals-heading"
|
||||
>
|
||||
<div className="container">
|
||||
{/* Cabeçalho da secção */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 'var(--space-4)',
|
||||
marginBottom: 'var(--space-5)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-accent)',
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.14em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--terra)',
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
Disponíveis agora
|
||||
</p>
|
||||
<h2 id="animals-heading" className="section-title">
|
||||
Animais à espera<br />
|
||||
<em style={{ fontStyle: 'italic', color: 'var(--terra)' }}>de ti.</em>
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/main/animals"
|
||||
className="btn btn-ghost"
|
||||
style={{ color: 'var(--terra)', display: 'flex', alignItems: 'center', gap: '6px' }}
|
||||
aria-label="Ver todos os animais disponíveis"
|
||||
>
|
||||
Ver todos
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<div style={{ marginBottom: 'var(--space-6)' }}>
|
||||
<FilterChips
|
||||
options={FILTER_OPTIONS}
|
||||
active={activeFilter}
|
||||
onChange={setActiveFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grelha */}
|
||||
{displayed.length > 0 ? (
|
||||
<div className="animal-grid">
|
||||
{displayed.map((animal, index) => (
|
||||
<AnimalCard key={animal.id} animal={animal} index={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: 'var(--space-9) var(--space-5)',
|
||||
color: 'var(--soil-mid)',
|
||||
}}
|
||||
>
|
||||
<PawPrint size={40} style={{ margin: '0 auto 16px', opacity: 0.25 }} />
|
||||
<p style={{ fontFamily: 'var(--font-body)', fontSize: '18px' }}>
|
||||
Nenhum animal encontrado com esse filtro.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setActiveFilter('all')}
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: 'var(--space-4)' }}
|
||||
>
|
||||
Ver todos
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayed.length > 0 && (
|
||||
<div style={{ textAlign: 'center', marginTop: 'var(--space-7)' }}>
|
||||
<Link href="/main/animals" className="btn btn-primary" id="homepage-see-all">
|
||||
Ver todos os animais
|
||||
<ChevronRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Como Funciona ──────────────────────────────────────── */}
|
||||
<section
|
||||
id="como-funciona"
|
||||
style={{ padding: 'var(--space-9) 0', background: 'var(--linen)' }}
|
||||
aria-labelledby="how-heading"
|
||||
>
|
||||
<div className="container">
|
||||
<div style={{ textAlign: 'center', marginBottom: 'var(--space-7)' }}>
|
||||
<p style={{ fontFamily: 'var(--font-accent)', fontSize: '11px', letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--terra)', marginBottom: '10px' }}>
|
||||
Simples e transparente
|
||||
</p>
|
||||
<h2 id="how-heading" className="section-title">Como funciona</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
||||
gap: 'var(--space-5)',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ step: '01', emoji: '🐾', title: 'Descobre', desc: 'Navega pelos animais disponíveis e filtra por distrito, espécie ou características.' },
|
||||
{ step: '02', emoji: '💛', title: 'Liga-te', desc: 'Lê a ficha completa, vê as fotos e conhece a história do animal.' },
|
||||
{ step: '03', emoji: '📅', title: 'Reserva', desc: 'Faz a reserva online e recebe confirmação por email. Simples e seguro.' },
|
||||
{ step: '04', emoji: '🏡', title: 'Adopta', desc: 'Vai ao canil na data marcada e leva o teu novo companheiro para casa.' },
|
||||
].map(({ step, emoji, title, desc }) => (
|
||||
<div
|
||||
key={step}
|
||||
style={{
|
||||
background: 'var(--cream)',
|
||||
border: '1px solid var(--parchment)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: 'var(--space-6)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--space-3)',
|
||||
transition: 'transform 220ms var(--ease-spring), box-shadow 220ms ease',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.transform = 'translateY(-4px)';
|
||||
el.style.boxShadow = 'var(--shadow-warm-md)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.transform = 'translateY(0)';
|
||||
el.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: '36px', lineHeight: 1 }}>{emoji}</span>
|
||||
<span style={{ fontFamily: 'var(--font-accent)', fontSize: '11px', color: 'var(--soil-faint)', letterSpacing: '0.1em' }}>{step}</span>
|
||||
</div>
|
||||
<h3 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '20px', fontStyle: 'italic', color: 'var(--soil)' }}>
|
||||
{title}
|
||||
</h3>
|
||||
<p style={{ fontFamily: 'var(--font-body)', fontSize: '15px', lineHeight: 1.65, color: 'var(--soil-mid)' }}>
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Doações ────────────────────────────────────────────── */}
|
||||
<section
|
||||
style={{ padding: 'var(--space-9) 0', background: 'var(--cream)' }}
|
||||
aria-labelledby="donate-heading"
|
||||
>
|
||||
<div className="container">
|
||||
<div style={{ textAlign: 'center', marginBottom: 'var(--space-7)' }}>
|
||||
<p style={{ fontFamily: 'var(--font-accent)', fontSize: '11px', letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--terra)', marginBottom: '10px' }}>
|
||||
Também podes ajudar assim
|
||||
</p>
|
||||
<h2 id="donate-heading" className="section-title" style={{ marginBottom: 'var(--space-3)' }}>
|
||||
Os canis precisam de<br />
|
||||
<em style={{ fontStyle: 'italic', color: 'var(--terra)' }}>mais</em> do que adopções.
|
||||
</h2>
|
||||
<p className="section-subtitle" style={{ maxWidth: '520px', margin: '0 auto' }}>
|
||||
Doa ração, brinquedos ou apoio financeiro. Tu escolhes como ajudar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 'var(--space-5)' }}>
|
||||
{[
|
||||
{ href: '/main/donate?type=monetary', emoji: '💸', label: 'Doação Monetária', desc: 'Contribui directamente para os cuidados veterinários e alimentação.' },
|
||||
{ href: '/main/donate?type=food', emoji: '🥩', label: 'Doação de Ração', desc: 'Escolhe a quantidade e enviamos ao canil da tua escolha.' },
|
||||
{ href: '/main/donate?type=toys', emoji: '🎾', label: 'Brinquedos', desc: 'Enriquecimento ambiental para animais em espera de adopção.' },
|
||||
].map(({ href, emoji, label, desc }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
style={{ textDecoration: 'none' }}
|
||||
aria-label={label}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--linen)',
|
||||
border: '1px solid var(--parchment)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: 'var(--space-6)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--space-4)',
|
||||
height: '100%',
|
||||
boxShadow: 'var(--shadow-warm-sm)',
|
||||
transition: 'transform 250ms var(--ease-spring), box-shadow 250ms ease',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.transform = 'translateY(-8px)';
|
||||
el.style.boxShadow = 'var(--shadow-warm-lg)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.transform = 'translateY(0)';
|
||||
el.style.boxShadow = 'var(--shadow-warm-sm)';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '56px',
|
||||
lineHeight: 1,
|
||||
filter: 'drop-shadow(0 8px 12px rgba(35,20,8,0.15))',
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontWeight: 700,
|
||||
fontSize: '22px',
|
||||
color: 'var(--soil)',
|
||||
marginBottom: '8px',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</h3>
|
||||
<p style={{ fontFamily: 'var(--font-body)', fontSize: '15px', color: 'var(--soil-mid)', lineHeight: 1.6 }}>
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontFamily: 'var(--font-accent)',
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--terra)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Saber mais
|
||||
<ChevronRight size={13} />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user