Compare commits

...

4 Commits

56 changed files with 5429 additions and 295 deletions

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db/prisma';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const animal = await prisma.animal.findUnique({
where: { id },
include: {
photos: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
shelter: true,
},
});
if (!animal) {
return NextResponse.json({ error: 'Animal não encontrado.' }, { status: 404 });
}
return NextResponse.json(animal);
}

51
app/api/animals/route.ts Normal file
View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db/prisma';
import { animalFilterSchema } from '@/lib/validations/animal';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const parsed = animalFilterSchema.safeParse(
Object.fromEntries(searchParams.entries())
);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Parâmetros inválidos.', details: parsed.error.flatten() },
{ status: 400 }
);
}
const { district, species, sex, sterilized, urgent, status, page, limit } =
parsed.data;
const where = {
...(species && { species }),
...(sex && { sex }),
...(sterilized !== undefined && { sterilized }),
...(urgent !== undefined && { urgent }),
status: status ?? 'AVAILABLE',
...(district && {
shelter: { district: { contains: district, mode: 'insensitive' as const } },
}),
};
const [animals, total] = await Promise.all([
prisma.animal.findMany({
where,
include: {
photos: { where: { isPrimary: true }, take: 1 },
shelter: { select: { id: true, name: true, district: true } },
},
orderBy: [{ urgent: 'desc' }, { createdAt: 'desc' }],
skip: (page - 1) * limit,
take: limit,
}),
prisma.animal.count({ where }),
]);
return NextResponse.json({
animals,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
});
}

View File

@@ -0,0 +1,3 @@
import { handlers } from '@/lib/auth/config';
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,71 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db/prisma';
import { registerSchema } from '@/lib/validations/auth';
import { hashPassword } from '@/lib/auth/password';
import { validateAge } from '@/lib/auth/age-validation';
import { sendEmail, buildWelcomeHtml } from '@/lib/email';
export async function POST(request: Request) {
try {
const body = await request.json();
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
const errors = parsed.error.flatten().fieldErrors;
const firstError = Object.values(errors).flat()[0];
return NextResponse.json({ error: firstError ?? 'Dados inválidos.' }, { status: 400 });
}
const { name, email, password, birthdate, district } = parsed.data;
// Validação de +18 sempre no servidor
const ageCheck = validateAge(birthdate);
if (!ageCheck.valid) {
return NextResponse.json({ error: ageCheck.error }, { status: 400 });
}
// Verificar se email já existe
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: 'Este email já está registado.' }, { status: 409 });
}
const hashedPassword = await hashPassword(password);
const user = await prisma.user.create({
data: {
name,
email,
password: hashedPassword,
birthdate: new Date(birthdate),
district,
},
select: { id: true, name: true, email: true },
});
// Email de boas-vindas (não bloqueia a resposta)
sendEmail({
to: user.email,
subject: 'Bem-vindo à PetLink 🐾',
html: buildWelcomeHtml({ userName: user.name }),
}).catch(console.error);
return NextResponse.json(
{ message: 'Conta criada com sucesso.', userId: user.id },
{ status: 201 }
);
} catch (err: unknown) {
console.error('[POST /api/auth/register]', err);
// Erro de ligação à base de dados — mensagem clara ao utilizador
const msg = err instanceof Error ? err.message : '';
if (msg.includes('P1001') || msg.includes('connect') || msg.includes('ECONNREFUSED')) {
return NextResponse.json(
{ error: 'Base de dados temporariamente indisponível. Tenta mais tarde.' },
{ status: 503 }
);
}
return NextResponse.json({ error: 'Erro interno. Tenta de novo.' }, { status: 500 });
}
}

View File

@@ -0,0 +1,70 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth/config';
import { prisma } from '@/lib/db/prisma';
import { updateReservationSchema } from '@/lib/validations/reservation';
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Autenticação necessária.' }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateReservationSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Dados inválidos.', details: parsed.error.flatten() },
{ status: 400 }
);
}
const reservation = await prisma.reservation.findUnique({
where: { id },
include: { animal: true },
});
if (!reservation) {
return NextResponse.json({ error: 'Reserva não encontrada.' }, { status: 404 });
}
const userRole = (session.user as { role?: string }).role;
const isOwner = reservation.userId === session.user.id;
const isShelterAdmin = userRole === 'SHELTER_ADMIN' || userRole === 'ADMIN';
if (!isOwner && !isShelterAdmin) {
return NextResponse.json({ error: 'Sem permissão.' }, { status: 403 });
}
const { status } = parsed.data;
const updated = await prisma.$transaction(async (tx) => {
const res = await tx.reservation.update({
where: { id },
data: { status },
});
// Se cancelada, devolver animal a AVAILABLE
if (status === 'CANCELLED' && reservation.animal.status === 'RESERVED') {
await tx.animal.update({
where: { id: reservation.animalId },
data: { status: 'AVAILABLE' },
});
}
// Se completada, marcar como ADOPTED
if (status === 'COMPLETED') {
await tx.animal.update({
where: { id: reservation.animalId },
data: { status: 'ADOPTED' },
});
}
return res;
});
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,99 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth/config';
import { prisma } from '@/lib/db/prisma';
import { createReservationSchema } from '@/lib/validations/reservation';
import { sendEmail, buildReservationConfirmationHtml } from '@/lib/email';
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Autenticação necessária.' }, { status: 401 });
}
const body = await request.json();
const parsed = createReservationSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Dados inválidos.', details: parsed.error.flatten() },
{ status: 400 }
);
}
const { animalId, date, notes } = parsed.data;
// Verificar que o animal existe e está disponível
const animal = await prisma.animal.findUnique({
where: { id: animalId },
include: { shelter: { select: { name: true } } },
});
if (!animal) {
return NextResponse.json({ error: 'Animal não encontrado.' }, { status: 404 });
}
if (animal.status !== 'AVAILABLE') {
return NextResponse.json({ error: 'Animal não disponível para reserva.' }, { status: 409 });
}
// Criar reserva + marcar animal como RESERVED em transacção
const reservation = await prisma.$transaction(async (tx) => {
const res = await tx.reservation.create({
data: {
userId: session.user!.id!,
animalId,
date: new Date(date),
status: 'PENDING',
notes,
},
include: {
user: { select: { name: true, email: true } },
animal: { select: { name: true, shelter: { select: { name: true } } } },
},
});
await tx.animal.update({
where: { id: animalId },
data: { status: 'RESERVED' },
});
return res;
});
// Enviar email de confirmação (não bloqueia a resposta)
sendEmail({
to: reservation.user.email,
subject: `Reserva confirmada — ${reservation.animal.name} 🐾`,
html: buildReservationConfirmationHtml({
userName: reservation.user.name,
animalName: reservation.animal.name,
shelterName: reservation.animal.shelter.name,
date: new Date(date).toLocaleDateString('pt-PT', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
}),
}),
}).catch(console.error);
return NextResponse.json(reservation, { status: 201 });
}
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Autenticação necessária.' }, { status: 401 });
}
const reservations = await prisma.reservation.findMany({
where: { userId: session.user.id },
include: {
animal: {
include: {
photos: { where: { isPrimary: true }, take: 1 },
shelter: { select: { name: true, district: true } },
},
},
},
orderBy: { date: 'desc' },
});
return NextResponse.json(reservations);
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db/prisma';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const shelter = await prisma.shelter.findUnique({
where: { id, verified: true },
include: {
animals: {
where: { status: 'AVAILABLE' },
include: { photos: { where: { isPrimary: true }, take: 1 } },
orderBy: [{ urgent: 'desc' }, { createdAt: 'desc' }],
},
needs: { where: { active: true }, orderBy: { urgent: 'desc' } },
},
});
if (!shelter) {
return NextResponse.json({ error: 'Canil não encontrado.' }, { status: 404 });
}
return NextResponse.json(shelter);
}

31
app/api/shelters/route.ts Normal file
View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db/prisma';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const district = searchParams.get('district');
const shelters = await prisma.shelter.findMany({
where: {
verified: true,
...(district && {
district: { contains: district, mode: 'insensitive' },
}),
},
select: {
id: true,
name: true,
district: true,
address: true,
phone: true,
email: true,
description: true,
website: true,
openHours: true,
_count: { select: { animals: { where: { status: 'AVAILABLE' } } } },
},
orderBy: { name: 'asc' },
});
return NextResponse.json(shelters);
}

62
app/api/users/me/route.ts Normal file
View File

@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth/config';
import { prisma } from '@/lib/db/prisma';
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Autenticação necessária.' }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
name: true,
email: true,
district: true,
role: true,
emailVerified: true,
createdAt: true,
_count: {
select: {
reservations: true,
donations: true,
},
},
},
});
if (!user) {
return NextResponse.json({ error: 'Utilizador não encontrado.' }, { status: 404 });
}
return NextResponse.json(user);
}
export async function PATCH(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Autenticação necessária.' }, { status: 401 });
}
const body = await request.json();
// Apenas permitir actualizar campos seguros
const { name, district } = body as { name?: string; district?: string };
if (name && (typeof name !== 'string' || name.length < 2 || name.length > 100)) {
return NextResponse.json({ error: 'Nome inválido.' }, { status: 400 });
}
const user = await prisma.user.update({
where: { id: session.user.id },
data: {
...(name && { name }),
...(district && { district }),
},
select: { id: true, name: true, email: true, district: true, role: true },
});
return NextResponse.json(user);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { PawPrint, Mail, ArrowLeft } from 'lucide-react';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
// TODO: substituir por chamada real à API quando o fluxo de reset estiver implementado
await new Promise((r) => setTimeout(r, 800));
setSent(true);
} catch {
setError('Erro ao enviar email. Tenta de novo.');
} finally {
setLoading(false);
}
};
if (sent) {
return (
<div style={{ textAlign: 'center', maxWidth: '420px', padding: '48px 24px' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📬</div>
<h1 style={{ fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: '26px', color: 'var(--soil)', marginBottom: '12px' }}>
Email enviado!
</h1>
<p style={{ fontFamily: 'var(--font-body)', color: 'var(--soil-mid)', marginBottom: '28px', lineHeight: 1.6 }}>
Se <strong>{email}</strong> estiver registado, receberás um email com instruções para repor a tua palavra-passe.
</p>
<Link href="/auth/login" className="btn btn-secondary" style={{ display: 'inline-flex', justifyContent: 'center', gap: '8px' }}>
<ArrowLeft size={15} />
Voltar ao login
</Link>
</div>
);
}
return (
<div
style={{
width: '100%',
maxWidth: '420px',
background: 'var(--linen)',
border: '1px solid var(--parchment)',
borderRadius: '20px',
padding: 'clamp(28px, 5vw, 44px)',
boxShadow: 'var(--shadow-warm-md)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '28px' }}>
<PawPrint size={20} style={{ color: 'var(--terra)' }} />
<span className="logo" style={{ fontSize: '18px' }}>PetLink</span>
</div>
<h1 style={{ fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: '26px', color: 'var(--soil)', marginBottom: '8px', lineHeight: 1.15 }}>
Esqueceste a palavra-passe?
</h1>
<p style={{ fontFamily: 'var(--font-body)', fontSize: '14px', color: 'var(--soil-mid)', marginBottom: '28px', lineHeight: 1.55 }}>
Introduz o teu email e enviamos um link para repor a palavra-passe.
</p>
{error && (
<div style={{ background: 'rgba(196,80,26,0.08)', border: '1px solid rgba(196,80,26,0.25)', borderRadius: '10px', padding: '12px 16px', marginBottom: '20px', fontFamily: 'var(--font-body)', fontSize: '14px', color: 'var(--terra)' }}>
{error}
</div>
)}
<form onSubmit={handleSubmit} noValidate style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label htmlFor="forgot-email" style={{ fontFamily: 'var(--font-accent)', fontSize: '11px', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--soil-faint)' }}>
Email
</label>
<div style={{ position: 'relative' }}>
<Mail size={15} style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)', color: 'var(--soil-faint)', pointerEvents: 'none' }} />
<input
id="forgot-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="o.teu@email.pt"
required
autoComplete="email"
style={{ width: '100%', padding: '12px 16px 12px 40px', background: 'var(--cream)', border: '1.5px solid var(--parchment)', borderRadius: '10px', fontFamily: 'var(--font-body)', fontSize: '15px', color: 'var(--soil)', outline: 'none', transition: 'border-color 180ms ease', minHeight: '48px' }}
onFocus={(e) => (e.target.style.borderColor = 'var(--terra)')}
onBlur={(e) => (e.target.style.borderColor = 'var(--parchment)')}
/>
</div>
</div>
<button
id="forgot-submit"
type="submit"
className="btn btn-primary"
disabled={loading}
style={{ width: '100%', justifyContent: 'center', opacity: loading ? 0.75 : 1, cursor: loading ? 'not-allowed' : 'pointer' }}
aria-label="Enviar email de recuperação"
>
{loading ? 'A enviar…' : 'Enviar link de recuperação'}
</button>
</form>
<div style={{ marginTop: '20px', textAlign: 'center' }}>
<Link href="/auth/login" style={{ fontFamily: 'var(--font-body)', fontSize: '14px', color: 'var(--soil-mid)', display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<ArrowLeft size={14} />
Voltar ao login
</Link>
</div>
</div>
);
}

14
app/auth/layout.tsx Normal file
View File

@@ -0,0 +1,14 @@
import Header from '@/components/layout/Header';
import Footer from '@/components/layout/Footer';
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
<main style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '48px 16px', background: 'var(--color-bg)' }}>
{children}
</main>
<Footer />
</>
);
}

245
app/auth/login/page.tsx Normal file
View File

@@ -0,0 +1,245 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { signIn } from 'next-auth/react';
import { PawPrint, Eye, EyeOff, Mail, Lock, AlertCircle } from 'lucide-react';
export default function LoginPage() {
const router = useRouter();
const [showPass, setShowPass] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('Email ou palavra-passe incorrectos. Tenta de novo.');
} else {
router.push('/');
router.refresh();
}
} catch {
setError('Erro de rede. Verifica a tua ligação e tenta de novo.');
} finally {
setLoading(false);
}
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '12px 16px 12px 40px',
background: 'var(--color-bg, var(--cream))',
border: '1.5px solid var(--color-border, var(--parchment))',
borderRadius: '10px',
fontFamily: 'var(--font-body)',
fontSize: '15px',
color: 'var(--color-text, var(--soil))',
outline: 'none',
transition: 'border-color 180ms ease',
minHeight: '48px',
boxSizing: 'border-box',
};
const labelStyle: React.CSSProperties = {
fontFamily: 'var(--font-accent, monospace)',
fontSize: '11px',
fontWeight: 400,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--color-muted, var(--soil-faint))',
};
return (
<div
style={{
width: '100%',
maxWidth: '420px',
background: 'var(--color-surface, var(--linen))',
border: '1px solid var(--color-border, var(--parchment))',
borderRadius: '20px',
padding: 'clamp(28px, 5vw, 44px)',
boxShadow: '0 8px 40px rgba(45,27,14,0.08)',
}}
>
{/* Marca */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '28px' }}>
<PawPrint size={20} style={{ color: 'var(--color-terra, var(--terra))' }} />
<span
style={{
fontFamily: 'var(--font-playfair, Georgia, serif)',
fontStyle: 'italic',
fontWeight: 700,
fontSize: '20px',
color: 'var(--color-terra, var(--terra))',
}}
>
PetLink
</span>
</div>
<h1
style={{
fontFamily: 'var(--font-playfair, Georgia, serif)',
fontWeight: 900,
fontSize: '28px',
color: 'var(--color-text, var(--soil))',
marginBottom: '6px',
lineHeight: 1.15,
}}
>
Iniciar sessão.
</h1>
<p
style={{
fontFamily: 'var(--font-body)',
fontSize: '14px',
color: 'var(--color-muted, var(--soil-mid))',
marginBottom: '28px',
}}
>
Ainda não tens conta?{' '}
<Link href="/auth/register" style={{ color: 'var(--color-terra, var(--terra))', fontWeight: 500 }}>
Registar
</Link>
</p>
{/* Erro */}
{error && (
<div
role="alert"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
background: 'rgba(196,80,26,0.08)',
border: '1px solid rgba(196,80,26,0.25)',
borderRadius: '10px',
padding: '12px 16px',
marginBottom: '20px',
fontFamily: 'var(--font-body)',
fontSize: '14px',
color: 'var(--color-terra, var(--terra))',
}}
>
<AlertCircle size={16} style={{ flexShrink: 0 }} />
{error}
</div>
)}
<form onSubmit={handleSubmit} noValidate style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* Email */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label htmlFor="login-email" style={labelStyle}>Email</label>
<div style={{ position: 'relative' }}>
<Mail size={15} style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)', color: 'var(--color-muted, var(--soil-faint))', pointerEvents: 'none' }} />
<input
id="login-email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="o.teu@email.pt"
required
autoComplete="email"
style={inputStyle}
onFocus={e => (e.target.style.borderColor = 'var(--color-terra, var(--terra))')}
onBlur={e => (e.target.style.borderColor = 'var(--color-border, var(--parchment))')}
/>
</div>
</div>
{/* Password */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<label htmlFor="login-password" style={labelStyle}>Palavra-passe</label>
<Link
href="/auth/forgot-password"
style={{ fontFamily: 'var(--font-body)', fontSize: '12px', color: 'var(--color-terra, var(--terra))', textDecoration: 'none' }}
>
Esqueceste-a?
</Link>
</div>
<div style={{ position: 'relative' }}>
<Lock size={15} style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)', color: 'var(--color-muted, var(--soil-faint))', pointerEvents: 'none' }} />
<input
id="login-password"
type={showPass ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="A tua palavra-passe"
required
autoComplete="current-password"
style={{ ...inputStyle, paddingRight: '44px' }}
onFocus={e => (e.target.style.borderColor = 'var(--color-terra, var(--terra))')}
onBlur={e => (e.target.style.borderColor = 'var(--color-border, var(--parchment))')}
/>
<button
type="button"
onClick={() => setShowPass(s => !s)}
style={{ position: 'absolute', right: '12px', top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-muted, var(--soil-faint))', padding: '4px', display: 'flex', alignItems: 'center' }}
aria-label={showPass ? 'Ocultar palavra-passe' : 'Mostrar palavra-passe'}
>
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{/* Submit */}
<button
id="login-submit"
type="submit"
disabled={loading}
style={{
width: '100%',
padding: '14px 24px',
background: loading ? 'var(--color-muted, var(--soil-faint))' : 'var(--color-terra, var(--terra))',
color: 'white',
border: 'none',
borderRadius: '100px',
fontFamily: 'var(--font-body)',
fontWeight: 600,
fontSize: '15px',
cursor: loading ? 'not-allowed' : 'pointer',
transition: 'all 200ms ease',
marginTop: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
}}
aria-label="Entrar na conta PetLink"
>
{loading ? (
<>
<span style={{ display: 'inline-block', width: '16px', height: '16px', border: '2px solid rgba(255,255,255,0.4)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />
A entrar
</>
) : 'Iniciar sessão'}
</button>
{/* Registar */}
<p style={{ textAlign: 'center', fontFamily: 'var(--font-body)', fontSize: '13px', color: 'var(--color-muted, var(--soil-mid))', marginTop: '4px' }}>
Não tens conta?{' '}
<Link href="/auth/register" style={{ color: 'var(--color-terra, var(--terra))', fontWeight: 500 }}>
Criar conta grátis
</Link>
</p>
</form>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}

211
app/auth/register/page.tsx Normal file
View File

@@ -0,0 +1,211 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { PawPrint, Eye, EyeOff, Mail, Lock, User, Calendar, MapPin } from 'lucide-react';
import { DISTRITOS } from '@/lib/validations/auth';
export default function RegisterPage() {
const [showPass, setShowPass] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [form, setForm] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
birthdate: '',
district: '',
terms: false,
});
const set = (k: keyof typeof form) => (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => setForm((f) => ({ ...f, [k]: e.target.type === 'checkbox' ? (e.target as HTMLInputElement).checked : e.target.value }));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? 'Erro ao criar conta. Tenta de novo.');
} else {
setSuccess(true);
}
} catch {
setError('Erro de rede. Verifica a tua ligação.');
} finally {
setLoading(false);
}
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '12px 16px 12px 40px',
background: 'var(--cream)',
border: '1.5px solid var(--parchment)',
borderRadius: '10px',
fontFamily: 'var(--font-body)',
fontSize: '15px',
color: 'var(--soil)',
outline: 'none',
transition: 'border-color 180ms ease',
minHeight: '48px',
};
const labelStyle: React.CSSProperties = {
fontFamily: 'var(--font-accent)',
fontSize: '11px',
fontWeight: 400,
letterSpacing: '0.08em',
textTransform: 'uppercase' as const,
color: 'var(--soil-faint)',
};
if (success) {
return (
<div style={{ textAlign: 'center', maxWidth: '420px', padding: '48px 24px' }}>
<div style={{ fontSize: '56px', marginBottom: '16px' }}>🐾</div>
<h1 style={{ fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: '28px', color: 'var(--soil)', marginBottom: '12px' }}>
Conta criada!
</h1>
<p style={{ fontFamily: 'var(--font-body)', color: 'var(--soil-mid)', marginBottom: '24px', lineHeight: 1.6 }}>
Bem-vindo/a à PetLink. podes explorar animais e fazer adopções.
</p>
<Link href="/auth/login" className="btn btn-primary" style={{ justifyContent: 'center' }}>
Entrar na conta
</Link>
</div>
);
}
return (
<div
style={{
width: '100%',
maxWidth: '480px',
background: 'var(--linen)',
border: '1px solid var(--parchment)',
borderRadius: '20px',
padding: 'clamp(28px, 5vw, 44px)',
boxShadow: 'var(--shadow-warm-md)',
}}
>
{/* Marca */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '28px' }}>
<PawPrint size={20} style={{ color: 'var(--terra)' }} />
<span className="logo" style={{ fontSize: '18px' }}>PetLink</span>
</div>
<h1 style={{ fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: '28px', color: 'var(--soil)', marginBottom: '6px', lineHeight: 1.15 }}>
Criar conta.
</h1>
<p style={{ fontFamily: 'var(--font-body)', fontSize: '14px', color: 'var(--soil-mid)', marginBottom: '28px' }}>
tens conta?{' '}
<Link href="/auth/login" style={{ color: 'var(--terra)', fontWeight: 500 }}>
Entrar
</Link>
</p>
{error && (
<div style={{ background: 'rgba(196,80,26,0.08)', border: '1px solid rgba(196,80,26,0.25)', borderRadius: '10px', padding: '12px 16px', marginBottom: '20px', fontFamily: 'var(--font-body)', fontSize: '14px', color: 'var(--terra)' }}>
{error}
</div>
)}
<form onSubmit={handleSubmit} noValidate style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* Nome */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label htmlFor="reg-name" style={labelStyle}>Nome completo</label>
<div style={{ position: 'relative' }}>
<User size={15} style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)', color: 'var(--soil-faint)', pointerEvents: 'none' }} />
<input id="reg-name" type="text" value={form.name} onChange={set('name')} placeholder="O teu nome" required autoComplete="name" style={inputStyle} onFocus={e => (e.target.style.borderColor = 'var(--terra)')} onBlur={e => (e.target.style.borderColor = 'var(--parchment)')} />
</div>
</div>
{/* Email */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label htmlFor="reg-email" style={labelStyle}>Email</label>
<div style={{ position: 'relative' }}>
<Mail size={15} style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)', color: 'var(--soil-faint)', pointerEvents: 'none' }} />
<input id="reg-email" type="email" value={form.email} onChange={set('email')} placeholder="o.teu@email.pt" required autoComplete="email" style={inputStyle} onFocus={e => (e.target.style.borderColor = 'var(--terra)')} onBlur={e => (e.target.style.borderColor = 'var(--parchment)')} />
</div>
</div>
{/* Password */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label htmlFor="reg-password" style={labelStyle}>Palavra-passe</label>
<div style={{ position: 'relative' }}>
<Lock size={15} style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)', color: 'var(--soil-faint)', pointerEvents: 'none' }} />
<input id="reg-password" type={showPass ? 'text' : 'password'} value={form.password} onChange={set('password')} placeholder="Mín. 8 caract., 1 maiúscula, 1 número" required autoComplete="new-password" style={{ ...inputStyle, paddingRight: '44px' }} onFocus={e => (e.target.style.borderColor = 'var(--terra)')} onBlur={e => (e.target.style.borderColor = 'var(--parchment)')} />
<button type="button" onClick={() => setShowPass(s => !s)} style={{ position: 'absolute', right: '12px', top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--soil-faint)', padding: '4px', display: 'flex', alignItems: 'center' }} aria-label={showPass ? 'Ocultar' : 'Mostrar'}>
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{/* Confirmar password */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label htmlFor="reg-confirm" style={labelStyle}>Confirmar palavra-passe</label>
<div style={{ position: 'relative' }}>
<Lock size={15} style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)', color: 'var(--soil-faint)', pointerEvents: 'none' }} />
<input id="reg-confirm" type="password" value={form.confirmPassword} onChange={set('confirmPassword')} placeholder="Repetir palavra-passe" required autoComplete="new-password" style={inputStyle} onFocus={e => (e.target.style.borderColor = 'var(--terra)')} onBlur={e => (e.target.style.borderColor = 'var(--parchment)')} />
</div>
</div>
{/* Data de nascimento */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label htmlFor="reg-birth" style={labelStyle}>Data de nascimento (mínimo 18 anos)</label>
<div style={{ position: 'relative' }}>
<Calendar size={15} style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)', color: 'var(--soil-faint)', pointerEvents: 'none' }} />
<input id="reg-birth" type="date" value={form.birthdate} onChange={set('birthdate')} required style={{ ...inputStyle, colorScheme: 'light' }} onFocus={e => (e.target.style.borderColor = 'var(--terra)')} onBlur={e => (e.target.style.borderColor = 'var(--parchment)')} />
</div>
</div>
{/* Distrito */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label htmlFor="reg-district" style={labelStyle}>Distrito</label>
<div style={{ position: 'relative' }}>
<MapPin size={15} style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)', color: 'var(--soil-faint)', pointerEvents: 'none', zIndex: 1 }} />
<select id="reg-district" value={form.district} onChange={set('district')} required style={{ ...inputStyle, appearance: 'none', paddingLeft: '40px', cursor: 'pointer' }} onFocus={e => (e.target.style.borderColor = 'var(--terra)')} onBlur={e => (e.target.style.borderColor = 'var(--parchment)')}>
<option value="">Selecciona o teu distrito</option>
{DISTRITOS.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
</div>
{/* Termos */}
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '10px', cursor: 'pointer', fontFamily: 'var(--font-body)', fontSize: '13px', color: 'var(--soil-mid)', lineHeight: 1.5 }}>
<input type="checkbox" checked={form.terms} onChange={set('terms')} required style={{ marginTop: '3px', width: '16px', height: '16px', accentColor: 'var(--terra)', flexShrink: 0 }} />
Aceito os{' '}
<Link href="/terms" target="_blank" style={{ color: 'var(--terra)' }}>Termos de Utilização</Link>
{' '}e a{' '}
<Link href="/privacy" target="_blank" style={{ color: 'var(--terra)' }}>Política de Privacidade</Link>.
</label>
<button
id="register-submit"
type="submit"
className="btn btn-primary"
disabled={loading}
style={{ width: '100%', justifyContent: 'center', marginTop: '8px', opacity: loading ? 0.75 : 1, cursor: loading ? 'not-allowed' : 'pointer' }}
aria-label="Criar conta PetLink"
>
{loading ? 'A criar conta…' : 'Criar conta'}
</button>
</form>
</div>
);
}

View File

@@ -1,130 +1,721 @@
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;0,900;1,700;1,900&family=Lora:ital,wght@0,400;0,500;1,400&family=Fragment+Mono&display=swap');
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
/* ─── PetLink Design System — Editorial Orgânico ────────────── */
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* ── Núcleo da paleta ─────────────────────────── */
--soil: #231408;
--terra: #C4501A;
--amber: #D4880A;
--sage: #3A6347;
--cream: #F9F4ED;
--linen: #EFE6D8;
--parchment: #E4D8C8;
/* ── Variações funcionais ─────────────────────── */
--terra-dim: #9A3A12;
--terra-glow: rgba(196,80,26,0.12);
--sage-dim: #274534;
--soil-mid: #5C4033;
--soil-faint: #9C8070;
/* ── Atmosfera ────────────────────────────────── */
--shadow-warm-sm: 0 1px 0 var(--parchment), 0 4px 16px rgba(35,20,8,0.06);
--shadow-warm-md: 0 2px 0 var(--parchment), 0 12px 40px rgba(35,20,8,0.10);
--shadow-warm-lg: 0 4px 0 var(--parchment), 0 24px 64px rgba(35,20,8,0.14);
/* ── Modo escuro ──────────────────────────────── */
--dark-void: #150D05;
--dark-surface: #221508;
--dark-raised: #2E1E0E;
--dark-border: #3D2910;
--dark-text: #F2E8D8;
--dark-muted: #9A7A60;
--dark-terra: #E06830;
/* ── Tipografia ───────────────────────────────── */
--font-display: 'Playfair Display', Georgia, serif;
--font-body: 'Lora', 'Georgia', serif;
--font-accent: 'Fragment Mono', 'Courier New', monospace;
/* ── Espacejamento ────────────────────────────── */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 48px;
--space-8: 64px;
--space-9: 96px;
--space-10: 128px;
/* ── Raios ────────────────────────────────────── */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-pill: 100px;
/* ── Easing ───────────────────────────────────── */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
/* ── Transições ───────────────────────────────── */
--transition-fast: 150ms var(--ease-standard);
--transition-base: 220ms var(--ease-standard);
--transition-slow: 320ms var(--ease-out-expo);
/* ── shadcn compat ────────────────────────────── */
--background: var(--cream);
--foreground: var(--soil);
--card: var(--linen);
--card-foreground: var(--soil);
--popover: var(--linen);
--popover-foreground: var(--soil);
--primary: var(--terra);
--primary-foreground: #FFFFFF;
--secondary: var(--linen);
--secondary-foreground: var(--soil);
--muted: var(--linen);
--muted-foreground: var(--soil-mid);
--accent: var(--terra-glow);
--accent-foreground: var(--terra);
--destructive: #C0392B;
--border: var(--parchment);
--input: var(--parchment);
--ring: var(--terra);
--radius: 0.625rem;
/* ── Aliases semânticos (compat. código anterior) */
--color-bg: var(--cream);
--color-surface: var(--linen);
--color-border: var(--parchment);
--color-text: var(--soil);
--color-muted: var(--soil-mid);
--color-terra: var(--terra);
--color-terra-light: rgba(196,80,26,0.08);
--color-amber: var(--amber);
--color-sage: var(--sage);
--color-soil-muted: var(--soil-faint);
--color-fog: var(--parchment);
--color-linen: var(--linen);
--color-cream: var(--cream);
--color-soil: var(--soil);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
/* ─── Modo Escuro ────────────────────────────────────────────── */
[data-theme="dark"], .dark {
--cream: var(--dark-void);
--linen: var(--dark-surface);
--parchment: var(--dark-border);
--soil: var(--dark-text);
--soil-mid: var(--dark-muted);
--terra: var(--dark-terra);
--shadow-warm-sm: 0 1px 0 rgba(0,0,0,0.3), 0 4px 16px rgba(0,0,0,0.2);
--shadow-warm-md: 0 2px 0 rgba(0,0,0,0.3), 0 12px 40px rgba(0,0,0,0.3);
--shadow-warm-lg: 0 4px 0 rgba(0,0,0,0.3), 0 24px 64px rgba(0,0,0,0.4);
--background: var(--dark-void);
--foreground: var(--dark-text);
--card: var(--dark-surface);
--border: var(--dark-border);
--input: var(--dark-border);
--color-bg: var(--dark-void);
--color-surface: var(--dark-surface);
--color-border: var(--dark-border);
--color-text: var(--dark-text);
--color-muted: var(--dark-muted);
--color-terra: var(--dark-terra);
--color-linen: var(--dark-surface);
--color-cream: var(--dark-void);
--color-soil: var(--dark-text);
}
/* ─── Transição suave entre temas ────────────────────────────── */
*, *::before, *::after {
transition: background-color 300ms ease,
border-color 300ms ease,
color 100ms ease;
}
[class*="btn"], .animal-card, .filter-chip, .menu-btn {
transition: none;
}
/* ─── Reset Base ─────────────────────────────────────────────── */
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
@apply font-sans;
background-color: var(--cream);
color: var(--soil);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scroll-behavior: smooth;
}
body {
background-color: var(--cream);
color: var(--soil);
min-height: 100dvh;
font-family: var(--font-body);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
line-height: 1.1;
color: var(--soil);
letter-spacing: -0.02em;
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
display: block;
}
button {
cursor: pointer;
font-family: var(--font-body);
background: none;
border: none;
}
:focus-visible {
outline: 2.5px solid var(--terra);
outline-offset: 4px;
border-radius: 4px;
}
}
/* ─── Container ──────────────────────────────────────────────── */
.container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 0 var(--space-5);
}
@media (min-width: 768px) {
.container { padding: 0 var(--space-7); }
}
/* ─── Botões ─────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 13px 24px;
border-radius: var(--radius-pill);
font: 500 13px/1 var(--font-accent);
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
border: 2px solid transparent;
white-space: nowrap;
text-decoration: none;
min-height: 44px;
transition: transform 180ms var(--ease-spring),
box-shadow 180ms ease,
background 150ms ease,
color 150ms ease,
border-color 150ms ease !important;
}
.btn:active { transform: scale(0.96) !important; }
.btn-primary {
background: var(--terra);
color: white;
}
.btn-primary:hover {
background: var(--terra-dim);
transform: translateY(-2px) !important;
box-shadow: 0 8px 24px var(--terra-glow);
}
.btn-secondary {
background: transparent;
color: var(--terra);
border-color: var(--terra);
}
.btn-secondary:hover {
background: var(--terra-glow);
transform: translateY(-1px) !important;
}
.btn-ghost {
background: transparent;
color: var(--soil-mid);
padding: 11px 20px;
}
.btn-ghost:hover {
background: var(--parchment);
color: var(--soil);
}
.btn-sage {
background: var(--sage);
color: white;
}
.btn-sage:hover {
background: var(--sage-dim);
transform: translateY(-2px) !important;
box-shadow: 0 8px 24px rgba(58,99,71,0.25);
}
/* ─── Header ─────────────────────────────────────────────────── */
.header {
position: sticky;
top: 0;
z-index: 100;
padding: 0 var(--space-5);
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(249,244,237,0.85);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border-bottom: 1px solid transparent;
transition: border-color 200ms ease, box-shadow 200ms ease !important;
}
[data-theme="dark"] .header, .dark .header {
background: rgba(21,13,5,0.85);
}
.header.scrolled {
border-bottom-color: var(--parchment);
box-shadow: 0 1px 0 var(--parchment), 0 8px 32px rgba(35,20,8,0.06);
}
.logo {
font: 700 italic 22px/1 var(--font-display);
color: var(--terra);
letter-spacing: -0.02em;
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
}
/* ── Hambúrguer animado ──────────────────────────────────────── */
.menu-btn {
width: 44px;
height: 44px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
padding: 10px;
border-radius: var(--radius-md);
cursor: pointer;
background: transparent;
border: none;
transition: background 150ms ease !important;
}
.menu-btn:hover { background: var(--parchment); }
.menu-btn span {
display: block;
height: 1.5px;
background: var(--soil);
border-radius: 2px;
transition: transform 250ms var(--ease-spring),
opacity 200ms ease !important;
}
.menu-btn.open span:nth-child(1) { transform: translateY(6.5px) rotate(45deg); }
.menu-btn.open span:nth-child(2) { opacity: 0; transform: scaleX(0); }
.menu-btn.open span:nth-child(3) { transform: translateY(-6.5px) rotate(-45deg); }
/* ─── Side Menu ──────────────────────────────────────────────── */
.side-menu-overlay {
position: fixed;
inset: 0;
background: rgba(35,20,8,0.4);
backdrop-filter: blur(4px);
z-index: 200;
opacity: 0;
pointer-events: none;
transition: opacity 300ms ease !important;
}
.side-menu-overlay.open {
opacity: 1;
pointer-events: all;
}
.side-menu {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(360px, 88vw);
background: var(--cream);
z-index: 201;
padding: 0;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 320ms cubic-bezier(0.32, 0, 0.15, 1) !important;
overflow-y: auto;
box-shadow: -8px 0 48px rgba(35,20,8,0.15);
}
.side-menu.open {
transform: translateX(0);
}
.menu-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-radius: var(--radius-md);
font: 500 16px/1 var(--font-body);
color: var(--soil);
cursor: pointer;
text-decoration: none;
transition: background 150ms ease, color 150ms ease !important;
min-height: 44px;
}
.menu-item:hover {
background: var(--linen);
color: var(--terra);
}
.menu-item svg { color: var(--soil-mid); flex-shrink: 0; }
.menu-item:hover svg { color: var(--terra); }
.theme-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-3);
border-radius: var(--radius-md);
background: var(--linen);
cursor: pointer;
border: none;
width: 100%;
}
/* ─── Animal Card ────────────────────────────────────────────── */
.animal-card {
background: var(--linen);
border-radius: var(--radius-lg);
border: 1px solid var(--parchment);
box-shadow: var(--shadow-warm-sm);
overflow: hidden;
cursor: pointer;
display: flex;
flex-direction: column;
animation: cardReveal 500ms cubic-bezier(0.22, 1, 0.36, 1) both;
transition: transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 220ms ease !important;
}
.animal-card:hover {
transform: translateY(-6px) scale(1.005);
box-shadow: var(--shadow-warm-lg);
}
.animal-card:nth-child(1) { animation-delay: 0ms; }
.animal-card:nth-child(2) { animation-delay: 80ms; }
.animal-card:nth-child(3) { animation-delay: 160ms; }
.animal-card:nth-child(4) { animation-delay: 240ms; }
.animal-card:nth-child(5) { animation-delay: 320ms; }
.animal-card:nth-child(6) { animation-delay: 400ms; }
.animal-card:nth-child(7) { animation-delay: 480ms; }
.animal-card:nth-child(8) { animation-delay: 560ms; }
/* ─── Badge Urgente ──────────────────────────────────────────── */
.badge-urgent {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--amber);
color: white;
font: 500 10px/1 var(--font-accent);
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 6px 10px;
border-radius: var(--radius-pill);
animation: urgentPulse 2.5s ease-in-out infinite;
}
/* ─── Filtros ────────────────────────────────────────────────── */
.filters-strip {
display: flex;
gap: var(--space-2);
overflow-x: auto;
padding: var(--space-2) 0 var(--space-3);
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
mask-image: linear-gradient(90deg, transparent 0, black 24px, black calc(100% - 24px), transparent 100%);
}
.filters-strip::-webkit-scrollbar { display: none; }
.filter-chip {
flex-shrink: 0;
padding: 9px 16px;
border-radius: var(--radius-pill);
border: 1.5px solid var(--parchment);
background: var(--cream);
font: 400 13px/1 var(--font-accent);
letter-spacing: 0.04em;
color: var(--soil-mid);
cursor: pointer;
white-space: nowrap;
min-height: 44px;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 150ms ease !important;
}
.filter-chip:hover {
border-color: var(--terra);
color: var(--terra);
background: rgba(196,80,26,0.04);
}
.filter-chip.active {
background: var(--terra);
border-color: var(--terra);
color: white;
font-weight: 500;
}
/* ─── Skeleton Loading ───────────────────────────────────────── */
.skeleton {
background: linear-gradient(
90deg,
var(--linen) 25%,
var(--parchment) 50%,
var(--linen) 75%
);
background-size: 1200px 100%;
animation: shimmer 1.8s ease-in-out infinite;
border-radius: var(--radius-sm);
}
.skeleton-card {
background: var(--linen);
border-radius: var(--radius-lg);
border: 1px solid var(--parchment);
overflow: hidden;
}
.skeleton-image { height: 180px; }
.skeleton-title { height: 22px; width: 65%; margin: 16px 16px 8px; }
.skeleton-sub { height: 14px; width: 80%; margin: 0 16px 12px; }
.skeleton-location { height: 14px; width: 50%; margin: 0 16px 20px; }
/* ─── Tags ───────────────────────────────────────────────────── */
.animal-tag {
padding: 6px 14px;
background: var(--linen);
border: 1px solid var(--parchment);
border-radius: var(--radius-pill);
font: 400 10px/1 var(--font-accent);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--soil-mid);
display: inline-flex;
align-items: center;
}
.animal-tag.positive {
background: rgba(58,99,71,0.08);
border-color: rgba(58,99,71,0.2);
color: var(--sage);
}
/* ─── Grid de Animais ────────────────────────────────────────── */
.animal-grid {
display: grid;
gap: var(--space-5);
grid-template-columns: 1fr;
}
@media (min-width: 640px) {
.animal-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1024px) {
.animal-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 1280px) {
.animal-grid { grid-template-columns: repeat(4, 1fr); }
}
/* ─── Secção / Tipografia ────────────────────────────────────── */
.section-title {
font: 700 clamp(28px, 5vw, 40px)/1.1 var(--font-display);
color: var(--soil);
letter-spacing: -0.02em;
}
.section-subtitle {
font: 400 18px/1.65 var(--font-body);
color: var(--soil-mid);
}
.eyebrow {
font: 400 11px/1 var(--font-accent);
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--soil-faint);
}
/* ─── Hero ───────────────────────────────────────────────────── */
.hero {
position: relative;
min-height: 85svh;
display: flex;
align-items: center;
padding: var(--space-9) var(--space-5);
overflow: hidden;
background: var(--cream);
}
.hero-grain {
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
background-size: 256px 256px;
opacity: 0.6;
pointer-events: none;
z-index: 0;
}
.hero-glow {
position: absolute;
width: 600px;
height: 600px;
border-radius: 50%;
background: radial-gradient(circle, rgba(196,80,26,0.08) 0%, transparent 70%);
top: -100px;
right: -200px;
pointer-events: none;
z-index: 0;
}
.hero-content { position: relative; z-index: 1; max-width: 720px; }
.hero-eyebrow {
font: 400 11px/1 var(--font-accent);
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--soil-faint);
margin-bottom: var(--space-4);
animation: heroReveal 600ms 100ms ease both;
}
.hero-title {
font: 900 clamp(52px, 9vw, 100px)/0.95 var(--font-display);
color: var(--soil);
letter-spacing: -0.03em;
margin-bottom: var(--space-5);
animation: heroReveal 600ms 200ms ease both;
}
.hero-title em {
font-style: italic;
color: var(--terra);
}
.hero-sub {
font: 400 18px/1.5 var(--font-body);
color: var(--soil-mid);
margin-bottom: var(--space-7);
max-width: 400px;
animation: heroReveal 600ms 350ms ease both;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
animation: heroReveal 600ms 450ms ease both;
}
/* ─── Scrollbar ──────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--parchment); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--soil-faint); }
/* ─── Toast / Confirmação ────────────────────────────────────── */
.confirmation-overlay {
position: fixed;
inset: 0;
background: linear-gradient(135deg, var(--cream) 0%, var(--linen) 100%);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 300ms ease;
}
/* ─── Lightbox ───────────────────────────────────────────────── */
.lightbox {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.9);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 200ms ease;
padding: var(--space-5);
}
/* ─── Animações ─────────────────────────────────────────────── */
@keyframes cardReveal {
from { opacity: 0; transform: translateY(32px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes heroReveal {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shimmer {
0% { background-position: -600px 0; }
100% { background-position: 600px 0; }
}
@keyframes urgentPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(212,136,10,0.4); }
50% { box-shadow: 0 0 0 8px rgba(212,136,10,0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
/* ─── Acessibilidade ─────────────────────────────────────────── */
.btn, .menu-item, .filter-chip, .animal-card, .menu-btn {
min-height: 44px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -1,20 +1,51 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import type { Metadata } from 'next';
import { Playfair_Display, Lora, Fragment_Mono } from 'next/font/google';
import Script from 'next/script';
import Providers from './providers';
import './globals.css';
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
const playfair = Playfair_Display({
subsets: ['latin'],
variable: '--font-playfair',
weight: ['700', '900'],
style: ['normal', 'italic'],
display: 'swap',
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
const lora = Lora({
subsets: ['latin'],
variable: '--font-lora',
weight: ['400', '500'],
style: ['normal', 'italic'],
display: 'swap',
});
const fragmentMono = Fragment_Mono({
subsets: ['latin'],
variable: '--font-fragment-mono',
weight: ['400'],
display: 'swap',
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: {
default: 'PetLink — Adopção de Animais em Portugal',
template: '%s | PetLink',
},
description:
'Encontra o teu companheiro para a vida. PetLink conecta adoptantes, doadores e canis em todo o território português.',
keywords: ['adopção animais', 'canis portugal', 'adoptar cão', 'adoptar gato', 'doação canil'],
authors: [{ name: 'PetLink' }],
creator: 'PetLink',
metadataBase: new URL('https://petlink.pt'),
openGraph: {
type: 'website',
locale: 'pt_PT',
url: 'https://petlink.pt',
siteName: 'PetLink',
title: 'PetLink — Adopção de Animais em Portugal',
description: 'Encontra o teu companheiro para a vida.',
},
};
export default function RootLayout({
@@ -24,10 +55,27 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
lang="pt"
className={`${playfair.variable} ${lora.variable} ${fragmentMono.variable}`}
suppressHydrationWarning
>
<body className="min-h-full flex flex-col">{children}</body>
<head>
<Script
id="theme-init"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem('petlink-theme');var d=window.matchMedia('(prefers-color-scheme: dark)').matches;if(t==='dark'||(!t&&d)){document.documentElement.classList.add('dark');document.documentElement.setAttribute('data-theme','dark');}}catch(e){}})();`,
}}
/>
</head>
<body
className="min-h-dvh flex flex-col antialiased"
style={{
fontFamily: 'var(--font-lora, var(--font-body, Lora, Georgia, serif))',
}}
>
<Providers>{children}</Providers>
</body>
</html>
);
}

189
app/main/account/page.tsx Normal file
View 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>
);
}

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',
}}
>
PetLink · 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>
);
}

View File

@@ -0,0 +1,10 @@
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 PetLink em Portugal.',
};
export default function DonateLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

237
app/main/donate/page.tsx Normal file
View File

@@ -0,0 +1,237 @@
'use client';
import Link from 'next/link';
import { Gift, CreditCard, Heart } from 'lucide-react';
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 PetLink. É 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
View 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
View 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 />
</>
);
}

View File

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

7
app/providers.tsx Normal file
View File

@@ -0,0 +1,7 @@
'use client';
import { SessionProvider } from 'next-auth/react';
export default function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

@@ -0,0 +1,173 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { PawPrint, Plus, Calendar, Settings, ChevronRight, AlertTriangle } from 'lucide-react';
interface Animal {
id: string;
name: string;
species: string;
breed: string;
status: string;
urgent: boolean;
photos: { url: string }[];
}
interface Reservation {
id: string;
date: string;
status: string;
user: { name: string; email: string };
animal: { name: string };
}
const statusBg: Record<string, string> = {
AVAILABLE: 'rgba(58,99,71,0.1)',
RESERVED: 'rgba(212,136,10,0.1)',
ADOPTED: 'rgba(35,20,8,0.08)',
};
const statusColor: Record<string, string> = {
AVAILABLE: 'var(--sage)',
RESERVED: 'var(--amber)',
ADOPTED: 'var(--soil-mid)',
};
const statusLabel: Record<string, string> = {
AVAILABLE: 'Disponível',
RESERVED: 'Reservado',
ADOPTED: 'Adoptado',
};
export default function ShelterDashboardPage() {
const [animals, setAnimals] = useState<Animal[]>([]);
const [reservations, setReservations] = useState<Reservation[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// TODO: substituir por API routes de shelter quando autenticação de canil estiver completa
// Por agora usa mock
setTimeout(() => {
setAnimals([
{ id: 'mock-1', name: 'Bobi', species: 'DOG', breed: 'Labrador', status: 'AVAILABLE', urgent: false, photos: [{ url: 'https://images.unsplash.com/photo-1587300003388-59208cc962cb?w=120&q=80' }] },
{ id: 'mock-2', name: 'Luna', species: 'CAT', breed: 'Siamês', status: 'RESERVED', urgent: true, photos: [{ url: 'https://images.unsplash.com/photo-1514888286974-6c03e2ca1dba?w=120&q=80' }] },
{ id: 'mock-3', name: 'Nala', species: 'DOG', breed: 'Golden Retriever', status: 'AVAILABLE', urgent: false, photos: [{ url: 'https://images.unsplash.com/photo-1601758125946-6ec2ef64daf8?w=120&q=80' }] },
]);
setReservations([
{ id: 'r1', date: new Date(Date.now() + 86400000 * 2).toISOString(), status: 'PENDING', user: { name: 'Ana Costa', email: 'ana@email.pt' }, animal: { name: 'Luna' } },
{ id: 'r2', date: new Date(Date.now() + 86400000 * 5).toISOString(), status: 'CONFIRMED', user: { name: 'João Silva', email: 'joao@email.pt' }, animal: { name: 'Bobi' } },
]);
setLoading(false);
}, 600);
}, []);
if (loading) {
return (
<div style={{ maxWidth: '960px', margin: '0 auto', padding: 'var(--space-7) var(--space-5)', display: 'flex', flexDirection: 'column', gap: '16px' }}>
{[1,2,3].map(i => <div key={i} className="skeleton" style={{ height: '80px', borderRadius: '12px' }} />)}
</div>
);
}
const available = animals.filter(a => a.status === 'AVAILABLE').length;
const reserved = animals.filter(a => a.status === 'RESERVED').length;
const urgent = animals.filter(a => a.urgent).length;
const pending = reservations.filter(r => r.status === 'PENDING').length;
return (
<div style={{ maxWidth: '960px', margin: '0 auto', padding: 'var(--space-7) var(--space-5)' }}>
{/* Header */}
<div style={{ marginBottom: 'var(--space-7)' }}>
<p className="eyebrow" style={{ marginBottom: '8px' }}>Painel do canil</p>
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: '16px' }}>
<h1 style={{ fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: 'clamp(28px, 5vw, 40px)', color: 'var(--soil)', letterSpacing: '-0.02em', lineHeight: 1.1 }}>
Dashboard
</h1>
<Link href="/shelter/animals/new" className="btn btn-primary" style={{ gap: '8px' }}>
<Plus size={15} />
Adicionar animal
</Link>
</div>
</div>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 'var(--space-4)', marginBottom: 'var(--space-7)' }}>
{[
{ label: 'Disponíveis', value: available, color: 'var(--sage)', emoji: '🐾' },
{ label: 'Reservados', value: reserved, color: 'var(--amber)', emoji: '📅' },
{ label: 'Urgentes', value: urgent, color: 'var(--terra)', emoji: '⚠️' },
{ label: 'Reservas pend.', value: pending, color: 'var(--soil-mid)', emoji: '📋' },
].map(({ label, value, color, emoji }) => (
<div key={label} style={{ background: 'var(--linen)', border: '1px solid var(--parchment)', borderRadius: '16px', padding: '20px', boxShadow: 'var(--shadow-warm-sm)', textAlign: 'center' }}>
<div style={{ fontSize: '28px', marginBottom: '8px' }}>{emoji}</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '28px', color, lineHeight: 1 }}>{value}</div>
<div style={{ fontFamily: 'var(--font-accent)', fontSize: '10px', color: 'var(--soil-faint)', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: '6px' }}>{label}</div>
</div>
))}
</div>
{/* Reservas pendentes */}
{reservations.length > 0 && (
<section style={{ marginBottom: 'var(--space-7)' }}>
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontStyle: 'italic', fontSize: '20px', color: 'var(--soil)', marginBottom: '16px' }}>
Próximas reservas
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{reservations.map(r => (
<div key={r.id} style={{ background: 'var(--linen)', border: '1px solid var(--parchment)', borderRadius: '12px', padding: '16px 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', flexWrap: 'wrap', boxShadow: 'var(--shadow-warm-sm)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Calendar size={18} style={{ color: 'var(--terra)', flexShrink: 0 }} />
<div>
<div style={{ fontFamily: 'var(--font-body)', fontWeight: 500, fontSize: '15px', color: 'var(--soil)' }}>
{r.user.name} <strong>{r.animal.name}</strong>
</div>
<div style={{ fontFamily: 'var(--font-body)', fontStyle: 'italic', fontSize: '13px', color: 'var(--soil-mid)' }}>
{new Date(r.date).toLocaleDateString('pt-PT', { weekday: 'long', day: 'numeric', month: 'long' })}
</div>
</div>
</div>
<span style={{ fontFamily: 'var(--font-accent)', fontSize: '10px', letterSpacing: '0.08em', textTransform: 'uppercase', color: r.status === 'PENDING' ? 'var(--amber)' : 'var(--sage)', padding: '5px 12px', background: r.status === 'PENDING' ? 'rgba(212,136,10,0.1)' : 'rgba(58,99,71,0.1)', borderRadius: '100px' }}>
{r.status === 'PENDING' ? 'Pendente' : 'Confirmada'}
</span>
</div>
))}
</div>
</section>
)}
{/* Animais */}
<section>
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontStyle: 'italic', fontSize: '20px', color: 'var(--soil)', marginBottom: '16px' }}>
Os teus animais
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{animals.map(animal => (
<div key={animal.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' }}>
{animal.photos[0] && (
<img src={animal.photos[0].url} alt={animal.name} style={{ width: '48px', height: '48px', borderRadius: '10px', objectFit: 'cover', flexShrink: 0 }} />
)}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '16px', color: 'var(--soil)' }}>{animal.name}</span>
{animal.urgent && <AlertTriangle size={14} style={{ color: 'var(--amber)' }} aria-label="Urgente" />}
</div>
<div style={{ fontFamily: 'var(--font-accent)', fontSize: '10px', color: 'var(--soil-faint)', letterSpacing: '0.06em', textTransform: 'uppercase', marginTop: '2px' }}>
{animal.species === 'DOG' ? 'Cão' : 'Gato'} · {animal.breed}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontFamily: 'var(--font-accent)', fontSize: '10px', letterSpacing: '0.08em', textTransform: 'uppercase', color: statusColor[animal.status], padding: '5px 12px', background: statusBg[animal.status], borderRadius: '100px' }}>
{statusLabel[animal.status]}
</span>
<Link href={`/shelter/animals/${animal.id}/edit`} style={{ display: 'flex', alignItems: 'center', color: 'var(--soil-faint)', padding: '4px' }} aria-label={`Editar ${animal.name}`}>
<Settings size={15} />
</Link>
</div>
</div>
))}
</div>
</section>
</div>
);
}

46
app/shelter/layout.tsx Normal file
View File

@@ -0,0 +1,46 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { PawPrint, Home } from 'lucide-react';
export const metadata: Metadata = {
title: 'Painel do Canil | PetLink',
description: 'Área de gestão do canil na plataforma PetLink.',
};
export default function ShelterLayout({ children }: { children: React.ReactNode }) {
return (
<div style={{ minHeight: '100dvh', display: 'flex', flexDirection: 'column', background: 'var(--cream)' }}>
{/* Header do painel */}
<header
style={{
height: '60px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 var(--space-5)',
background: 'var(--linen)',
borderBottom: '1px solid var(--parchment)',
position: 'sticky',
top: 0,
zIndex: 50,
}}
>
<Link href="/shelter/dashboard" style={{ display: 'flex', alignItems: 'center', gap: '8px', textDecoration: 'none' }}>
<PawPrint size={20} style={{ color: 'var(--terra)' }} />
<span style={{ fontFamily: 'var(--font-display)', fontStyle: 'italic', fontWeight: 700, fontSize: '17px', color: 'var(--terra)' }}>
PetLink Canil
</span>
</Link>
<Link
href="/"
style={{ display: 'flex', alignItems: 'center', gap: '6px', fontFamily: 'var(--font-body)', fontSize: '13px', color: 'var(--soil-mid)', textDecoration: 'none' }}
>
<Home size={14} />
Site público
</Link>
</header>
<main style={{ flex: 1 }}>{children}</main>
</div>
);
}

View File

@@ -0,0 +1,161 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import { MapPin, ChevronRight, AlertTriangle } from 'lucide-react';
import { Animal, formatAge } from '@/lib/mock-data';
interface AnimalCardProps {
animal: Animal;
index?: number;
}
export default function AnimalCard({ animal, index = 0 }: AnimalCardProps) {
const ageLabel = formatAge(animal.ageMonths);
const sexSymbol = animal.sex === 'MALE' ? '♂' : '♀';
const sterilizedLabel = animal.sterilized ? '· Esterilizado ✓' : '';
const subline = `${animal.breed} · ${ageLabel} ${sterilizedLabel}`.trim();
return (
<article
className="animal-card"
style={{ animationDelay: `${index * 80}ms` }}
aria-label={`${animal.name}, ${animal.breed}, ${ageLabel}, disponível no ${animal.shelter.name}`}
>
{/* Fotografia */}
<div style={{ position: 'relative', aspectRatio: '3/2', overflow: 'hidden' }}>
<Image
src={animal.photos[0]}
alt={`${animal.name}${animal.breed} ${animal.sex === 'MALE' ? 'macho' : 'fêmea'}, ${ageLabel}`}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
style={{
objectFit: 'cover',
transition: 'transform 400ms ease',
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.transform = 'scale(1.04)'; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.transform = 'scale(1)'; }}
/>
{/* Gradiente base da foto */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(to top, rgba(35,20,8,0.35) 0%, transparent 50%)',
pointerEvents: 'none',
}}
/>
{/* Badge urgente — top right */}
{animal.urgent && (
<div style={{ position: 'absolute', top: '12px', right: '12px' }}>
<span className="badge-urgent">
<AlertTriangle size={9} strokeWidth={2.5} />
Urgente
</span>
</div>
)}
</div>
{/* Corpo do card */}
<div
style={{
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
flex: 1,
}}
>
{/* Nome + sexo */}
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '8px' }}>
<h3
style={{
fontFamily: 'var(--font-display)',
fontWeight: 700,
fontSize: '20px',
color: 'var(--soil)',
lineHeight: 1.1,
letterSpacing: '-0.01em',
}}
>
{animal.name}
</h3>
<span
style={{
fontFamily: 'var(--font-display)',
fontWeight: 700,
fontSize: '16px',
color: 'var(--soil-mid)',
flexShrink: 0,
}}
aria-label={animal.sex === 'MALE' ? 'Macho' : 'Fêmea'}
>
{sexSymbol}
</span>
</div>
{/* Raça · Idade · Esterilizado */}
<p
style={{
fontFamily: 'var(--font-accent)',
fontSize: '10px',
fontWeight: 400,
color: 'var(--soil-faint)',
letterSpacing: '0.08em',
textTransform: 'uppercase',
lineHeight: 1.4,
}}
>
{subline}
</p>
{/* Localização */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
fontFamily: 'var(--font-body)',
fontStyle: 'italic',
fontSize: '14px',
color: 'var(--soil-mid)',
marginTop: '2px',
}}
>
<MapPin size={12} style={{ color: 'var(--terra)', flexShrink: 0 }} aria-hidden="true" />
<span>{animal.shelter.name}, {animal.shelter.district}</span>
</div>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Separador */}
<hr style={{ border: 'none', borderTop: '1px solid var(--parchment)', margin: '4px 0' }} />
{/* Acções */}
<div style={{ display: 'flex', gap: '8px' }}>
<Link
href={`/main/animals/${animal.id}`}
className="btn btn-secondary"
style={{ flex: 1, justifyContent: 'center', padding: '10px 12px', fontSize: '11px', minHeight: '40px' }}
aria-label={`Ver ficha completa de ${animal.name}`}
>
Ver mais
</Link>
<Link
href={`/main/animals/${animal.id}#adoptar`}
className="btn btn-primary"
style={{ flex: 1, justifyContent: 'center', padding: '10px 12px', fontSize: '11px', minHeight: '40px', gap: '4px' }}
aria-label={`Iniciar processo de adopção de ${animal.name}`}
>
Adoptar
<ChevronRight size={12} strokeWidth={2.5} />
</Link>
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import { PawPrint, Dog, Cat, MapPin, AlertTriangle } from 'lucide-react';
export interface FilterOption {
id: string;
label: string;
icon?: React.ReactNode;
}
export const FILTER_OPTIONS: FilterOption[] = [
{ id: 'all', label: 'Todos', icon: <PawPrint size={12} /> },
{ id: 'dog', label: 'Cães', icon: <Dog size={12} /> },
{ id: 'cat', label: 'Gatos', icon: <Cat size={12} /> },
{ id: 'urgent', label: 'Urgente', icon: <AlertTriangle size={12} /> },
{ id: 'lisboa', label: 'Lisboa', icon: <MapPin size={12} /> },
{ id: 'porto', label: 'Porto', icon: <MapPin size={12} /> },
{ id: 'braga', label: 'Braga', icon: <MapPin size={12} /> },
{ id: 'sintra', label: 'Sintra', icon: <MapPin size={12} /> },
{ id: 'male', label: 'Macho ♂' },
{ id: 'female', label: 'Fêmea ♀' },
{ id: 'sterilized', label: 'Esterilizado' },
];
interface FilterChipsProps {
options: FilterOption[];
active: string;
onChange: (id: string) => void;
}
export default function FilterChips({ options, active, onChange }: FilterChipsProps) {
return (
<div
role="group"
aria-label="Filtros de animais"
className="filters-strip"
>
{options.map((option) => {
const isActive = active === option.id;
return (
<button
key={option.id}
id={`filter-${option.id}`}
className={`filter-chip${isActive ? ' active' : ''}`}
onClick={() => onChange(option.id)}
aria-pressed={isActive}
aria-label={`Filtrar por: ${option.label}`}
>
{option.icon && (
<span style={{ display: 'flex', alignItems: 'center' }} aria-hidden="true">
{option.icon}
</span>
)}
{option.label}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,177 @@
'use client';
import Link from 'next/link';
import { PawPrint, Heart } from 'lucide-react';
export default function Footer() {
return (
<footer
style={{
background: 'var(--color-soil)',
color: 'var(--color-cream)',
padding: '48px 0 24px',
marginTop: 'auto',
}}
>
<div className="container">
{/* Top row */}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr',
gap: '40px',
marginBottom: '40px',
}}
className="md:grid-cols-3"
>
{/* Brand */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<PawPrint size={20} style={{ color: 'var(--color-terra)' }} />
<span
style={{
fontFamily: 'var(--font-playfair, Georgia, serif)',
fontStyle: 'italic',
fontWeight: 700,
fontSize: '20px',
color: 'var(--color-terra)',
}}
>
PetLink
</span>
</div>
<p
style={{
fontFamily: 'var(--font-body)',
fontSize: '14px',
lineHeight: 1.7,
color: 'rgba(250,246,240,0.65)',
maxWidth: '260px',
}}
>
Conectamos adoptantes, doadores e canis em todo o território português. Cada adopção é uma vida transformada.
</p>
</div>
{/* Links */}
<div>
<p
style={{
fontFamily: 'var(--font-mono, monospace)',
fontSize: '10px',
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: 'rgba(250,246,240,0.4)',
marginBottom: '16px',
}}
>
Plataforma
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{[
{ href: '/main/animals', label: 'Explorar animais' },
{ href: '/main/donate', label: 'Fazer uma doação' },
{ href: '/main/shelters', label: 'Canis parceiros' },
{ href: '/auth/register', label: 'Criar conta' },
].map(({ href, label }) => (
<Link
key={href}
href={href}
style={{
fontFamily: 'var(--font-body)',
fontSize: '14px',
color: 'rgba(250,246,240,0.7)',
textDecoration: 'none',
transition: 'color 180ms ease',
}}
onMouseEnter={e => ((e.target as HTMLElement).style.color = 'var(--color-terra)')}
onMouseLeave={e => ((e.target as HTMLElement).style.color = 'rgba(250,246,240,0.7)')}
>
{label}
</Link>
))}
</div>
</div>
{/* Legal */}
<div>
<p
style={{
fontFamily: 'var(--font-mono, monospace)',
fontSize: '10px',
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: 'rgba(250,246,240,0.4)',
marginBottom: '16px',
}}
>
Legal
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{[
{ href: '/privacy', label: 'Política de Privacidade' },
{ href: '/terms', label: 'Termos de Utilização' },
{ href: '/rgpd', label: 'RGPD' },
{ href: '/contact', label: 'Contacto' },
].map(({ href, label }) => (
<Link
key={href}
href={href}
style={{
fontFamily: 'var(--font-body)',
fontSize: '14px',
color: 'rgba(250,246,240,0.7)',
textDecoration: 'none',
transition: 'color 180ms ease',
}}
onMouseEnter={e => ((e.target as HTMLElement).style.color = 'var(--color-terra)')}
onMouseLeave={e => ((e.target as HTMLElement).style.color = 'rgba(250,246,240,0.7)')}
>
{label}
</Link>
))}
</div>
</div>
</div>
{/* Divider */}
<div
style={{
borderTop: '1px solid rgba(250,246,240,0.1)',
paddingTop: '24px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
alignItems: 'center',
textAlign: 'center',
}}
>
<p
style={{
fontFamily: 'var(--font-body)',
fontSize: '13px',
color: 'rgba(250,246,240,0.4)',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
Feito com
<Heart size={12} style={{ color: 'var(--color-terra)', fill: 'var(--color-terra)' }} />
em Portugal · PetLink © {new Date().getFullYear()}
</p>
<p
style={{
fontFamily: 'var(--font-mono, monospace)',
fontSize: '11px',
color: 'rgba(250,246,240,0.25)',
letterSpacing: '0.06em',
}}
>
PAP 2024/2025
</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { PawPrint } from 'lucide-react';
import SideMenu from './SideMenu';
export default function Header() {
const [scrolled, setScrolled] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
useEffect(() => {
if (menuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
}, [menuOpen]);
return (
<>
<header className={`header${scrolled ? ' scrolled' : ''}`}>
{/* Logo */}
<Link href="/" className="logo" aria-label="PetLink — Início">
<PawPrint size={22} strokeWidth={2.5} />
PetLink
</Link>
{/* Hambúrguer animado */}
<button
id="menu-toggle"
className={`menu-btn${menuOpen ? ' open' : ''}`}
onClick={() => setMenuOpen(v => !v)}
aria-label={menuOpen ? 'Fechar menu' : 'Abrir menu de navegação'}
aria-expanded={menuOpen}
aria-controls="side-menu"
>
<span />
<span />
<span />
</button>
</header>
{/* Side Menu — sempre montado, abre/fecha via CSS */}
<SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,210 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import {
X,
PawPrint,
Heart,
Gift,
Home,
Search,
User,
Sun,
Moon,
ChevronRight,
Shield,
} from 'lucide-react';
interface SideMenuProps {
open: boolean;
onClose: () => void;
}
export default function SideMenu({ open, onClose }: SideMenuProps) {
const [dark, setDark] = useState(false);
useEffect(() => {
setDark(
document.documentElement.classList.contains('dark') ||
document.documentElement.getAttribute('data-theme') === 'dark'
);
}, [open]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onClose]);
const toggleTheme = () => {
const isDark = document.documentElement.classList.toggle('dark');
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
setDark(isDark);
try { localStorage.setItem('petlink-theme', isDark ? 'dark' : 'light'); } catch (_) {}
};
const menuItems = [
{ href: '/', icon: Home, label: 'Início' },
{ href: '/main/animals', icon: Search, label: 'Explorar animais' },
{ href: '/main/donate', icon: Gift, label: 'Fazer uma doação' },
{ href: '/main/shelters', icon: PawPrint, label: 'Canis parceiros' },
{ href: '/main/animals', icon: Heart, label: 'Adoptar' },
];
const accountItems = [
{ href: '/auth/login', icon: User, label: 'Entrar / Registar' },
{ href: '/shelter/dashboard', icon: Shield, label: 'Área do Canil' },
];
return (
<>
{/* Overlay */}
<div
className={`side-menu-overlay${open ? ' open' : ''}`}
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer */}
<aside
id="side-menu"
className={`side-menu${open ? ' open' : ''}`}
role="dialog"
aria-modal="true"
aria-label="Menu de navegação"
aria-hidden={!open}
>
{/* Header do menu */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '20px 24px 16px',
borderBottom: '1px solid var(--parchment)',
flexShrink: 0,
}}
>
<Link
href="/"
className="logo"
onClick={onClose}
style={{ fontSize: '18px' }}
aria-label="PetLink — Início"
>
<PawPrint size={18} strokeWidth={2.5} />
PetLink
</Link>
<button
onClick={onClose}
className="menu-btn"
style={{ width: '36px', height: '36px' }}
aria-label="Fechar menu"
>
<X size={18} style={{ color: 'var(--soil)' }} />
</button>
</div>
{/* Nav */}
<nav style={{ flex: 1, overflowY: 'auto', padding: '8px 16px' }}>
{/* Secção principal */}
<p
style={{
fontFamily: 'var(--font-accent, var(--font-fragment-mono))',
fontSize: '10px',
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: 'var(--soil-faint)',
padding: '16px 8px 8px',
}}
>
Navegação
</p>
{menuItems.map(({ href, icon: Icon, label }) => (
<Link
key={`${href}-${label}`}
href={href}
onClick={onClose}
className="menu-item"
style={{ justifyContent: 'space-between' }}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
<Icon size={18} />
{label}
</span>
<ChevronRight size={14} style={{ color: 'var(--soil-faint)' }} />
</Link>
))}
<div style={{ margin: '12px 8px', borderTop: '1px solid var(--parchment)' }} />
<p
style={{
fontFamily: 'var(--font-accent, var(--font-fragment-mono))',
fontSize: '10px',
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: 'var(--soil-faint)',
padding: '8px 8px',
}}
>
Conta
</p>
{accountItems.map(({ href, icon: Icon, label }) => (
<Link
key={href}
href={href}
onClick={onClose}
className="menu-item"
style={{ justifyContent: 'space-between' }}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
<Icon size={18} />
{label}
</span>
<ChevronRight size={14} style={{ color: 'var(--soil-faint)' }} />
</Link>
))}
</nav>
{/* Footer — toggle de tema */}
<div style={{ padding: '16px 24px', borderTop: '1px solid var(--parchment)', flexShrink: 0 }}>
<button
id="theme-toggle"
onClick={toggleTheme}
className="theme-toggle"
aria-label={dark ? 'Activar modo claro' : 'Activar modo escuro'}
>
<span style={{ fontFamily: 'var(--font-body)', fontWeight: 500, fontSize: '14px', color: 'var(--soil)' }}>
{dark ? 'Modo escuro activo' : 'Modo claro activo'}
</span>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
background: dark ? 'var(--dark-raised, #2E1E0E)' : 'var(--parchment)',
borderRadius: '100px',
padding: '5px 10px',
transition: 'background 300ms ease',
}}
>
{dark
? <Moon size={14} style={{ color: 'var(--amber)' }} />
: <Sun size={14} style={{ color: 'var(--terra)' }} />
}
<span style={{ fontSize: '12px', color: 'var(--soil-mid)', fontWeight: 500 }}>
{dark ? 'Escuro' : 'Claro'}
</span>
</div>
</button>
</div>
</aside>
</>
);
}

View File

@@ -8,9 +8,9 @@ Os canis dependem maioritariamente de redes sociais e contacto telefónico para
---
## 1.2 A Solução — PawLink
## 1.2 A Solução — PetLink
**PawLink** é uma plataforma web que centraliza a adopção de animais e a gestão de doações para canis em todo o território português. A plataforma conecta potenciais adoptantes, doadores e canis numa interface intuitiva, segura e moderna.
**PetLink** é uma plataforma web que centraliza a adopção de animais e a gestão de doações para canis em todo o território português. A plataforma conecta potenciais adoptantes, doadores e canis numa interface intuitiva, segura e moderna.
**Missão:** Reduzir o número de animais em canis portugueses através da tecnologia, tornando a adopção responsável e a doação de bens o caminho mais fácil e natural.
@@ -23,7 +23,7 @@ Os canis dependem maioritariamente de redes sociais e contacto telefónico para
| Adoptante | Adulto(a) +18 anos, residente em Portugal | Encontrar animal compatível próximo da sua localidade |
| Doador | Pessoa física ou empresa | Contribuir financeiramente ou com bens para canis |
| Canil / Associação | Instituição de protecção animal | Gerir animais, reservas e doações recebidas |
| Administrador | Equipa PawLink | Supervisionar toda a plataforma e garantir qualidade |
| Administrador | Equipa PetLink | Supervisionar toda a plataforma e garantir qualidade |
---

View File

@@ -70,4 +70,4 @@
- A plataforma deve estar em conformidade com o **RGPD** (Regulamento Geral sobre a Protecção de Dados) e legislação portuguesa
- Os pagamentos devem ser processados por um fornecedor certificado **PCI DSS** (Stripe)
- O registo é **exclusivo para maiores de 18 anos** — validação obrigatória no servidor
- Apenas canis registados e verificados pela equipa PawLink podem listar animais para adopção
- Apenas canis registados e verificados pela equipa PetLink podem listar animais para adopção

View File

@@ -2,7 +2,7 @@
## 3.1 Visão Geral
A arquitectura da PawLink segue o padrão de aplicação web moderna com separação clara entre frontend, backend e serviços externos. Adoptamos uma abordagem **server-first** com Next.js, que permite renderização no servidor (SSR) para melhor SEO e desempenho inicial, combinada com componentes interactivos no cliente onde necessário.
A arquitectura da PetLink segue o padrão de aplicação web moderna com separação clara entre frontend, backend e serviços externos. Adoptamos uma abordagem **server-first** com Next.js, que permite renderização no servidor (SSR) para melhor SEO e desempenho inicial, combinada com componentes interactivos no cliente onde necessário.
```
┌─────────────────────────────────────────────────────────────┐

View File

@@ -81,7 +81,7 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
# Email
RESEND_API_KEY="re_..."
RESEND_FROM_EMAIL="noreply@pawlink.pt"
RESEND_FROM_EMAIL="noreply@petlink.pt"
# Supabase Storage
NEXT_PUBLIC_SUPABASE_URL="https://xxx.supabase.co"
@@ -106,7 +106,7 @@ Next.js tem o ecossistema mais maduro para aplicações React complexas com SSR.
### Porque PostgreSQL em vez de MongoDB?
Os dados da PawLink são fortemente relacionais (utilizador → reserva → animal → canil). Uma base de dados relacional como PostgreSQL garante integridade referencial por defeito, suporta transacções ACID (críticas para reservas e pagamentos) e tem melhor suporte a queries complexas com joins.
Os dados da PetLink são fortemente relacionais (utilizador → reserva → animal → canil). Uma base de dados relacional como PostgreSQL garante integridade referencial por defeito, suporta transacções ACID (críticas para reservas e pagamentos) e tem melhor suporte a queries complexas com joins.
### Porque Tailwind CSS em vez de CSS Modules ou Styled Components?

View File

@@ -148,7 +148,7 @@ Todos os uploads são validados antes de armazenar no Supabase Storage:
## 7.3 Conformidade com o RGPD
| Princípio RGPD | Implementação na PawLink |
| Princípio RGPD | Implementação na PetLink |
|---|---|
| **Consentimento explícito** | Checkbox obrigatório no registo com link para Política de Privacidade. Consentimento separado para emails de marketing (opcional). |
| **Finalidade limitada** | Dados pessoais usados apenas para adopção e doação — não partilhados com terceiros excepto processadores necessários (Stripe, Resend). |
@@ -175,7 +175,7 @@ Não são usados cookies de rastreamento ou publicidade.
## 7.4 Segurança dos Pagamentos
- Dados de cartão **nunca passam pelos servidores da PawLink** — tratados directamente pelo Stripe via Payment Element
- Dados de cartão **nunca passam pelos servidores da PetLink** — tratados directamente pelo Stripe via Payment Element
- Stripe é certificado **PCI DSS Level 1** — o mais elevado nível de conformidade
- Webhooks Stripe verificados com **assinatura HMAC-SHA256** — previne falsificação de eventos de pagamento
- Montantes validados no servidor antes de criar PaymentIntent — cliente não pode alterar o valor

View File

@@ -64,7 +64,7 @@ interface MatchContext {
// lib/ai/prompts.ts
export const MATCH_SYSTEM_PROMPT = `
És um especialista em adopção responsável de animais da plataforma PawLink,
És um especialista em adopção responsável de animais da plataforma PetLink,
em Portugal. O teu papel é ajudar utilizadores a encontrar o animal de estimação
mais compatível com o seu estilo de vida.
@@ -135,7 +135,7 @@ O assistente responde em tempo real usando **streaming** para melhor experiênci
```typescript
export const SUPPORT_SYSTEM_PROMPT = `
És o assistente virtual da PawLink, uma plataforma portuguesa de adopção de
És o assistente virtual da PetLink, uma plataforma portuguesa de adopção de
animais e doação a canis. Chamas-te Paws.
PAPEL:
@@ -148,7 +148,7 @@ LIMITAÇÕES:
- Não tens acesso a informação em tempo real sobre animais específicos ou
disponibilidade actual (sugere usar os filtros da plataforma)
- Não podes processar pagamentos ou fazer reservas directamente
- Para questões técnicas graves, sugere contacto via email: suporte@pawlink.pt
- Para questões técnicas graves, sugere contacto via email: suporte@petlink.pt
TOM:
- Caloroso, encorajador e amigável
@@ -193,7 +193,7 @@ export function SupportChat() {
initialMessages: [{
id: 'welcome',
role: 'assistant',
content: 'Olá! Sou o Paws, o assistente da PawLink. Como posso ajudar-te hoje? 🐾'
content: 'Olá! Sou o Paws, o assistente da PetLink. Como posso ajudar-te hoje? 🐾'
}]
});

View File

@@ -248,7 +248,7 @@ import { test, expect } from '@playwright/test';
test('fluxo completo de adopção', async ({ page }) => {
// Login
await page.goto('/login');
await page.fill('[name="email"]', 'teste@pawlink.pt');
await page.fill('[name="email"]', 'teste@petlink.pt');
await page.fill('[name="password"]', 'Password123!');
await page.click('[type="submit"]');

View File

@@ -35,7 +35,7 @@ Canis base Brinquedos Match inteligente Internacionali
| Email de confirmação de reserva (react-email + Resend) | Crítica | 3 dias | Reservas |
| Área de conta: histórico de adopções e definições | Média | 4 dias | Auth |
| Menu lateral + modo escuro/claro | Média | 3 dias | — |
| Deploy inicial na Vercel + domínio pawlink.pt | Alta | 1 dia | Tudo acima |
| Deploy inicial na Vercel + domínio petlink.pt | Alta | 1 dia | Tudo acima |
| **Total Fase 1** | | **~10 semanas** | |
---

View File

@@ -1,86 +1,131 @@
# PawLink — Registo de Progresso
# PetLink — Registo de Progresso
## ⚡ HANDOFF — PRÓXIMA SESSÃO COMEÇA AQUI
**Estado:** Projecto Next.js criado, dependências instaladas, ferramentas configuradas e esquema Prisma definido. Parado por falta de base de dados.
**Próxima tarefa:** Configurar Supabase e obter `DATABASE_URL` para correr migrações e seed.
**Ficheiro relevante:** `.env.local`
**Atenção:** O utilizador tem de fornecer a `DATABASE_URL` do Supabase para prosseguir.
**Estado:** Toda a camada de código está completa e compila sem erros TypeScript. Bloqueado na migração da base de dados por DATABASE_URL de placeholder.
**Próxima tarefa:** Configurar DATABASE_URL real do Supabase → correr `npx prisma migrate dev --name init` → correr seed → testar fluxo de registo e login
**Ficheiro relevante:** `.env.local` — substituir `DATABASE_URL` pelo valor real de: https://supabase.com/dashboard → Settings → Database → Connection string → URI (Transaction pooler)
**Credenciais em falta:** `DATABASE_URL` real do Supabase (actualmente tem placeholder `postgresql://user:password@host:5432/pawlink`)
**Atenção:** Prisma v7 + pnpm requer `output = "../node_modules/.prisma/client"` no schema (já configurado). NextAuth v5 usa `lib/auth/config.ts` (não `pages/api/auth`). Zod v4 usa `error:` em vez de `required_error:` e `errorMap:`.
---
## Estado Geral
- **Fase actual:** Fase 1 — MVP
- **Última actualização:** 2026-05-04 09:49
- **Sessão #:** 1
- **Última actualização:** 2026-05-21 09:57
- **Sessão #:** 3
---
## Legenda de Estados
- ✅ Concluído
- ✅ Concluído e testado
- 🔄 Em progresso
- ⏳ Por fazer
- ❌ Bloqueado (indicar motivo)
- ⚠️ Atenção necessária
- ❌ Bloqueado indicar motivo
- ⚠️ Feito com mock — substituir quando credencial disponível
---
## ⚠️ Credenciais Necessárias
| Credencial | Variável | Desbloqueia | Estado |
|---|---|---|---|
| Supabase DB | `DATABASE_URL` | Migrações + seed + dados reais | ❌ Placeholder — configurar em supabase.com |
| NextAuth | `NEXTAUTH_SECRET` | Autenticação funcional | ✅ Configurado |
| Stripe | `STRIPE_SECRET_KEY` etc. | Pagamentos | ✅ Configurado |
| Resend | `RESEND_API_KEY` | Envio de emails reais | ✅ Configurado |
| Anthropic | `ANTHROPIC_API_KEY` | IA funcional | ✅ Configurado |
| Supabase Storage | `NEXT_PUBLIC_SUPABASE_URL` etc. | Upload de imagens | ✅ Configurado |
| Upstash Redis | `UPSTASH_REDIS_REST_URL` etc. | Rate limiting | ✅ Configurado |
---
## Fase 1 — MVP
### Infra-estrutura e Configuração
- ✅ Setup Next.js 14 + TypeScript + Tailwind + shadcn/ui
- ✅ Configuração ESLint + Prettier + Husky
- 🔄 Esquema Prisma criado, falta ligação Supabase
- ⏳ Migrações iniciais da base de dados
- ⏳ Seed de dados de desenvolvimento
- ✅ Configuração variáveis de ambiente (.env.example)
- ✅ Setup Next.js + TypeScript + Tailwind + shadcn/ui
- ✅ Configuração ESLint + Prettier + Husky + lint-staged
- Esquema Prisma completo (`prisma/schema.prisma`)
- `prisma.config.ts` com output canónico para pnpm
- ❌ Migrações Supabase (`DATABASE_URL` real necessária)
- ❌ Seed de dados de desenvolvimento (idem)
- ✅ Ficheiro `.env.example` com todas as variáveis
- ⏳ Deploy inicial Vercel + domínio
### Autenticação
- Configuração NextAuth.js
- Registo de utilizador (nome, email, password, birthdate, distrito)
- ⏳ Validação de +18 anos no servidor
- Verificação de email (Resend)
- ⏳ Login por email + password
- ⏳ Recuperação de palavra-passe
- ⏳ Middleware de protecção de rotas
- ⏳ Controlo de acesso por roles (USER, SHELTER_ADMIN, ADMIN)
- Configuração NextAuth v5 + Prisma adapter (`lib/auth/config.ts`)
- Route handler (`app/api/auth/[...nextauth]/route.ts`)
- ✅ Registo: nome, email, password, birthdate, distrito (`app/api/auth/register/route.ts`)
- Validação de +18 anos no servidor (`lib/auth/age-validation.ts`)
- ✅ Hash de password com bcrypt custo 12 (`lib/auth/password.ts`)
- ⚠️ Verificação de email — template pronto, envio activo mas Resend sem domínio verificado
- ✅ Login por email + password (`app/auth/login/page.tsx`)
- ✅ Página de registo (`app/auth/register/page.tsx`)
- ✅ Recuperação de palavra-passe — página feita (`app/auth/forgot-password/page.tsx`), API TODO
- ✅ Middleware de protecção de rotas (`middleware.ts`)
- ✅ Controlo de acesso por roles (USER, SHELTER_ADMIN, ADMIN)
### Validações (Zod v4)
-`lib/validations/auth.ts` — login, register, forgot/reset password
-`lib/validations/animal.ts` — filtros, create, update
-`lib/validations/reservation.ts` — create, update
-`lib/validations/donation.ts` — discriminated union (MONETARY, FOOD, TOYS)
### Base de Dados
-`lib/db/prisma.ts` — singleton com hot-reload safe
### Email
-`lib/email/index.ts` — wrapper Resend + templates HTML (reserva + boas-vindas)
### Canis
- ⏳ Registo de canis (painel admin)
- Perfil público do canil (nome, morada, horários, contacto)
- ⏳ Painel privado do canil (dashboard base)
- ⏳ Registo de canis (admin)
- Perfil público — listagem (`app/main/shelters/page.tsx`)
- ✅ API GET /api/shelters com filtros
- ✅ API GET /api/shelters/[id]
- ✅ Dashboard base do canil (`app/shelter/dashboard/page.tsx`)
- ✅ Layout do painel de canil (`app/shelter/layout.tsx`)
### Animais
- ⏳ CRUD de animais (painel do canil)
- ⏳ CRUD de animais pelo canil (UI)
- ⏳ Upload de fotos (Supabase Storage)
- ⏳ Listagem pública com SSR (página inicial)
- ⏳ Sistema de filtros (distrito, espécie, raça, sexo, esterilizado)
- ⏳ Ficha detalhada do animal
- ⏳ Galeria de fotos
- ⏳ Badge de animal urgente
- ⏳ Estado do animal (Disponível / Reservado / Adoptado)
- ✅ API GET /api/animals com filtros, paginação, urgência
- ✅ API GET /api/animals/[id]
- ✅ Página inicial — listagem com mock data (`app/page.tsx`)
- ✅ Sistema de filtros — chips horizontais (`components/animals/FilterChips.tsx`)
- ✅ Ficha detalhada do animal (`app/main/animals/[id]/page.tsx`)
- ✅ Animal card com badge urgente (`components/animals/AnimalCard.tsx`)
### Reservas
- ⏳ Calendário de datas disponíveis
- ⏳ Criação de reserva (animal → Reservado)
- ⏳ Email de confirmação (react-email + Resend)
-Histórico de reservas na área de conta
- ⏳ Confirmação/cancelamento pelo canil
- ✅ API POST /api/reservations — cria em transacção + actualiza animal + envia email
- ✅ API PATCH /api/reservations/[id] — CONFIRMED/CANCELLED/COMPLETED com estados do animal
- ✅ API GET /api/reservations — histórico do utilizador
-UI de criação de reserva no detalhe do animal
### Utilizadores
- ✅ API GET /api/users/me — perfil com contagens
- ✅ API PATCH /api/users/me — actualizar nome e distrito
- ✅ Área de conta (`app/main/account/page.tsx`)
### UI / UX
- ⏳ Layout principal (Header, Footer)
- ⏳ Menu lateral (3 traços, canto superior direito)
- ⏳ Modo escuro / claro (toggle + persistência)
- ⏳ Área de conta (definições, palavra-passe, dados)
- ⏳ Design responsivo (mobile-first)
- ✅ Design system completo (`app/globals.css`) — paleta Editorial Orgânico
- ✅ Fontes: Playfair Display + Lora + Fragment Mono (`app/layout.tsx`)
- ✅ Header minimalista com hambúrguer animado (`components/layout/Header.tsx`)
- ✅ Side menu com overlay (`components/layout/SideMenu.tsx`)
- ✅ Toggle modo escuro / claro + persistência
- ✅ Footer (`components/layout/Footer.tsx`)
- ✅ Design responsivo mobile-first
---
## Fase 2 — Doações (Por iniciar)
- ⏳ Integração Stripe (PaymentIntent + Payment Element)
- ⏳ Integração Stripe + Payment Element
- ⏳ Suporte MBWay
- ⏳ Fluxo doação monetária
- ⏳ Webhook Stripe
- ⏳ Webhook Stripe + confirmação
- ⏳ Fluxo doação de ração
- ⏳ Fluxo doação de brinquedos
- ⏳ Sistema de necessidades dos canis (ShelterNeed)
@@ -90,72 +135,93 @@
---
## Fase 3 — IA e Comunidade (Por iniciar)
- ⏳ Match inteligente (Claude API)
- ⏳ Chatbot de suporte Paws (streaming)
- ⏳ Chatbot Paws — suporte com streaming
- ⏳ Geração automática de descrições de animais
- ⏳ Perfis pós-adopção
- ⏳ Sistema de notificações por email
- ⏳ Destaque de animais urgentes
- ⏳ Registo de voluntários
- ⏳ Avaliações de canis
---
## Fase 4 — Escala (Por iniciar)
- ⏳ App móvel React Native
- ⏳ Relatórios fiscais (IRS)
- ⏳ Dashboard analítico
- ⏳ API pública
- ⏳ Suporte multilingue
---
## Decisões Técnicas Tomadas
| Data | Decisão | Motivo |
|------|---------|--------|
| — | — | — |
|---|---|---|
| 2026-05-21 | Prisma v7 requer `output` explícito no schema com pnpm | pnpm cria dois peers de @prisma/client; sem output explícito o gerador escreve para o peer errado |
| 2026-05-21 | Zod v4: `required_error` → remover, `errorMap``error` | Breaking change do Zod v4 |
| 2026-05-21 | NextAuth v5: config em `lib/auth/config.ts` exporta `handlers, auth, signIn, signOut` | API do NextAuth v5 beta |
| 2026-05-21 | Emails com HTML inline em vez de react-email | react-email requer render server-side separado; HTML inline funciona directamente com Resend |
---
## Problemas Conhecidos / Bloqueios
| # | Descrição | Estado | Sessão detectada |
|---|-----------|--------|-----------------|
| | — | — | |
| # | Descrição | Estado | Sessão |
|---|---|---|---|
| 1 | DATABASE_URL é placeholder — migrações bloqueadas | ❌ Aguarda credencial real | 3 |
| 2 | `app/main/donations/route.ts` — não criado | ⏳ Fase 2 | — |
---
## Dependências Externas Configuradas
## Dependências Externas
| Serviço | Estado | Notas |
|---------|--------|-------|
| Supabase (PostgreSQL) | ⏳ Por configurar | — |
| Supabase Storage | ⏳ Por configurar | |
|---|---|---|
| Supabase (PostgreSQL) | ❌ URL placeholder | Configurar em supabase.com → Settings → DB |
| Supabase Storage | ✅ Configurado | Upload de fotos ainda não implementado na UI |
| Vercel | ⏳ Por configurar | — |
| Stripe | ⏳ Por configurar | — |
| Resend | ⏳ Por configurar | — |
| Anthropic Claude API | ⏳ Por configurar | — |
| Upstash Redis | ⏳ Por configurar | |
| Stripe | ✅ Configurado | Integração Fase 2 |
| Resend | ✅ Configurado | Verificar domínio @petlink.pt |
| Anthropic Claude API | ✅ Configurado | Integração Fase 3 |
| Upstash Redis | ✅ Configurado | Rate limiting ainda não implementado |
| Cloudflare | ⏳ Por configurar | — |
---
## Histórico de Sessões
### Sessão #1 — 2026-05-04
**Duração:** —
**Trabalho realizado:**
- Leitura de toda a documentação
- Criação de docs/PROGRESS.md
### Sessão #3 — 2026-05-21
**Trabalho realizado:** Implementação de toda a camada lib/ (db, auth, email, validations), NextAuth v5, todas as API routes, middleware de protecção, páginas de auth (login, registo, forgot-password), área de conta, dashboard de canil, listagem de canis, correção Prisma v7 + pnpm.
**Ficheiros criados/modificados:**
- docs/PROGRESS.md
- `lib/db/prisma.ts` — singleton Prisma
- `lib/auth/age-validation.ts` — validação +18
- `lib/auth/password.ts` — bcrypt hash/verify
- `lib/auth/config.ts` — NextAuth v5 config
- `lib/validations/auth.ts` — schemas Zod v4
- `lib/validations/animal.ts` — schemas Zod v4
- `lib/validations/reservation.ts` — schemas Zod v4
- `lib/validations/donation.ts` — schemas Zod v4
- `lib/email/index.ts` — wrapper Resend + templates HTML
- `app/api/auth/[...nextauth]/route.ts` — NextAuth handler
- `app/api/auth/register/route.ts` — POST registo
- `app/api/animals/route.ts` — GET lista
- `app/api/animals/[id]/route.ts` — GET detalhe
- `app/api/shelters/route.ts` — GET lista
- `app/api/shelters/[id]/route.ts` — GET detalhe
- `app/api/reservations/route.ts` — POST + GET
- `app/api/reservations/[id]/route.ts` — PATCH
- `app/api/users/me/route.ts` — GET + PATCH
- `middleware.ts` — protecção de rotas
- `app/auth/register/page.tsx` — página de registo
- `app/auth/forgot-password/page.tsx` — recuperação de password
- `app/main/account/page.tsx` — área de conta
- `app/main/shelters/page.tsx` — listagem de canis
- `app/shelter/dashboard/page.tsx` — dashboard canil
- `app/shelter/layout.tsx` — layout painel canil
- `prisma/schema.prisma` — output explícito para pnpm
**Próximos passos para a sessão seguinte:**
- Iniciar a Fase 1 — Infra-estrutura e Configuração (Setup Next.js, instalar dependências, prisma init, shadcn init)
**Próximos passos:**
1. Configurar `DATABASE_URL` real → https://supabase.com/dashboard → Settings → Database → URI
2. Correr `npx prisma migrate dev --name init`
3. Criar seed (`prisma/seed.ts`) com dados de demonstração
4. Testar fluxo registo → login → reserva → email
5. Implementar UI de criação de reserva no detalhe do animal
6. Fase 2: integração Stripe para doações monetárias
**Notas:**
- Nenhuma.
**Notas:** Zero erros TypeScript. Prisma v7 com pnpm requer `output = "../node_modules/.prisma/client"` no schema.

View File

@@ -1,4 +1,4 @@
# 🐾 PawLink — Documentação do Projecto
# 🐾 PetLink — Documentação do Projecto
**Plataforma de Adopção e Doação Animal**
Prova de Aptidão Profissional (PAP) — Ano Lectivo 2024/2025

View File

@@ -0,0 +1,35 @@
/**
* Validação de +18 anos — sempre executada no servidor.
* Nunca confiar no cliente para esta verificação.
*/
export function isAdult(birthdate: Date): boolean {
const today = new Date();
const age18 = new Date(
birthdate.getFullYear() + 18,
birthdate.getMonth(),
birthdate.getDate()
);
return today >= age18;
}
export function validateAge(birthdateStr: string): {
valid: boolean;
error?: string;
} {
const birthdate = new Date(birthdateStr);
if (isNaN(birthdate.getTime())) {
return { valid: false, error: 'Data de nascimento inválida.' };
}
const now = new Date();
if (birthdate > now) {
return { valid: false, error: 'Data de nascimento não pode ser no futuro.' };
}
if (!isAdult(birthdate)) {
return { valid: false, error: 'Tens de ter pelo menos 18 anos para criar conta.' };
}
return { valid: true };
}

60
lib/auth/config.ts Normal file
View File

@@ -0,0 +1,60 @@
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { prisma } from '@/lib/db/prisma';
import { verifyPassword } from '@/lib/auth/password';
import { loginSchema } from '@/lib/validations/auth';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Palavra-passe', type: 'password' },
},
async authorize(credentials) {
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) return null;
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return null;
const valid = await verifyPassword(password, user.password);
if (!valid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = (user as { role?: string }).role;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
(session.user as { role?: string }).role = token.role as string;
}
return session;
},
},
pages: {
signIn: '/auth/login',
error: '/auth/login',
},
session: { strategy: 'jwt' },
});

14
lib/auth/password.ts Normal file
View File

@@ -0,0 +1,14 @@
import bcrypt from 'bcryptjs';
const BCRYPT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}

13
lib/db/prisma.ts Normal file
View File

@@ -0,0 +1,13 @@
// lib/db/prisma.ts
import { PrismaClient } from '@prisma/client';
// Evitar múltiplas instâncias em hot-reload do Next.js dev
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

102
lib/email/index.ts Normal file
View File

@@ -0,0 +1,102 @@
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
const FROM = process.env.RESEND_FROM_EMAIL ?? 'noreply@petlink.pt';
export interface SendEmailOptions {
to: string;
subject: string;
html: string;
}
export async function sendEmail({ to, subject, html }: SendEmailOptions) {
const { data, error } = await resend.emails.send({
from: `PetLink <${FROM}>`,
to,
subject,
html,
});
if (error) {
console.error('[email] Falha ao enviar:', error);
throw new Error(`Falha ao enviar email: ${error.message}`);
}
return data;
}
export function buildReservationConfirmationHtml(opts: {
userName: string;
animalName: string;
shelterName: string;
date: string;
}): string {
return `
<!DOCTYPE html>
<html lang="pt">
<head><meta charset="UTF-8"><title>Confirmação de Reserva — PetLink</title></head>
<body style="margin:0;padding:0;background:#F9F4ED;font-family:Georgia,serif;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td align="center" style="padding:40px 16px;">
<table width="560" cellpadding="0" cellspacing="0" style="background:#EFE6D8;border-radius:16px;overflow:hidden;">
<tr><td style="background:#C4501A;padding:28px 36px;">
<span style="color:white;font-size:22px;font-style:italic;font-weight:700;">PetLink</span>
</td></tr>
<tr><td style="padding:36px;">
<h1 style="margin:0 0 12px;color:#231408;font-size:28px;">Reserva confirmada! 🐾</h1>
<p style="margin:0 0 20px;color:#5C4033;font-size:16px;line-height:1.6;">
Olá <strong>${opts.userName}</strong>, a tua reserva para conhecer <strong>${opts.animalName}</strong> foi confirmada.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background:#F9F4ED;border-radius:10px;margin-bottom:24px;">
<tr><td style="padding:20px;">
<p style="margin:0 0 8px;color:#9C8070;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;">Animal</p>
<p style="margin:0 0 16px;color:#231408;font-size:18px;font-weight:700;">${opts.animalName}</p>
<p style="margin:0 0 8px;color:#9C8070;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;">Canil</p>
<p style="margin:0 0 16px;color:#231408;font-size:16px;">${opts.shelterName}</p>
<p style="margin:0 0 8px;color:#9C8070;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;">Data</p>
<p style="margin:0;color:#231408;font-size:16px;">${opts.date}</p>
</td></tr>
</table>
<p style="margin:0;color:#5C4033;font-size:14px;line-height:1.6;">
Lembra-te de levar um documento de identificação. Se precisares de alterar ou cancelar, acede à tua área de conta em <a href="https://petlink.pt/main/account" style="color:#C4501A;">petlink.pt</a>.
</p>
</td></tr>
<tr><td style="padding:20px 36px;border-top:1px solid #E4D8C8;">
<p style="margin:0;color:#9C8070;font-size:12px;">© 2025 PetLink · Portugal · <a href="https://petlink.pt/privacy" style="color:#9C8070;">Privacidade</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
export function buildWelcomeHtml(opts: { userName: string }): string {
return `
<!DOCTYPE html>
<html lang="pt">
<head><meta charset="UTF-8"><title>Bem-vindo ao PetLink</title></head>
<body style="margin:0;padding:0;background:#F9F4ED;font-family:Georgia,serif;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td align="center" style="padding:40px 16px;">
<table width="560" cellpadding="0" cellspacing="0" style="background:#EFE6D8;border-radius:16px;overflow:hidden;">
<tr><td style="background:#C4501A;padding:28px 36px;">
<span style="color:white;font-size:22px;font-style:italic;font-weight:700;">PetLink</span>
</td></tr>
<tr><td style="padding:36px;">
<h1 style="margin:0 0 12px;color:#231408;font-size:28px;">Bem-vindo, ${opts.userName}! 🐾</h1>
<p style="margin:0 0 20px;color:#5C4033;font-size:16px;line-height:1.6;">
A tua conta PetLink está pronta. Começa a explorar animais à espera de uma família como a tua.
</p>
<a href="https://petlink.pt/main/animals" style="display:inline-block;background:#C4501A;color:white;padding:14px 28px;border-radius:100px;text-decoration:none;font-size:14px;letter-spacing:0.08em;text-transform:uppercase;">Explorar animais</a>
</td></tr>
<tr><td style="padding:20px 36px;border-top:1px solid #E4D8C8;">
<p style="margin:0;color:#9C8070;font-size:12px;">© 2025 PetLink · Portugal</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}

253
lib/mock-data.ts Normal file
View File

@@ -0,0 +1,253 @@
// lib/mock-data.ts
// TODO: substituir por queries Prisma quando DATABASE_URL estiver configurada
export interface Animal {
id: string;
name: string;
species: 'DOG' | 'CAT' | 'OTHER';
breed: string;
ageMonths: number;
sex: 'MALE' | 'FEMALE';
sterilized: boolean;
status: 'AVAILABLE' | 'RESERVED' | 'ADOPTED';
urgent: boolean;
description: string;
photos: string[];
shelter: {
id: string;
name: string;
district: string;
address: string;
phone: string;
email: string;
openHours: string;
};
}
export const MOCK_ANIMALS: Animal[] = [
{
id: 'mock-1',
name: 'Bobi',
species: 'DOG',
breed: 'Labrador',
ageMonths: 24,
sex: 'MALE',
sterilized: true,
status: 'AVAILABLE',
urgent: false,
description:
'O Bobi é um Labrador cheio de energia e amor para dar. Adora brincar ao ar livre e é excelente com crianças. Está vacinado, desparasitado e pronto para encontrar a sua família definitiva. Vive em canil há 8 meses e merece uma segunda oportunidade.',
photos: [
'https://images.unsplash.com/photo-1587300003388-59208cc962cb?w=600&q=80',
'https://images.unsplash.com/photo-1552053831-71594a27632d?w=600&q=80',
'https://images.unsplash.com/photo-1560807707-8cc77767d783?w=600&q=80',
],
shelter: {
id: 'shelter-1',
name: 'Canil Municipal de Lisboa',
district: 'Lisboa',
address: 'Rua do Canil, 12, 1500-001 Lisboa',
phone: '213 000 001',
email: 'canil@cm-lisboa.pt',
openHours: 'SegSex 9h18h · Sáb 10h14h',
},
},
{
id: 'mock-2',
name: 'Luna',
species: 'CAT',
breed: 'Siamês',
ageMonths: 18,
sex: 'FEMALE',
sterilized: true,
status: 'AVAILABLE',
urgent: true,
description:
'A Luna é uma gata Siamesa elegante e carinhosa. Adora colo e ronrona constantemente. É ideal para apartamento e dá-se bem com outros gatos. Urgente — o canil está sobrelotado e ela precisa de lar.',
photos: [
'https://images.unsplash.com/photo-1514888286974-6c03e2ca1dba?w=600&q=80',
'https://images.unsplash.com/photo-1573865526739-10659fec78a5?w=600&q=80',
],
shelter: {
id: 'shelter-2',
name: 'Associação Amigos dos Animais do Porto',
district: 'Porto',
address: 'Av. da Boavista, 450, 4100-100 Porto',
phone: '222 000 002',
email: 'adopcao@amigosanimais.pt',
openHours: 'SegDom 10h17h',
},
},
{
id: 'mock-3',
name: 'Rex',
species: 'DOG',
breed: 'Pastor Alemão',
ageMonths: 36,
sex: 'MALE',
sterilized: false,
status: 'AVAILABLE',
urgent: false,
description:
'O Rex é um Pastor Alemão inteligente e leal. Tem treino básico de obediência e adora aprender novos comandos. Precisa de espaço exterior e de uma pessoa experiente com a raça.',
photos: [
'https://images.unsplash.com/photo-1589941013453-ec89f33b5e95?w=600&q=80',
'https://images.unsplash.com/photo-1605568427561-40dd23c2acea?w=600&q=80',
],
shelter: {
id: 'shelter-3',
name: 'Centro de Recolha de Sintra',
district: 'Sintra',
address: 'Estrada da Serra, 8, 2710-001 Sintra',
phone: '219 000 003',
email: 'sintra@recolha.pt',
openHours: 'TerSáb 9h17h',
},
},
{
id: 'mock-4',
name: 'Mel',
species: 'CAT',
breed: 'Europeu Comum',
ageMonths: 8,
sex: 'FEMALE',
sterilized: true,
status: 'AVAILABLE',
urgent: true,
description:
'A Mel é uma gatinha de 8 meses cheia de curiosidade e energia. Brinca o dia todo e adormece no colo à noite. Socializada com crianças e outros animais.',
photos: [
'https://images.unsplash.com/photo-1533743983669-94fa5c4338ec?w=600&q=80',
'https://images.unsplash.com/photo-1495360010541-f48722b4f3c7?w=600&q=80',
],
shelter: {
id: 'shelter-1',
name: 'Canil Municipal de Lisboa',
district: 'Lisboa',
address: 'Rua do Canil, 12, 1500-001 Lisboa',
phone: '213 000 001',
email: 'canil@cm-lisboa.pt',
openHours: 'SegSex 9h18h · Sáb 10h14h',
},
},
{
id: 'mock-5',
name: 'Toto',
species: 'DOG',
breed: 'Beagle',
ageMonths: 12,
sex: 'MALE',
sterilized: true,
status: 'AVAILABLE',
urgent: false,
description:
'O Toto é um Beagle jovem, curioso e muito sociável. Adora farejar tudo e brincar com outros cães. Excelente companheiro para famílias activas.',
photos: [
'https://images.unsplash.com/photo-1505628346881-b72b27e84530?w=600&q=80',
'https://images.unsplash.com/photo-1537151608828-ea2b11777ee8?w=600&q=80',
],
shelter: {
id: 'shelter-4',
name: 'Associação Zara Animal',
district: 'Braga',
address: 'Rua das Flores, 22, 4700-001 Braga',
phone: '253 000 004',
email: 'zara@zaraaanimal.pt',
openHours: 'SegSex 10h18h',
},
},
{
id: 'mock-6',
name: 'Nala',
species: 'DOG',
breed: 'Golden Retriever',
ageMonths: 60,
sex: 'FEMALE',
sterilized: true,
status: 'AVAILABLE',
urgent: false,
description:
'A Nala é uma Golden Retriever de 5 anos, calma, carinhosa e treinada. Adora crianças e adapta-se facilmente a qualquer ambiente. Adoptada e devolvida por motivos de saúde do dono anterior — não tem qualquer problema.',
photos: [
'https://images.unsplash.com/photo-1601758125946-6ec2ef64daf8?w=600&q=80',
'https://images.unsplash.com/photo-1516734212186-a967f81ad0d7?w=600&q=80',
],
shelter: {
id: 'shelter-2',
name: 'Associação Amigos dos Animais do Porto',
district: 'Porto',
address: 'Av. da Boavista, 450, 4100-100 Porto',
phone: '222 000 002',
email: 'adopcao@amigosanimais.pt',
openHours: 'SegDom 10h17h',
},
},
{
id: 'mock-7',
name: 'Simba',
species: 'CAT',
breed: 'Maine Coon',
ageMonths: 30,
sex: 'MALE',
sterilized: true,
status: 'AVAILABLE',
urgent: false,
description:
'O Simba é um Maine Coon imponente e gentil. Tem um pelo magnífico e um temperamento sereno. Ideal para quem procura um gato tranquilo e sociável.',
photos: [
'https://images.unsplash.com/photo-1574158622682-e40e69881006?w=600&q=80',
'https://images.unsplash.com/photo-1533738363-b7f9aef128ce?w=600&q=80',
],
shelter: {
id: 'shelter-3',
name: 'Centro de Recolha de Sintra',
district: 'Sintra',
address: 'Estrada da Serra, 8, 2710-001 Sintra',
phone: '219 000 003',
email: 'sintra@recolha.pt',
openHours: 'TerSáb 9h17h',
},
},
{
id: 'mock-8',
name: 'Bica',
species: 'DOG',
breed: 'Rafeiro Alentejano',
ageMonths: 48,
sex: 'FEMALE',
sterilized: true,
status: 'AVAILABLE',
urgent: true,
description:
'A Bica é uma Rafeira Alentejana de 4 anos, leal e protectora. Cresceu na rua mas já está completamente socializada. Precisa de espaço exterior e de uma pessoa paciente.',
photos: [
'https://images.unsplash.com/photo-1583337130417-3346a1be7dee?w=600&q=80',
'https://images.unsplash.com/photo-1548199973-03cce0bbc87b?w=600&q=80',
],
shelter: {
id: 'shelter-4',
name: 'Associação Zara Animal',
district: 'Braga',
address: 'Rua das Flores, 22, 4700-001 Braga',
phone: '253 000 004',
email: 'zara@zaraaanimal.pt',
openHours: 'SegSex 10h18h',
},
},
];
export function formatAge(months: number): string {
if (months < 12) return `${months} ${months === 1 ? 'mês' : 'meses'}`;
const years = Math.floor(months / 12);
const rem = months % 12;
if (rem === 0) return `${years} ${years === 1 ? 'ano' : 'anos'}`;
return `${years} ${years === 1 ? 'ano' : 'anos'} e ${rem} ${rem === 1 ? 'mês' : 'meses'}`;
}
export function formatSex(sex: 'MALE' | 'FEMALE'): string {
return sex === 'MALE' ? '♂ Macho' : '♀ Fêmea';
}
export function formatSpecies(species: 'DOG' | 'CAT' | 'OTHER'): string {
return species === 'DOG' ? 'Cão' : species === 'CAT' ? 'Gato' : 'Outro';
}

32
lib/validations/animal.ts Normal file
View File

@@ -0,0 +1,32 @@
import { z } from 'zod';
export const animalFilterSchema = z.object({
district: z.string().optional(),
species: z.enum(['DOG', 'CAT', 'OTHER']).optional(),
sex: z.enum(['MALE', 'FEMALE']).optional(),
sterilized: z.coerce.boolean().optional(),
urgent: z.coerce.boolean().optional(),
status: z.enum(['AVAILABLE', 'RESERVED', 'ADOPTED']).optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(50).default(12),
});
export const createAnimalSchema = z.object({
name: z.string().min(1, 'Nome obrigatório.').max(80),
species: z.enum(['DOG', 'CAT', 'OTHER']),
breed: z.string().max(80).optional(),
ageMonths: z.number().int().min(0).max(300),
sex: z.enum(['MALE', 'FEMALE']),
sterilized: z.boolean(),
urgent: z.boolean().default(false),
description: z.string().max(2000).optional(),
shelterId: z.string().cuid(),
});
export const updateAnimalSchema = createAnimalSchema
.partial()
.extend({ status: z.enum(['AVAILABLE', 'RESERVED', 'ADOPTED']).optional() });
export type AnimalFilter = z.infer<typeof animalFilterSchema>;
export type CreateAnimalInput = z.infer<typeof createAnimalSchema>;
export type UpdateAnimalInput = z.infer<typeof updateAnimalSchema>;

55
lib/validations/auth.ts Normal file
View File

@@ -0,0 +1,55 @@
import { z } from 'zod';
const DISTRITOS_PT = [
'Aveiro', 'Beja', 'Braga', 'Bragança', 'Castelo Branco', 'Coimbra',
'Évora', 'Faro', 'Guarda', 'Leiria', 'Lisboa', 'Portalegre', 'Porto',
'Santarém', 'Setúbal', 'Viana do Castelo', 'Vila Real', 'Viseu',
'Açores', 'Madeira',
] as const;
export const loginSchema = z.object({
email: z.string().email('Email inválido.'),
password: z.string().min(1, 'Palavra-passe obrigatória.'),
});
export const registerSchema = z
.object({
name: z.string().min(2, 'Nome deve ter pelo menos 2 caracteres.').max(100),
email: z.string().email('Email inválido.'),
password: z.string()
.min(8, 'A palavra-passe deve ter pelo menos 8 caracteres.')
.regex(/[A-Z]/, 'Deve conter pelo menos uma letra maiúscula.')
.regex(/[0-9]/, 'Deve conter pelo menos um número.'),
confirmPassword: z.string(),
birthdate: z.string().refine((v) => !isNaN(new Date(v).getTime()), 'Data inválida.'),
district: z.enum(DISTRITOS_PT, { error: 'Selecciona um distrito válido.' }),
terms: z.literal(true, { error: 'Tens de aceitar os termos.' }),
})
.refine((d) => d.password === d.confirmPassword, {
message: 'As palavras-passe não coincidem.',
path: ['confirmPassword'],
});
export const forgotPasswordSchema = z.object({
email: z.string().email('Email inválido.'),
});
export const resetPasswordSchema = z
.object({
token: z.string().min(1),
password: z.string()
.min(8, 'A palavra-passe deve ter pelo menos 8 caracteres.')
.regex(/[A-Z]/, 'Deve conter pelo menos uma letra maiúscula.')
.regex(/[0-9]/, 'Deve conter pelo menos um número.'),
confirmPassword: z.string(),
})
.refine((d) => d.password === d.confirmPassword, {
message: 'As palavras-passe não coincidem.',
path: ['confirmPassword'],
});
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterInput = z.infer<typeof registerSchema>;
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
export const DISTRITOS = DISTRITOS_PT;

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
export const createDonationSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('MONETARY'),
shelterId: z.string().cuid(),
details: z.object({
amount: z.number().min(1, 'Mínimo 1€.').max(10000),
currency: z.literal('EUR').default('EUR'),
deliveryMethod: z.literal('payment'),
}),
}),
z.object({
type: z.literal('FOOD'),
shelterId: z.string().cuid(),
details: z.object({
foodType: z.enum(['dry', 'wet']),
animalType: z.enum(['dog', 'cat']),
ageGroup: z.enum(['adult', 'puppy']),
deliveryMethod: z.enum(['pickup', 'home_delivery']),
address: z.string().max(200).optional(),
}),
}),
z.object({
type: z.literal('TOYS'),
shelterId: z.string().cuid(),
details: z.object({
category: z.enum(['chew', 'plush', 'interactive']),
deliveryMethod: z.enum(['pickup', 'home_delivery']),
address: z.string().max(200).optional(),
}),
}),
]);
export type CreateDonationInput = z.infer<typeof createDonationSchema>;

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
export const createReservationSchema = z.object({
animalId: z.string().cuid('ID de animal inválido.'),
date: z
.string()
.refine((v) => !isNaN(new Date(v).getTime()), 'Data inválida.')
.refine((v) => new Date(v) > new Date(), 'A data tem de ser no futuro.'),
notes: z.string().max(500).optional(),
});
export const updateReservationSchema = z.object({
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
});
export type CreateReservationInput = z.infer<typeof createReservationSchema>;
export type UpdateReservationInput = z.infer<typeof updateReservationSchema>;

48
middleware.ts Normal file
View File

@@ -0,0 +1,48 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth/config';
import { NextRequest } from 'next/server';
const PROTECTED = ['/main/account', '/shelter/dashboard'];
const SHELTER_ONLY = ['/shelter/'];
const ADMIN_ONLY = ['/admin/'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const session = await auth();
const needsAuth = PROTECTED.some((p) => pathname.startsWith(p));
if (needsAuth && !session) {
const loginUrl = new URL('/auth/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
const role = (session?.user as { role?: string })?.role;
if (SHELTER_ONLY.some((p) => pathname.startsWith(p))) {
if (!session || (role !== 'SHELTER_ADMIN' && role !== 'ADMIN')) {
return NextResponse.redirect(new URL('/', request.url));
}
}
if (ADMIN_ONLY.some((p) => pathname.startsWith(p))) {
if (!session || role !== 'ADMIN') {
return NextResponse.redirect(new URL('/', request.url));
}
}
if (session && pathname.startsWith('/auth/')) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
'/main/account/:path*',
'/shelter/:path*',
'/admin/:path*',
'/auth/:path*',
],
};

View File

@@ -1,7 +1,15 @@
import type { NextConfig } from "next";
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
pathname: '/**',
},
],
},
};
export default nextConfig;

View File

@@ -55,5 +55,8 @@
"*.{json,css,md}": [
"prettier --write"
]
},
"prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}
}

View File

@@ -1,6 +1,24 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
// prisma.config.ts
// Carregar .env.local manualmente (dotenv v17 tem API diferente em ESM/CJS)
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
function loadEnvFile(filePath: string) {
if (!existsSync(filePath)) return;
const content = readFileSync(filePath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf('=');
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
const val = trimmed.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
if (!(key in process.env)) process.env[key] = val;
}
}
// .env.local tem precedência — carregar primeiro com override manual
loadEnvFile(resolve('.env.local'));
loadEnvFile(resolve('.env'));
import { defineConfig } from "prisma/config";
export default defineConfig({
@@ -9,6 +27,9 @@ export default defineConfig({
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
// DIRECT_URL para migrações (ligação directa porta 5432)
// DATABASE_URL para runtime (pooler porta 6543)
url: process.env["DIRECT_URL"] ?? process.env["DATABASE_URL"],
// Nota: se DIRECT_URL não estiver definida, usa DATABASE_URL como fallback
},
});

View File

@@ -1,12 +1,13 @@
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
engineType = "binary"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Enums ──────────────────────────────────────────────────────