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:
189
app/main/account/page.tsx
Normal file
189
app/main/account/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { User, MapPin, Calendar, Heart, Gift, Settings, LogOut, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface UserProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
district: string;
|
||||
role: string;
|
||||
emailVerified: boolean;
|
||||
createdAt: string;
|
||||
_count: { reservations: number; donations: number };
|
||||
}
|
||||
|
||||
interface Reservation {
|
||||
id: string;
|
||||
date: string;
|
||||
status: string;
|
||||
animal: {
|
||||
name: string;
|
||||
species: string;
|
||||
photos: { url: string }[];
|
||||
shelter: { name: string; district: string };
|
||||
};
|
||||
}
|
||||
|
||||
export default function AccountPage() {
|
||||
const [user, setUser] = useState<UserProfile | null>(null);
|
||||
const [reservations, setReservations] = useState<Reservation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch('/api/users/me').then(r => r.json()),
|
||||
fetch('/api/reservations').then(r => r.json()),
|
||||
])
|
||||
.then(([u, r]) => {
|
||||
setUser(u);
|
||||
setReservations(Array.isArray(r) ? r : []);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
PENDING: 'var(--amber)',
|
||||
CONFIRMED: 'var(--sage)',
|
||||
CANCELLED: 'var(--soil-faint)',
|
||||
COMPLETED: 'var(--sage)',
|
||||
};
|
||||
const statusLabel: Record<string, string> = {
|
||||
PENDING: 'Pendente',
|
||||
CONFIRMED: 'Confirmada',
|
||||
CANCELLED: 'Cancelada',
|
||||
COMPLETED: 'Concluída',
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto', padding: 'var(--space-7) var(--space-5)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{[1,2,3].map(i => (
|
||||
<div key={i} className="skeleton" style={{ height: '80px', borderRadius: '12px' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 'var(--space-9) var(--space-5)' }}>
|
||||
<p style={{ fontFamily: 'var(--font-body)', color: 'var(--soil-mid)', marginBottom: '20px' }}>
|
||||
Precisas de estar autenticado para ver esta página.
|
||||
</p>
|
||||
<Link href="/auth/login" className="btn btn-primary">Entrar</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto', padding: 'var(--space-7) var(--space-5)' }}>
|
||||
{/* Header da conta */}
|
||||
<div style={{ marginBottom: 'var(--space-7)' }}>
|
||||
<p className="eyebrow" style={{ marginBottom: '8px' }}>A tua conta</p>
|
||||
<h1 style={{ fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: 'clamp(28px, 5vw, 40px)', color: 'var(--soil)', letterSpacing: '-0.02em', lineHeight: 1.1 }}>
|
||||
Olá, <em style={{ fontStyle: 'italic', color: 'var(--terra)' }}>{user.name.split(' ')[0]}.</em>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-4)', marginBottom: 'var(--space-7)' }}>
|
||||
{[
|
||||
{ icon: Heart, label: 'Reservas', value: user._count.reservations },
|
||||
{ icon: Gift, label: 'Doações', value: user._count.donations },
|
||||
].map(({ icon: Icon, label, value }) => (
|
||||
<div key={label} style={{ background: 'var(--linen)', border: '1px solid var(--parchment)', borderRadius: '16px', padding: '20px', display: 'flex', alignItems: 'center', gap: '14px', boxShadow: 'var(--shadow-warm-sm)' }}>
|
||||
<div style={{ width: '40px', height: '40px', borderRadius: '12px', background: 'var(--terra-glow)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Icon size={18} style={{ color: 'var(--terra)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-accent)', fontSize: '11px', color: 'var(--soil-faint)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '4px' }}>{label}</div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '22px', color: 'var(--soil)' }}>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Informação pessoal */}
|
||||
<section style={{ background: 'var(--linen)', border: '1px solid var(--parchment)', borderRadius: '16px', padding: '24px', marginBottom: 'var(--space-6)', boxShadow: 'var(--shadow-warm-sm)' }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '18px', color: 'var(--soil)', marginBottom: '20px', fontStyle: 'italic' }}>
|
||||
Dados pessoais
|
||||
</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{[
|
||||
{ icon: User, label: 'Nome', value: user.name },
|
||||
{ icon: Settings, label: 'Email', value: user.email },
|
||||
{ icon: MapPin, label: 'Distrito', value: user.district },
|
||||
{ icon: Calendar, label: 'Membro desde', value: new Date(user.createdAt).toLocaleDateString('pt-PT', { year: 'numeric', month: 'long' }) },
|
||||
].map(({ icon: Icon, label, value }) => (
|
||||
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '12px', background: 'var(--cream)', borderRadius: '10px' }}>
|
||||
<Icon size={16} style={{ color: 'var(--soil-faint)', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: 'var(--font-accent)', fontSize: '10px', color: 'var(--soil-faint)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>{label}</div>
|
||||
<div style={{ fontFamily: 'var(--font-body)', fontSize: '15px', color: 'var(--soil)', marginTop: '2px' }}>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Histórico de reservas */}
|
||||
<section>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '18px', color: 'var(--soil)', marginBottom: '16px', fontStyle: 'italic' }}>
|
||||
As tuas reservas
|
||||
</h2>
|
||||
|
||||
{reservations.length === 0 ? (
|
||||
<div style={{ background: 'var(--linen)', border: '1px solid var(--parchment)', borderRadius: '16px', padding: '40px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '40px', marginBottom: '12px', opacity: 0.4 }}>🐾</div>
|
||||
<p style={{ fontFamily: 'var(--font-body)', color: 'var(--soil-mid)', marginBottom: '20px' }}>
|
||||
Ainda não tens reservas. Começa por explorar os animais disponíveis.
|
||||
</p>
|
||||
<Link href="/main/animals" className="btn btn-primary" style={{ justifyContent: 'center' }}>
|
||||
Explorar animais
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{reservations.map((res) => (
|
||||
<div
|
||||
key={res.id}
|
||||
style={{ background: 'var(--linen)', border: '1px solid var(--parchment)', borderRadius: '12px', padding: '16px 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', boxShadow: 'var(--shadow-warm-sm)', flexWrap: 'wrap' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
|
||||
{res.animal.photos[0] && (
|
||||
<img src={res.animal.photos[0].url} alt={res.animal.name} style={{ width: '48px', height: '48px', borderRadius: '10px', objectFit: 'cover', flexShrink: 0 }} />
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '16px', color: 'var(--soil)' }}>{res.animal.name}</div>
|
||||
<div style={{ fontFamily: 'var(--font-body)', fontStyle: 'italic', fontSize: '13px', color: 'var(--soil-mid)' }}>
|
||||
{res.animal.shelter.name} · {new Date(res.date).toLocaleDateString('pt-PT', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontFamily: 'var(--font-accent)', fontSize: '10px', letterSpacing: '0.08em', textTransform: 'uppercase', color: statusColor[res.status] ?? 'var(--soil-mid)', padding: '5px 12px', background: `${statusColor[res.status]}18`, borderRadius: '100px', border: `1px solid ${statusColor[res.status]}40` }}>
|
||||
{statusLabel[res.status] ?? res.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Logout */}
|
||||
<div style={{ marginTop: 'var(--space-7)', paddingTop: 'var(--space-5)', borderTop: '1px solid var(--parchment)' }}>
|
||||
<Link
|
||||
href="/api/auth/signout"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '8px', fontFamily: 'var(--font-body)', fontSize: '14px', color: 'var(--soil-mid)', textDecoration: 'none', padding: '10px 0' }}
|
||||
>
|
||||
<LogOut size={15} />
|
||||
Terminar sessão
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
575
app/main/animals/[id]/page.tsx
Normal file
575
app/main/animals/[id]/page.tsx
Normal 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
245
app/main/animals/page.tsx
Normal 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 vê todos os animais disponíveis.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleFilterChange('all')}
|
||||
className="btn-secondary"
|
||||
style={{ marginTop: '8px' }}
|
||||
>
|
||||
Remover filtros
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
app/main/donate/page.tsx
Normal file
241
app/main/donate/page.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import Link from 'next/link';
|
||||
import { Gift, CreditCard, Truck, Heart } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Fazer uma Doação',
|
||||
description: 'Doa ração, brinquedos ou apoio financeiro a canis parceiros PawLink em Portugal.',
|
||||
};
|
||||
|
||||
const donationTypes = [
|
||||
{
|
||||
id: 'monetary',
|
||||
icon: CreditCard,
|
||||
title: 'Doação Monetária',
|
||||
description: 'Transferência, cartão ou MBWay. O canil usa o valor para os cuidados que precisar.',
|
||||
badge: 'Mais impacto',
|
||||
badgeColor: 'var(--color-terra)',
|
||||
},
|
||||
{
|
||||
id: 'food',
|
||||
icon: Gift,
|
||||
title: 'Ração e Comida',
|
||||
description: 'Escolhes o tipo, a quantidade e se queres entregar em casa ou no canil.',
|
||||
badge: null,
|
||||
badgeColor: null,
|
||||
},
|
||||
{
|
||||
id: 'toys',
|
||||
icon: Heart,
|
||||
title: 'Brinquedos e Acessórios',
|
||||
description: 'Bolas, cordas, camas e muito mais. Podes escolher recolha em casa.',
|
||||
badge: null,
|
||||
badgeColor: null,
|
||||
},
|
||||
];
|
||||
|
||||
export default function DonatePage() {
|
||||
return (
|
||||
<div style={{ background: 'var(--color-bg)', minHeight: '100vh' }}>
|
||||
{/* Hero */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-soil)',
|
||||
padding: 'clamp(48px, 8vw, 80px) 0',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className="container" style={{ position: 'relative', zIndex: 1 }}>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--color-amber)',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
Faz a diferença hoje
|
||||
</p>
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: 'var(--font-playfair, Georgia, serif)',
|
||||
fontWeight: 900,
|
||||
fontSize: 'clamp(36px, 6vw, 60px)',
|
||||
color: 'var(--color-cream)',
|
||||
lineHeight: 1.1,
|
||||
maxWidth: '560px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
Doa ao teu canil{' '}
|
||||
<em style={{ color: 'var(--color-amber)', fontStyle: 'italic' }}>favorito.</em>
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: '16px',
|
||||
color: 'rgba(250,246,240,0.65)',
|
||||
maxWidth: '440px',
|
||||
lineHeight: 1.65,
|
||||
}}
|
||||
>
|
||||
Ração, brinquedos ou apoio financeiro. Escolhe como queres ajudar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Donation types */}
|
||||
<div className="container" style={{ padding: 'clamp(40px, 6vw, 64px) 16px' }}>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: 'var(--font-playfair, Georgia, serif)',
|
||||
fontWeight: 900,
|
||||
fontSize: 'clamp(24px, 4vw, 36px)',
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: '32px',
|
||||
}}
|
||||
>
|
||||
Como queres ajudar?
|
||||
</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '48px',
|
||||
}}
|
||||
>
|
||||
{donationTypes.map(({ id, icon: Icon, title, description, badge, badgeColor }) => (
|
||||
<div
|
||||
key={id}
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1.5px solid var(--color-border)',
|
||||
borderRadius: '16px',
|
||||
padding: '28px 24px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 200ms ease',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.borderColor = 'var(--color-terra)';
|
||||
el.style.transform = 'translateY(-4px)';
|
||||
el.style.boxShadow = '0 12px 40px rgba(45,27,14,0.1)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.borderColor = 'var(--color-border)';
|
||||
el.style.transform = 'translateY(0)';
|
||||
el.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
{badge && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
right: '16px',
|
||||
background: badgeColor || 'var(--color-terra)',
|
||||
color: 'white',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '9px',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '100px',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
background: 'var(--color-terra-light)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<Icon size={22} style={{ color: 'var(--color-terra)' }} />
|
||||
</div>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontWeight: 700,
|
||||
fontSize: '20px',
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.65,
|
||||
color: 'var(--color-muted)',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ width: '100%', justifyContent: 'center', fontSize: '13px', padding: '12px 20px', minHeight: '44px' }}
|
||||
aria-label={`Seleccionar: ${title}`}
|
||||
>
|
||||
Escolher
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Auth notice */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-linen)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px 24px',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: '15px',
|
||||
color: 'var(--color-text)',
|
||||
maxWidth: '400px',
|
||||
}}
|
||||
>
|
||||
Para fazer uma doação precisas de ter conta PawLink. É grátis, rápido e seguro.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||||
<Link href="/auth/register" className="btn-primary" style={{ fontSize: '13px', padding: '12px 20px', minHeight: '44px' }}>
|
||||
Criar conta
|
||||
</Link>
|
||||
<Link href="/auth/login" className="btn-secondary" style={{ fontSize: '13px', padding: '12px 20px', minHeight: '44px' }}>
|
||||
Entrar
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
app/main/layout.tsx
Normal file
12
app/main/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import Header from '@/components/layout/Header';
|
||||
import Footer from '@/components/layout/Footer';
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main style={{ flex: 1, minHeight: '60vh' }}>{children}</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
176
app/main/shelters/page.tsx
Normal file
176
app/main/shelters/page.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { MapPin, Phone, Mail, Clock, PawPrint, ChevronRight } from 'lucide-react';
|
||||
import Header from '@/components/layout/Header';
|
||||
import Footer from '@/components/layout/Footer';
|
||||
|
||||
interface Shelter {
|
||||
id: string;
|
||||
name: string;
|
||||
district: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
description: string | null;
|
||||
website: string | null;
|
||||
openHours: Record<string, string | null>;
|
||||
_count: { animals: number };
|
||||
}
|
||||
|
||||
const DAY_LABELS: Record<string, string> = {
|
||||
mon: 'Seg', tue: 'Ter', wed: 'Qua',
|
||||
thu: 'Qui', fri: 'Sex', sat: 'Sáb', sun: 'Dom',
|
||||
};
|
||||
|
||||
export default function SheltersPage() {
|
||||
const [shelters, setShelters] = useState<Shelter[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/shelters')
|
||||
.then((r) => r.json())
|
||||
.then((data) => setShelters(Array.isArray(data) ? data : []))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main style={{ flex: 1 }}>
|
||||
{/* Hero */}
|
||||
<section style={{ padding: 'var(--space-9) 0 var(--space-7)', background: 'var(--cream)', borderBottom: '1px solid var(--parchment)' }}>
|
||||
<div className="container">
|
||||
<p className="eyebrow" style={{ marginBottom: '10px' }}>Portugal</p>
|
||||
<h1 style={{ fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: 'clamp(36px, 6vw, 60px)', color: 'var(--soil)', letterSpacing: '-0.03em', lineHeight: 1.05, marginBottom: 'var(--space-4)' }}>
|
||||
Canis <em style={{ fontStyle: 'italic', color: 'var(--terra)' }}>parceiros.</em>
|
||||
</h1>
|
||||
<p style={{ fontFamily: 'var(--font-body)', fontSize: '18px', color: 'var(--soil-mid)', maxWidth: '520px', lineHeight: 1.65 }}>
|
||||
Organizações verificadas em todo o país que trabalham diariamente para encontrar lares a animais abandonados.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Listagem */}
|
||||
<section style={{ padding: 'var(--space-8) 0', background: 'var(--cream)' }}>
|
||||
<div className="container">
|
||||
{loading ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 'var(--space-5)' }}>
|
||||
{[1,2,3,4].map(i => (
|
||||
<div key={i} className="skeleton-card">
|
||||
<div className="skeleton skeleton-image" style={{ height: '120px' }} />
|
||||
<div className="skeleton skeleton-title" />
|
||||
<div className="skeleton skeleton-sub" />
|
||||
<div className="skeleton skeleton-location" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : shelters.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 'var(--space-9) 0' }}>
|
||||
<PawPrint size={48} style={{ margin: '0 auto 16px', opacity: 0.2, color: 'var(--soil)' }} />
|
||||
<p style={{ fontFamily: 'var(--font-body)', fontSize: '18px', color: 'var(--soil-mid)' }}>
|
||||
Nenhum canil disponível de momento.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 'var(--space-5)' }}>
|
||||
{shelters.map((shelter) => (
|
||||
<article
|
||||
key={shelter.id}
|
||||
style={{
|
||||
background: 'var(--linen)',
|
||||
border: '1px solid var(--parchment)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'var(--shadow-warm-sm)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 220ms var(--ease-spring), box-shadow 220ms ease',
|
||||
animation: 'cardReveal 500ms cubic-bezier(0.22, 1, 0.36, 1) both',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.transform = 'translateY(-4px)';
|
||||
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)';
|
||||
}}
|
||||
>
|
||||
{/* Header colorido */}
|
||||
<div style={{ background: 'var(--terra-glow)', borderBottom: '1px solid var(--parchment)', padding: '20px 20px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div style={{ width: '40px', height: '40px', borderRadius: '12px', background: 'var(--terra)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<PawPrint size={18} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '16px', color: 'var(--soil)', lineHeight: 1.2 }}>{shelter.name}</h2>
|
||||
<p style={{ fontFamily: 'var(--font-accent)', fontSize: '10px', color: 'var(--soil-faint)', letterSpacing: '0.08em', textTransform: 'uppercase', marginTop: '2px' }}>{shelter.district}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '22px', color: 'var(--terra)', lineHeight: 1 }}>{shelter._count.animals}</div>
|
||||
<div style={{ fontFamily: 'var(--font-accent)', fontSize: '9px', color: 'var(--soil-faint)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>disponíveis</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: '10px', flex: 1 }}>
|
||||
{shelter.description && (
|
||||
<p style={{ fontFamily: 'var(--font-body)', fontStyle: 'italic', fontSize: '14px', color: 'var(--soil-mid)', lineHeight: 1.6 }}>
|
||||
{shelter.description.length > 120 ? `${shelter.description.slice(0, 120)}…` : shelter.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<MapPin size={13} style={{ color: 'var(--terra)', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'var(--font-body)', fontStyle: 'italic', fontSize: '13px', color: 'var(--soil-mid)' }}>{shelter.address}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Phone size={13} style={{ color: 'var(--soil-faint)', flexShrink: 0 }} />
|
||||
<a href={`tel:${shelter.phone}`} style={{ fontFamily: 'var(--font-body)', fontSize: '13px', color: 'var(--soil-mid)', textDecoration: 'none' }}>{shelter.phone}</a>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Mail size={13} style={{ color: 'var(--soil-faint)', flexShrink: 0 }} />
|
||||
<a href={`mailto:${shelter.email}`} style={{ fontFamily: 'var(--font-body)', fontSize: '13px', color: 'var(--terra)', textDecoration: 'none' }}>{shelter.email}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--parchment)', margin: '4px 0' }} />
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Link
|
||||
href={`/main/animals?district=${encodeURIComponent(shelter.district)}`}
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1, justifyContent: 'center', padding: '10px 12px', fontSize: '11px', minHeight: '40px' }}
|
||||
>
|
||||
Ver animais
|
||||
</Link>
|
||||
<Link
|
||||
href={`/main/donate?shelterId=${shelter.id}`}
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1, justifyContent: 'center', padding: '10px 12px', fontSize: '11px', minHeight: '40px', gap: '4px' }}
|
||||
>
|
||||
Doar
|
||||
<ChevronRight size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user