feat: sessão #3 — lib (db/auth/email/validations), API routes, NextAuth v5, middleware, páginas account/shelters/shelter-dashboard, Prisma v7 fix
This commit is contained in:
23
app/api/animals/[id]/route.ts
Normal file
23
app/api/animals/[id]/route.ts
Normal 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
51
app/api/animals/route.ts
Normal 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) },
|
||||
});
|
||||
}
|
||||
3
app/api/auth/[...nextauth]/route.ts
Normal file
3
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from '@/lib/auth/config';
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
56
app/api/auth/register/route.ts
Normal file
56
app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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) {
|
||||
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)
|
||||
sendEmail({
|
||||
to: user.email,
|
||||
subject: 'Bem-vindo à PawLink 🐾',
|
||||
html: buildWelcomeHtml({ userName: user.name }),
|
||||
}).catch(console.error);
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Conta criada com sucesso.', userId: user.id },
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
70
app/api/reservations/[id]/route.ts
Normal file
70
app/api/reservations/[id]/route.ts
Normal 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);
|
||||
}
|
||||
99
app/api/reservations/route.ts
Normal file
99
app/api/reservations/route.ts
Normal 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);
|
||||
}
|
||||
27
app/api/shelters/[id]/route.ts
Normal file
27
app/api/shelters/[id]/route.ts
Normal 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
31
app/api/shelters/route.ts
Normal 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
62
app/api/users/me/route.ts
Normal 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);
|
||||
}
|
||||
119
app/auth/forgot-password/page.tsx
Normal file
119
app/auth/forgot-password/page.tsx
Normal 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' }}>PawLink</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
14
app/auth/layout.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
226
app/auth/login/page.tsx
Normal file
226
app/auth/login/page.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { PawPrint, Eye, EyeOff, Mail, Lock } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
// TODO: substituir por NextAuth signIn quando NEXTAUTH_SECRET estiver configurado
|
||||
setTimeout(() => setLoading(false), 1200);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '420px',
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '20px',
|
||||
padding: 'clamp(28px, 5vw, 44px)',
|
||||
boxShadow: '0 8px 40px rgba(45,27,14,0.08)',
|
||||
}}
|
||||
>
|
||||
{/* Brand mark */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '28px' }}>
|
||||
<PawPrint size={20} style={{ color: 'var(--color-terra)' }} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-playfair, Georgia, serif)',
|
||||
fontStyle: 'italic',
|
||||
fontWeight: 700,
|
||||
fontSize: '18px',
|
||||
color: 'var(--color-terra)',
|
||||
}}
|
||||
>
|
||||
PawLink
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: 'var(--font-playfair, Georgia, serif)',
|
||||
fontWeight: 900,
|
||||
fontSize: '28px',
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: '6px',
|
||||
lineHeight: 1.15,
|
||||
}}
|
||||
>
|
||||
Bem-vindo de volta.
|
||||
</h1>
|
||||
<p style={{ fontFamily: 'var(--font-body)', fontSize: '14px', color: 'var(--color-muted)', marginBottom: '28px' }}>
|
||||
Não tens conta?{' '}
|
||||
<Link
|
||||
href="/auth/register"
|
||||
style={{ color: 'var(--color-terra)', fontWeight: 500, textDecoration: 'underline', textDecorationColor: 'transparent', transition: 'text-decoration-color 180ms ease' }}
|
||||
onMouseEnter={e => ((e.target as HTMLElement).style.textDecorationColor = 'var(--color-terra)')}
|
||||
onMouseLeave={e => ((e.target as HTMLElement).style.textDecorationColor = 'transparent')}
|
||||
>
|
||||
Criar conta grátis
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{/* Email field */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<label
|
||||
htmlFor="login-email"
|
||||
style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: '11px', fontWeight: 500, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--color-muted)' }}
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Mail size={15} style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)', color: 'var(--color-muted)', 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={{
|
||||
width: '100%',
|
||||
padding: '12px 16px 12px 40px',
|
||||
background: 'var(--color-bg)',
|
||||
border: '1.5px solid var(--color-border)',
|
||||
borderRadius: '10px',
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: '15px',
|
||||
color: 'var(--color-text)',
|
||||
outline: 'none',
|
||||
transition: 'border-color 180ms ease',
|
||||
minHeight: '48px',
|
||||
}}
|
||||
onFocus={e => (e.target.style.borderColor = 'var(--color-terra)')}
|
||||
onBlur={e => (e.target.style.borderColor = 'var(--color-border)')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password field */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<label
|
||||
htmlFor="login-password"
|
||||
style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: '11px', fontWeight: 500, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--color-muted)' }}
|
||||
>
|
||||
Palavra-passe
|
||||
</label>
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
style={{ fontFamily: 'var(--font-body)', fontSize: '12px', color: 'var(--color-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)', pointerEvents: 'none' }} />
|
||||
<input
|
||||
id="login-password"
|
||||
type={showPass ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 44px 12px 40px',
|
||||
background: 'var(--color-bg)',
|
||||
border: '1.5px solid var(--color-border)',
|
||||
borderRadius: '10px',
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: '15px',
|
||||
color: 'var(--color-text)',
|
||||
outline: 'none',
|
||||
transition: 'border-color 180ms ease',
|
||||
minHeight: '48px',
|
||||
}}
|
||||
onFocus={e => (e.target.style.borderColor = 'var(--color-terra)')}
|
||||
onBlur={e => (e.target.style.borderColor = 'var(--color-border)')}
|
||||
/>
|
||||
<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)',
|
||||
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"
|
||||
className="btn-primary"
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
marginTop: '8px',
|
||||
opacity: loading ? 0.75 : 1,
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
aria-label="Entrar na conta PawLink"
|
||||
>
|
||||
{loading ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
width: '14px', height: '14px',
|
||||
border: '2px solid rgba(255,255,255,0.3)',
|
||||
borderTopColor: 'white',
|
||||
borderRadius: '50%',
|
||||
display: 'inline-block',
|
||||
animation: 'spin 0.7s linear infinite',
|
||||
}} />
|
||||
A entrar…
|
||||
</span>
|
||||
) : (
|
||||
'Entrar'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', margin: '20px 0' }}>
|
||||
<div style={{ flex: 1, height: '1px', background: 'var(--color-border)' }} />
|
||||
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: '10px', color: 'var(--color-muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>ou</span>
|
||||
<div style={{ flex: 1, height: '1px', background: 'var(--color-border)' }} />
|
||||
</div>
|
||||
|
||||
<p style={{ textAlign: 'center', fontFamily: 'var(--font-body)', fontSize: '12px', color: 'var(--color-muted)', lineHeight: 1.5 }}>
|
||||
Ao entrar, aceitas os nossos{' '}
|
||||
<Link href="/terms" style={{ color: 'var(--color-terra)', textDecoration: 'none' }}>Termos de Utilização</Link>
|
||||
{' '}e a nossa{' '}
|
||||
<Link href="/privacy" style={{ color: 'var(--color-terra)', textDecoration: 'none' }}>Política de Privacidade</Link>.
|
||||
</p>
|
||||
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
app/auth/register/page.tsx
Normal file
211
app/auth/register/page.tsx
Normal 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 à PawLink. Já 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' }}>PawLink</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' }}>
|
||||
Já 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 PawLink"
|
||||
>
|
||||
{loading ? 'A criar conta…' : 'Criar conta'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
823
app/globals.css
823
app/globals.css
@@ -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);
|
||||
}
|
||||
/* ─── PawLink 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;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,49 @@
|
||||
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 './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: 'PawLink — Adopção de Animais em Portugal',
|
||||
template: '%s | PawLink',
|
||||
},
|
||||
description:
|
||||
'Encontra o teu companheiro para a vida. PawLink 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: 'PawLink' }],
|
||||
creator: 'PawLink',
|
||||
metadataBase: new URL('https://pawlink.pt'),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'pt_PT',
|
||||
url: 'https://pawlink.pt',
|
||||
siteName: 'PawLink',
|
||||
title: 'PawLink — Adopção de Animais em Portugal',
|
||||
description: 'Encontra o teu companheiro para a vida.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,10 +53,36 @@ 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
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
try {
|
||||
var theme = localStorage.getItem('pawlink-theme');
|
||||
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (theme === 'dark' || (!theme && prefersDark)) {
|
||||
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))',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
189
app/main/account/page.tsx
Normal file
189
app/main/account/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { User, MapPin, Calendar, Heart, Gift, Settings, LogOut, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface UserProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
district: string;
|
||||
role: string;
|
||||
emailVerified: boolean;
|
||||
createdAt: string;
|
||||
_count: { reservations: number; donations: number };
|
||||
}
|
||||
|
||||
interface Reservation {
|
||||
id: string;
|
||||
date: string;
|
||||
status: string;
|
||||
animal: {
|
||||
name: string;
|
||||
species: string;
|
||||
photos: { url: string }[];
|
||||
shelter: { name: string; district: string };
|
||||
};
|
||||
}
|
||||
|
||||
export default function AccountPage() {
|
||||
const [user, setUser] = useState<UserProfile | null>(null);
|
||||
const [reservations, setReservations] = useState<Reservation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch('/api/users/me').then(r => r.json()),
|
||||
fetch('/api/reservations').then(r => r.json()),
|
||||
])
|
||||
.then(([u, r]) => {
|
||||
setUser(u);
|
||||
setReservations(Array.isArray(r) ? r : []);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
PENDING: 'var(--amber)',
|
||||
CONFIRMED: 'var(--sage)',
|
||||
CANCELLED: 'var(--soil-faint)',
|
||||
COMPLETED: 'var(--sage)',
|
||||
};
|
||||
const statusLabel: Record<string, string> = {
|
||||
PENDING: 'Pendente',
|
||||
CONFIRMED: 'Confirmada',
|
||||
CANCELLED: 'Cancelada',
|
||||
COMPLETED: 'Concluída',
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto', padding: 'var(--space-7) var(--space-5)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{[1,2,3].map(i => (
|
||||
<div key={i} className="skeleton" style={{ height: '80px', borderRadius: '12px' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 'var(--space-9) var(--space-5)' }}>
|
||||
<p style={{ fontFamily: 'var(--font-body)', color: 'var(--soil-mid)', marginBottom: '20px' }}>
|
||||
Precisas de estar autenticado para ver esta página.
|
||||
</p>
|
||||
<Link href="/auth/login" className="btn btn-primary">Entrar</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto', padding: 'var(--space-7) var(--space-5)' }}>
|
||||
{/* Header da conta */}
|
||||
<div style={{ marginBottom: 'var(--space-7)' }}>
|
||||
<p className="eyebrow" style={{ marginBottom: '8px' }}>A tua conta</p>
|
||||
<h1 style={{ fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: 'clamp(28px, 5vw, 40px)', color: 'var(--soil)', letterSpacing: '-0.02em', lineHeight: 1.1 }}>
|
||||
Olá, <em style={{ fontStyle: 'italic', color: 'var(--terra)' }}>{user.name.split(' ')[0]}.</em>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-4)', marginBottom: 'var(--space-7)' }}>
|
||||
{[
|
||||
{ icon: Heart, label: 'Reservas', value: user._count.reservations },
|
||||
{ icon: Gift, label: 'Doações', value: user._count.donations },
|
||||
].map(({ icon: Icon, label, value }) => (
|
||||
<div key={label} style={{ background: 'var(--linen)', border: '1px solid var(--parchment)', borderRadius: '16px', padding: '20px', display: 'flex', alignItems: 'center', gap: '14px', boxShadow: 'var(--shadow-warm-sm)' }}>
|
||||
<div style={{ width: '40px', height: '40px', borderRadius: '12px', background: 'var(--terra-glow)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Icon size={18} style={{ color: 'var(--terra)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-accent)', fontSize: '11px', color: 'var(--soil-faint)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '4px' }}>{label}</div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '22px', color: 'var(--soil)' }}>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Informação pessoal */}
|
||||
<section style={{ background: 'var(--linen)', border: '1px solid var(--parchment)', borderRadius: '16px', padding: '24px', marginBottom: 'var(--space-6)', boxShadow: 'var(--shadow-warm-sm)' }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '18px', color: 'var(--soil)', marginBottom: '20px', fontStyle: 'italic' }}>
|
||||
Dados pessoais
|
||||
</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{[
|
||||
{ icon: User, label: 'Nome', value: user.name },
|
||||
{ icon: Settings, label: 'Email', value: user.email },
|
||||
{ icon: MapPin, label: 'Distrito', value: user.district },
|
||||
{ icon: Calendar, label: 'Membro desde', value: new Date(user.createdAt).toLocaleDateString('pt-PT', { year: 'numeric', month: 'long' }) },
|
||||
].map(({ icon: Icon, label, value }) => (
|
||||
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '12px', background: 'var(--cream)', borderRadius: '10px' }}>
|
||||
<Icon size={16} style={{ color: 'var(--soil-faint)', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: 'var(--font-accent)', fontSize: '10px', color: 'var(--soil-faint)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>{label}</div>
|
||||
<div style={{ fontFamily: 'var(--font-body)', fontSize: '15px', color: 'var(--soil)', marginTop: '2px' }}>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Histórico de reservas */}
|
||||
<section>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '18px', color: 'var(--soil)', marginBottom: '16px', fontStyle: 'italic' }}>
|
||||
As tuas reservas
|
||||
</h2>
|
||||
|
||||
{reservations.length === 0 ? (
|
||||
<div style={{ background: 'var(--linen)', border: '1px solid var(--parchment)', borderRadius: '16px', padding: '40px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '40px', marginBottom: '12px', opacity: 0.4 }}>🐾</div>
|
||||
<p style={{ fontFamily: 'var(--font-body)', color: 'var(--soil-mid)', marginBottom: '20px' }}>
|
||||
Ainda não tens reservas. Começa por explorar os animais disponíveis.
|
||||
</p>
|
||||
<Link href="/main/animals" className="btn btn-primary" style={{ justifyContent: 'center' }}>
|
||||
Explorar animais
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{reservations.map((res) => (
|
||||
<div
|
||||
key={res.id}
|
||||
style={{ background: 'var(--linen)', border: '1px solid var(--parchment)', borderRadius: '12px', padding: '16px 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', boxShadow: 'var(--shadow-warm-sm)', flexWrap: 'wrap' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
|
||||
{res.animal.photos[0] && (
|
||||
<img src={res.animal.photos[0].url} alt={res.animal.name} style={{ width: '48px', height: '48px', borderRadius: '10px', objectFit: 'cover', flexShrink: 0 }} />
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '16px', color: 'var(--soil)' }}>{res.animal.name}</div>
|
||||
<div style={{ fontFamily: 'var(--font-body)', fontStyle: 'italic', fontSize: '13px', color: 'var(--soil-mid)' }}>
|
||||
{res.animal.shelter.name} · {new Date(res.date).toLocaleDateString('pt-PT', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontFamily: 'var(--font-accent)', fontSize: '10px', letterSpacing: '0.08em', textTransform: 'uppercase', color: statusColor[res.status] ?? 'var(--soil-mid)', padding: '5px 12px', background: `${statusColor[res.status]}18`, borderRadius: '100px', border: `1px solid ${statusColor[res.status]}40` }}>
|
||||
{statusLabel[res.status] ?? res.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Logout */}
|
||||
<div style={{ marginTop: 'var(--space-7)', paddingTop: 'var(--space-5)', borderTop: '1px solid var(--parchment)' }}>
|
||||
<Link
|
||||
href="/api/auth/signout"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '8px', fontFamily: 'var(--font-body)', fontSize: '14px', color: 'var(--soil-mid)', textDecoration: 'none', padding: '10px 0' }}
|
||||
>
|
||||
<LogOut size={15} />
|
||||
Terminar sessão
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
575
app/main/animals/[id]/page.tsx
Normal file
575
app/main/animals/[id]/page.tsx
Normal file
@@ -0,0 +1,575 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
MapPin,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Heart,
|
||||
X,
|
||||
PawPrint,
|
||||
Phone,
|
||||
Mail,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { MOCK_ANIMALS, formatAge, formatSex, Animal } from '@/lib/mock-data';
|
||||
|
||||
// TODO: substituir por query Prisma quando DATABASE_URL estiver configurada
|
||||
|
||||
function ConfirmationOverlay({ animal, onClose }: { animal: Animal; onClose: () => void }) {
|
||||
const [pieces] = useState(() =>
|
||||
Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
color: ['#C4501A', '#E8952A', '#3D6B4F', '#2D1B0E'][Math.floor(Math.random() * 4)],
|
||||
delay: Math.random() * 0.8,
|
||||
duration: 1.5 + Math.random() * 1.5,
|
||||
size: 6 + Math.random() * 10,
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="confirmation-overlay" role="dialog" aria-modal="true" aria-label="Reserva confirmada">
|
||||
{/* Confetti */}
|
||||
{pieces.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="confetti-piece"
|
||||
style={{
|
||||
left: `${p.x}%`,
|
||||
top: 0,
|
||||
width: `${p.size}px`,
|
||||
height: `${p.size}px`,
|
||||
background: p.color,
|
||||
animationDuration: `${p.duration}s`,
|
||||
animationDelay: `${p.delay}s`,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Card */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '24px',
|
||||
padding: 'clamp(32px, 5vw, 48px)',
|
||||
maxWidth: '420px',
|
||||
width: '90%',
|
||||
textAlign: 'center',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
boxShadow: '0 24px 80px rgba(45,27,14,0.15)',
|
||||
animation: 'bounceIn 500ms cubic-bezier(0.34, 1.56, 0.64, 1) both',
|
||||
}}
|
||||
>
|
||||
{/* Paw icon */}
|
||||
<div
|
||||
style={{
|
||||
width: '72px',
|
||||
height: '72px',
|
||||
background: 'var(--color-terra-light)',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 20px',
|
||||
animation: 'pawBounce 1.5s ease-in-out infinite',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<PawPrint size={32} style={{ color: 'var(--color-terra)' }} />
|
||||
</div>
|
||||
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: 'var(--font-playfair, Georgia, serif)',
|
||||
fontWeight: 900,
|
||||
fontSize: '28px',
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Pedido enviado! 🎉
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: '16px',
|
||||
color: 'var(--color-muted)',
|
||||
lineHeight: 1.6,
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
O teu pedido de adopção do{' '}
|
||||
<strong style={{ color: 'var(--color-text)', fontWeight: 600 }}>{animal.name}</strong>{' '}
|
||||
foi enviado para o{' '}
|
||||
<strong style={{ color: 'var(--color-text)', fontWeight: 600 }}>{animal.shelter.name}</strong>.
|
||||
<br /><br />
|
||||
Vais receber um email de confirmação em breve.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ background: 'var(--color-sage)', width: '100%', justifyContent: 'center' }}
|
||||
onClick={onClose}
|
||||
id="confirmation-view-reservation"
|
||||
>
|
||||
<CheckCircle2 size={16} />
|
||||
Ver a minha reserva
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
Voltar à ficha
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Lightbox({ photos, initial, onClose }: { photos: string[]; initial: number; onClose: () => void }) {
|
||||
const [current, setCurrent] = useState(initial);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowRight') setCurrent(c => (c + 1) % photos.length);
|
||||
if (e.key === 'ArrowLeft') setCurrent(c => (c - 1 + photos.length) % photos.length);
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [photos.length, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lightbox"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Galeria de fotos"
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
right: '16px',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
color: 'white',
|
||||
}}
|
||||
aria-label="Fechar galeria"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
{photos.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setCurrent(c => (c - 1 + photos.length) % photos.length); }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '16px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
color: 'white',
|
||||
}}
|
||||
aria-label="Foto anterior"
|
||||
>
|
||||
<ChevronLeft size={22} />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setCurrent(c => (c + 1) % photos.length); }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '16px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
color: 'white',
|
||||
}}
|
||||
aria-label="Próxima foto"
|
||||
>
|
||||
<ChevronRight size={22} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{ position: 'relative', maxWidth: '90vw', maxHeight: '85vh', width: '100%' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Image
|
||||
src={photos[current]}
|
||||
alt={`Foto ${current + 1} de ${photos.length}`}
|
||||
width={900}
|
||||
height={675}
|
||||
style={{ objectFit: 'contain', borderRadius: '12px', maxHeight: '85vh', width: 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnimalDetailPage({ params }: { params: { id: string } }) {
|
||||
const animal = MOCK_ANIMALS.find(a => a.id === params.id);
|
||||
const [mainPhoto, setMainPhoto] = useState(0);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxPhoto, setLightboxPhoto] = useState(0);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
|
||||
if (!animal) return notFound();
|
||||
|
||||
const ageLabel = formatAge(animal.ageMonths);
|
||||
|
||||
const openLightbox = (index: number) => {
|
||||
setLightboxPhoto(index);
|
||||
setLightboxOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Lightbox */}
|
||||
{lightboxOpen && (
|
||||
<Lightbox
|
||||
photos={animal.photos}
|
||||
initial={lightboxPhoto}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirmation overlay */}
|
||||
{showConfirmation && (
|
||||
<ConfirmationOverlay animal={animal} onClose={() => setShowConfirmation(false)} />
|
||||
)}
|
||||
|
||||
<div style={{ background: 'var(--color-bg)' }}>
|
||||
{/* Breadcrumb */}
|
||||
<div className="container" style={{ paddingTop: '24px', paddingBottom: '8px' }}>
|
||||
<Link
|
||||
href="/main/animals"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: '14px',
|
||||
color: 'var(--color-muted)',
|
||||
textDecoration: 'none',
|
||||
transition: 'color 180ms ease',
|
||||
}}
|
||||
onMouseEnter={e => ((e.currentTarget as HTMLElement).style.color = 'var(--color-terra)')}
|
||||
onMouseLeave={e => ((e.currentTarget as HTMLElement).style.color = 'var(--color-muted)')}
|
||||
aria-label="Voltar à listagem de animais"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Todos os animais
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="container" style={{ padding: '16px 16px 80px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr',
|
||||
gap: '40px',
|
||||
}}
|
||||
className="lg:grid-cols-2"
|
||||
>
|
||||
{/* Left — Gallery */}
|
||||
<div>
|
||||
{/* Main image */}
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '16px',
|
||||
overflow: 'hidden',
|
||||
aspectRatio: '4/3',
|
||||
position: 'relative',
|
||||
cursor: 'zoom-in',
|
||||
marginBottom: '12px',
|
||||
background: 'var(--color-linen)',
|
||||
}}
|
||||
onClick={() => openLightbox(mainPhoto)}
|
||||
role="button"
|
||||
aria-label={`Ver foto ${mainPhoto + 1} em tamanho completo`}
|
||||
tabIndex={0}
|
||||
onKeyDown={e => e.key === 'Enter' && openLightbox(mainPhoto)}
|
||||
>
|
||||
<Image
|
||||
src={animal.photos[mainPhoto]}
|
||||
alt={`${animal.name}, ${animal.breed} ${animal.sex === 'MALE' ? 'macho' : 'fêmea'}, foto ${mainPhoto + 1}`}
|
||||
fill
|
||||
style={{ objectFit: 'cover', transition: 'transform 400ms ease' }}
|
||||
priority
|
||||
/>
|
||||
{animal.urgent && (
|
||||
<div style={{ position: 'absolute', top: '16px', left: '16px' }}>
|
||||
<span className="badge-urgent">
|
||||
<AlertTriangle size={10} />
|
||||
Urgente
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnails */}
|
||||
{animal.photos.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{animal.photos.map((photo, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setMainPhoto(i)}
|
||||
style={{
|
||||
width: '72px',
|
||||
height: '54px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
border: `2px solid ${i === mainPhoto ? 'var(--color-terra)' : 'var(--color-border)'}`,
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 180ms ease',
|
||||
padding: 0,
|
||||
background: 'none',
|
||||
}}
|
||||
aria-label={`Ver foto ${i + 1}`}
|
||||
aria-pressed={i === mainPhoto}
|
||||
>
|
||||
<Image
|
||||
src={photo}
|
||||
alt={`Miniatura ${i + 1}`}
|
||||
fill
|
||||
style={{ objectFit: 'cover' }}
|
||||
sizes="72px"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right — Info */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
{/* Name & tags */}
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: 'var(--font-playfair, Georgia, serif)',
|
||||
fontWeight: 900,
|
||||
fontSize: 'clamp(36px, 5vw, 52px)',
|
||||
color: 'var(--color-text)',
|
||||
lineHeight: 1.05,
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
{animal.name}
|
||||
</h1>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '16px' }}>
|
||||
{[animal.breed, ageLabel, animal.sex === 'MALE' ? '♂ Macho' : '♀ Fêmea'].map((tag) => (
|
||||
<span key={tag} className="tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{animal.sterilized && (
|
||||
<span
|
||||
className="tag"
|
||||
style={{ color: 'var(--color-sage)', borderColor: 'var(--color-sage)', background: 'transparent' }}
|
||||
>
|
||||
<CheckCircle2 size={11} style={{ marginRight: '4px' }} />
|
||||
Esterilizado/a
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* About section */}
|
||||
<div>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: 'var(--font-playfair, Georgia, serif)',
|
||||
fontWeight: 700,
|
||||
fontSize: '20px',
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<em style={{ fontStyle: 'italic' }}>Sobre</em> o {animal.name}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: '16px',
|
||||
lineHeight: 1.7,
|
||||
color: 'var(--color-muted)',
|
||||
}}
|
||||
>
|
||||
{animal.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Shelter info */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: 'var(--color-text)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
}}
|
||||
>
|
||||
Canil responsável
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
|
||||
<MapPin size={15} style={{ color: 'var(--color-terra)', flexShrink: 0, marginTop: '2px' }} />
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, fontSize: '15px', color: 'var(--color-text)', fontFamily: 'var(--font-body)' }}>
|
||||
{animal.shelter.name}
|
||||
</p>
|
||||
<p style={{ fontSize: '13px', color: 'var(--color-muted)', fontFamily: 'var(--font-body)' }}>
|
||||
{animal.shelter.address}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<Clock size={15} style={{ color: 'var(--color-terra)', flexShrink: 0 }} />
|
||||
<p style={{ fontSize: '14px', color: 'var(--color-muted)', fontFamily: 'var(--font-body)' }}>
|
||||
{animal.shelter.openHours}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<Phone size={15} style={{ color: 'var(--color-terra)', flexShrink: 0 }} />
|
||||
<a
|
||||
href={`tel:${animal.shelter.phone.replace(/\s/g, '')}`}
|
||||
style={{ fontSize: '14px', color: 'var(--color-terra)', fontFamily: 'var(--font-body)', textDecoration: 'none' }}
|
||||
>
|
||||
{animal.shelter.phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA — desktop */}
|
||||
<div
|
||||
id="adoptar"
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}
|
||||
className="hidden-mobile-cta"
|
||||
>
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ justifyContent: 'center', fontSize: '15px', padding: '16px 32px' }}
|
||||
onClick={() => setShowConfirmation(true)}
|
||||
id="animal-adopt-btn"
|
||||
aria-label={`Adoptar ${animal.name} — enviar pedido ao ${animal.shelter.name}`}
|
||||
>
|
||||
<Heart size={16} />
|
||||
Adoptar o {animal.name}
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-muted)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Precisas de ter conta para adoptar. É gratuito e leva 2 minutos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky CTA bar — mobile */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'var(--color-bg)',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
zIndex: 50,
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}
|
||||
className="mobile-sticky-cta"
|
||||
>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
style={{ flex: 1, justifyContent: 'center', minHeight: '48px' }}
|
||||
aria-label={`Guardar ${animal.name} nos favoritos`}
|
||||
>
|
||||
<Heart size={16} />
|
||||
Guardar
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ flex: 2, justifyContent: 'center', minHeight: '48px' }}
|
||||
onClick={() => setShowConfirmation(true)}
|
||||
id="animal-adopt-mobile-btn"
|
||||
aria-label={`Adoptar ${animal.name}`}
|
||||
>
|
||||
Adoptar o {animal.name}
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
245
app/main/animals/page.tsx
Normal file
245
app/main/animals/page.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { PawPrint, SlidersHorizontal } from 'lucide-react';
|
||||
import AnimalCard from '@/components/animals/AnimalCard';
|
||||
import FilterChips, { FILTER_OPTIONS } from '@/components/animals/FilterChips';
|
||||
import { MOCK_ANIMALS, Animal } from '@/lib/mock-data';
|
||||
|
||||
// TODO: substituir por query Prisma quando DATABASE_URL estiver configurada
|
||||
|
||||
function filterAnimals(animals: Animal[], filter: string): Animal[] {
|
||||
switch (filter) {
|
||||
case 'dog': return animals.filter(a => a.species === 'DOG');
|
||||
case 'cat': return animals.filter(a => a.species === 'CAT');
|
||||
case 'urgent': return animals.filter(a => a.urgent);
|
||||
case 'lisboa': return animals.filter(a => a.shelter.district.toLowerCase() === 'lisboa');
|
||||
case 'porto': return animals.filter(a => a.shelter.district.toLowerCase() === 'porto');
|
||||
case 'braga': return animals.filter(a => a.shelter.district.toLowerCase() === 'braga');
|
||||
case 'sintra': return animals.filter(a => a.shelter.district.toLowerCase() === 'sintra');
|
||||
case 'male': return animals.filter(a => a.sex === 'MALE');
|
||||
case 'female': return animals.filter(a => a.sex === 'FEMALE');
|
||||
case 'sterilized': return animals.filter(a => a.sterilized);
|
||||
default: return animals;
|
||||
}
|
||||
}
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '16px',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="skeleton" style={{ aspectRatio: '4/3', width: '100%' }} />
|
||||
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<div className="skeleton" style={{ height: '26px', width: '60%', borderRadius: '6px' }} />
|
||||
<div className="skeleton" style={{ height: '16px', width: '80%', borderRadius: '6px' }} />
|
||||
<div className="skeleton" style={{ height: '14px', width: '50%', borderRadius: '6px' }} />
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||||
<div className="skeleton" style={{ height: '40px', flex: 1, borderRadius: '100px' }} />
|
||||
<div className="skeleton" style={{ height: '40px', flex: 1, borderRadius: '100px' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnimalsPage() {
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Simulate async load
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setLoading(false), 700);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
// Re-show skeleton briefly when filter changes
|
||||
const handleFilterChange = (id: string) => {
|
||||
setLoading(true);
|
||||
setActiveFilter(id);
|
||||
setTimeout(() => setLoading(false), 350);
|
||||
};
|
||||
|
||||
const animals = useMemo(() => filterAnimals(MOCK_ANIMALS, activeFilter), [activeFilter]);
|
||||
|
||||
const urgentCount = MOCK_ANIMALS.filter(a => a.urgent).length;
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--color-bg)', minHeight: '100vh' }}>
|
||||
{/* Page header */}
|
||||
<div
|
||||
style={{
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
padding: 'clamp(32px, 5vw, 56px) 0 0',
|
||||
background: 'var(--color-bg)',
|
||||
}}
|
||||
>
|
||||
<div className="container">
|
||||
{/* Breadcrumb */}
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--color-muted)',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
PawLink · Adopção
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: '16px',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: 'var(--font-playfair, Georgia, serif)',
|
||||
fontWeight: 900,
|
||||
fontSize: 'clamp(32px, 5vw, 48px)',
|
||||
color: 'var(--color-text)',
|
||||
lineHeight: 1.1,
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Animais disponíveis
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: '16px',
|
||||
color: 'var(--color-muted)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{MOCK_ANIMALS.length.toLocaleString('pt-PT')} animais à espera
|
||||
{urgentCount > 0 && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '10px',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
background: 'var(--color-amber)',
|
||||
color: 'var(--color-soil)',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '100px',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{urgentCount} urgentes
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-ghost"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
border: '1.5px solid var(--color-border)',
|
||||
borderRadius: '12px',
|
||||
}}
|
||||
aria-label="Abrir filtros avançados"
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
Filtros avançados
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter chips */}
|
||||
<FilterChips
|
||||
options={FILTER_OPTIONS}
|
||||
active={activeFilter}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="container" style={{ padding: '40px 16px' }}>
|
||||
{loading ? (
|
||||
<div className="animal-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : animals.length > 0 ? (
|
||||
<>
|
||||
<div className="animal-grid">
|
||||
{animals.map((animal, index) => (
|
||||
<AnimalCard key={animal.id} animal={animal} index={index} />
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '40px',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-muted)',
|
||||
letterSpacing: '0.06em',
|
||||
}}
|
||||
>
|
||||
{animals.length} de {MOCK_ANIMALS.length} animais • Dados de demonstração
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '80px 24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<PawPrint
|
||||
size={48}
|
||||
style={{ color: 'var(--color-fog)', strokeWidth: 1.5 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
Nenhum animal encontrado
|
||||
</h2>
|
||||
<p style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-body)', fontSize: '16px' }}>
|
||||
Tenta um filtro diferente ou vê todos os animais disponíveis.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleFilterChange('all')}
|
||||
className="btn-secondary"
|
||||
style={{ marginTop: '8px' }}
|
||||
>
|
||||
Remover filtros
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
app/main/donate/page.tsx
Normal file
241
app/main/donate/page.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import Link from 'next/link';
|
||||
import { Gift, CreditCard, Truck, Heart } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Fazer uma Doação',
|
||||
description: 'Doa ração, brinquedos ou apoio financeiro a canis parceiros PawLink em Portugal.',
|
||||
};
|
||||
|
||||
const donationTypes = [
|
||||
{
|
||||
id: 'monetary',
|
||||
icon: CreditCard,
|
||||
title: 'Doação Monetária',
|
||||
description: 'Transferência, cartão ou MBWay. O canil usa o valor para os cuidados que precisar.',
|
||||
badge: 'Mais impacto',
|
||||
badgeColor: 'var(--color-terra)',
|
||||
},
|
||||
{
|
||||
id: 'food',
|
||||
icon: Gift,
|
||||
title: 'Ração e Comida',
|
||||
description: 'Escolhes o tipo, a quantidade e se queres entregar em casa ou no canil.',
|
||||
badge: null,
|
||||
badgeColor: null,
|
||||
},
|
||||
{
|
||||
id: 'toys',
|
||||
icon: Heart,
|
||||
title: 'Brinquedos e Acessórios',
|
||||
description: 'Bolas, cordas, camas e muito mais. Podes escolher recolha em casa.',
|
||||
badge: null,
|
||||
badgeColor: null,
|
||||
},
|
||||
];
|
||||
|
||||
export default function DonatePage() {
|
||||
return (
|
||||
<div style={{ background: 'var(--color-bg)', minHeight: '100vh' }}>
|
||||
{/* Hero */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-soil)',
|
||||
padding: 'clamp(48px, 8vw, 80px) 0',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className="container" style={{ position: 'relative', zIndex: 1 }}>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--color-amber)',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
Faz a diferença hoje
|
||||
</p>
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: 'var(--font-playfair, Georgia, serif)',
|
||||
fontWeight: 900,
|
||||
fontSize: 'clamp(36px, 6vw, 60px)',
|
||||
color: 'var(--color-cream)',
|
||||
lineHeight: 1.1,
|
||||
maxWidth: '560px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
Doa ao teu canil{' '}
|
||||
<em style={{ color: 'var(--color-amber)', fontStyle: 'italic' }}>favorito.</em>
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: '16px',
|
||||
color: 'rgba(250,246,240,0.65)',
|
||||
maxWidth: '440px',
|
||||
lineHeight: 1.65,
|
||||
}}
|
||||
>
|
||||
Ração, brinquedos ou apoio financeiro. Escolhe como queres ajudar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Donation types */}
|
||||
<div className="container" style={{ padding: 'clamp(40px, 6vw, 64px) 16px' }}>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: 'var(--font-playfair, Georgia, serif)',
|
||||
fontWeight: 900,
|
||||
fontSize: 'clamp(24px, 4vw, 36px)',
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: '32px',
|
||||
}}
|
||||
>
|
||||
Como queres ajudar?
|
||||
</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '48px',
|
||||
}}
|
||||
>
|
||||
{donationTypes.map(({ id, icon: Icon, title, description, badge, badgeColor }) => (
|
||||
<div
|
||||
key={id}
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1.5px solid var(--color-border)',
|
||||
borderRadius: '16px',
|
||||
padding: '28px 24px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 200ms ease',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.borderColor = 'var(--color-terra)';
|
||||
el.style.transform = 'translateY(-4px)';
|
||||
el.style.boxShadow = '0 12px 40px rgba(45,27,14,0.1)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.borderColor = 'var(--color-border)';
|
||||
el.style.transform = 'translateY(0)';
|
||||
el.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
{badge && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
right: '16px',
|
||||
background: badgeColor || 'var(--color-terra)',
|
||||
color: 'white',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '9px',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '100px',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
background: 'var(--color-terra-light)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<Icon size={22} style={{ color: 'var(--color-terra)' }} />
|
||||
</div>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontWeight: 700,
|
||||
fontSize: '20px',
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.65,
|
||||
color: 'var(--color-muted)',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ width: '100%', justifyContent: 'center', fontSize: '13px', padding: '12px 20px', minHeight: '44px' }}
|
||||
aria-label={`Seleccionar: ${title}`}
|
||||
>
|
||||
Escolher
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Auth notice */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-linen)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px 24px',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: '15px',
|
||||
color: 'var(--color-text)',
|
||||
maxWidth: '400px',
|
||||
}}
|
||||
>
|
||||
Para fazer uma doação precisas de ter conta PawLink. É grátis, rápido e seguro.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||||
<Link href="/auth/register" className="btn-primary" style={{ fontSize: '13px', padding: '12px 20px', minHeight: '44px' }}>
|
||||
Criar conta
|
||||
</Link>
|
||||
<Link href="/auth/login" className="btn-secondary" style={{ fontSize: '13px', padding: '12px 20px', minHeight: '44px' }}>
|
||||
Entrar
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
app/main/layout.tsx
Normal file
12
app/main/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import Header from '@/components/layout/Header';
|
||||
import Footer from '@/components/layout/Footer';
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main style={{ flex: 1, minHeight: '60vh' }}>{children}</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
176
app/main/shelters/page.tsx
Normal file
176
app/main/shelters/page.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { MapPin, Phone, Mail, Clock, PawPrint, ChevronRight } from 'lucide-react';
|
||||
import Header from '@/components/layout/Header';
|
||||
import Footer from '@/components/layout/Footer';
|
||||
|
||||
interface Shelter {
|
||||
id: string;
|
||||
name: string;
|
||||
district: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
description: string | null;
|
||||
website: string | null;
|
||||
openHours: Record<string, string | null>;
|
||||
_count: { animals: number };
|
||||
}
|
||||
|
||||
const DAY_LABELS: Record<string, string> = {
|
||||
mon: 'Seg', tue: 'Ter', wed: 'Qua',
|
||||
thu: 'Qui', fri: 'Sex', sat: 'Sáb', sun: 'Dom',
|
||||
};
|
||||
|
||||
export default function SheltersPage() {
|
||||
const [shelters, setShelters] = useState<Shelter[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/shelters')
|
||||
.then((r) => r.json())
|
||||
.then((data) => setShelters(Array.isArray(data) ? data : []))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main style={{ flex: 1 }}>
|
||||
{/* Hero */}
|
||||
<section style={{ padding: 'var(--space-9) 0 var(--space-7)', background: 'var(--cream)', borderBottom: '1px solid var(--parchment)' }}>
|
||||
<div className="container">
|
||||
<p className="eyebrow" style={{ marginBottom: '10px' }}>Portugal</p>
|
||||
<h1 style={{ fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: 'clamp(36px, 6vw, 60px)', color: 'var(--soil)', letterSpacing: '-0.03em', lineHeight: 1.05, marginBottom: 'var(--space-4)' }}>
|
||||
Canis <em style={{ fontStyle: 'italic', color: 'var(--terra)' }}>parceiros.</em>
|
||||
</h1>
|
||||
<p style={{ fontFamily: 'var(--font-body)', fontSize: '18px', color: 'var(--soil-mid)', maxWidth: '520px', lineHeight: 1.65 }}>
|
||||
Organizações verificadas em todo o país que trabalham diariamente para encontrar lares a animais abandonados.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Listagem */}
|
||||
<section style={{ padding: 'var(--space-8) 0', background: 'var(--cream)' }}>
|
||||
<div className="container">
|
||||
{loading ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 'var(--space-5)' }}>
|
||||
{[1,2,3,4].map(i => (
|
||||
<div key={i} className="skeleton-card">
|
||||
<div className="skeleton skeleton-image" style={{ height: '120px' }} />
|
||||
<div className="skeleton skeleton-title" />
|
||||
<div className="skeleton skeleton-sub" />
|
||||
<div className="skeleton skeleton-location" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : shelters.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 'var(--space-9) 0' }}>
|
||||
<PawPrint size={48} style={{ margin: '0 auto 16px', opacity: 0.2, color: 'var(--soil)' }} />
|
||||
<p style={{ fontFamily: 'var(--font-body)', fontSize: '18px', color: 'var(--soil-mid)' }}>
|
||||
Nenhum canil disponível de momento.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 'var(--space-5)' }}>
|
||||
{shelters.map((shelter) => (
|
||||
<article
|
||||
key={shelter.id}
|
||||
style={{
|
||||
background: 'var(--linen)',
|
||||
border: '1px solid var(--parchment)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'var(--shadow-warm-sm)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 220ms var(--ease-spring), box-shadow 220ms ease',
|
||||
animation: 'cardReveal 500ms cubic-bezier(0.22, 1, 0.36, 1) both',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.transform = 'translateY(-4px)';
|
||||
el.style.boxShadow = 'var(--shadow-warm-lg)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.transform = 'translateY(0)';
|
||||
el.style.boxShadow = 'var(--shadow-warm-sm)';
|
||||
}}
|
||||
>
|
||||
{/* Header colorido */}
|
||||
<div style={{ background: 'var(--terra-glow)', borderBottom: '1px solid var(--parchment)', padding: '20px 20px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div style={{ width: '40px', height: '40px', borderRadius: '12px', background: 'var(--terra)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<PawPrint size={18} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '16px', color: 'var(--soil)', lineHeight: 1.2 }}>{shelter.name}</h2>
|
||||
<p style={{ fontFamily: 'var(--font-accent)', fontSize: '10px', color: 'var(--soil-faint)', letterSpacing: '0.08em', textTransform: 'uppercase', marginTop: '2px' }}>{shelter.district}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '22px', color: 'var(--terra)', lineHeight: 1 }}>{shelter._count.animals}</div>
|
||||
<div style={{ fontFamily: 'var(--font-accent)', fontSize: '9px', color: 'var(--soil-faint)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>disponíveis</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: '10px', flex: 1 }}>
|
||||
{shelter.description && (
|
||||
<p style={{ fontFamily: 'var(--font-body)', fontStyle: 'italic', fontSize: '14px', color: 'var(--soil-mid)', lineHeight: 1.6 }}>
|
||||
{shelter.description.length > 120 ? `${shelter.description.slice(0, 120)}…` : shelter.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<MapPin size={13} style={{ color: 'var(--terra)', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'var(--font-body)', fontStyle: 'italic', fontSize: '13px', color: 'var(--soil-mid)' }}>{shelter.address}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Phone size={13} style={{ color: 'var(--soil-faint)', flexShrink: 0 }} />
|
||||
<a href={`tel:${shelter.phone}`} style={{ fontFamily: 'var(--font-body)', fontSize: '13px', color: 'var(--soil-mid)', textDecoration: 'none' }}>{shelter.phone}</a>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Mail size={13} style={{ color: 'var(--soil-faint)', flexShrink: 0 }} />
|
||||
<a href={`mailto:${shelter.email}`} style={{ fontFamily: 'var(--font-body)', fontSize: '13px', color: 'var(--terra)', textDecoration: 'none' }}>{shelter.email}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--parchment)', margin: '4px 0' }} />
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Link
|
||||
href={`/main/animals?district=${encodeURIComponent(shelter.district)}`}
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1, justifyContent: 'center', padding: '10px 12px', fontSize: '11px', minHeight: '40px' }}
|
||||
>
|
||||
Ver animais
|
||||
</Link>
|
||||
<Link
|
||||
href={`/main/donate?shelterId=${shelter.id}`}
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1, justifyContent: 'center', padding: '10px 12px', fontSize: '11px', minHeight: '40px', gap: '4px' }}
|
||||
>
|
||||
Doar
|
||||
<ChevronRight size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
493
app/page.tsx
493
app/page.tsx
@@ -1,65 +1,438 @@
|
||||
import Image from "next/image";
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ChevronRight, PawPrint, Heart, ArrowRight } from 'lucide-react';
|
||||
import Header from '@/components/layout/Header';
|
||||
import Footer from '@/components/layout/Footer';
|
||||
import AnimalCard from '@/components/animals/AnimalCard';
|
||||
import FilterChips, { FILTER_OPTIONS } from '@/components/animals/FilterChips';
|
||||
import { MOCK_ANIMALS, Animal } from '@/lib/mock-data';
|
||||
|
||||
function useCountUp(target: number, duration = 1800) {
|
||||
const [count, setCount] = useState(0);
|
||||
const [started, setStarted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!started) return;
|
||||
const start = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const elapsed = now - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setCount(Math.floor(eased * target));
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}, [started, target, duration]);
|
||||
|
||||
return { count, setStarted };
|
||||
}
|
||||
|
||||
function AnimatedCounter({ target, label }: { target: number; label: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { count, setStarted } = useCountUp(target);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => { if (entry.isIntersecting) setStarted(true); },
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
if (ref.current) observer.observe(ref.current);
|
||||
return () => observer.disconnect();
|
||||
}, [setStarted]);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<div ref={ref} style={{ textAlign: 'center' }} role="status" aria-label={`${count} ${label}`}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-accent)',
|
||||
fontSize: 'clamp(32px, 6vw, 48px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--terra)',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{count.toLocaleString('pt-PT')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-accent)',
|
||||
fontSize: '11px',
|
||||
color: 'var(--soil-faint)',
|
||||
marginTop: '6px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function filterAnimals(animals: Animal[], filter: string): Animal[] {
|
||||
switch (filter) {
|
||||
case 'dog': return animals.filter(a => a.species === 'DOG');
|
||||
case 'cat': return animals.filter(a => a.species === 'CAT');
|
||||
case 'urgent': return animals.filter(a => a.urgent);
|
||||
case 'lisboa': return animals.filter(a => a.shelter.district.toLowerCase() === 'lisboa');
|
||||
case 'porto': return animals.filter(a => a.shelter.district.toLowerCase() === 'porto');
|
||||
case 'braga': return animals.filter(a => a.shelter.district.toLowerCase() === 'braga');
|
||||
case 'sintra': return animals.filter(a => a.shelter.district.toLowerCase() === 'sintra');
|
||||
case 'male': return animals.filter(a => a.sex === 'MALE');
|
||||
case 'female': return animals.filter(a => a.sex === 'FEMALE');
|
||||
case 'sterilized': return animals.filter(a => a.sterilized);
|
||||
default: return animals;
|
||||
}
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
const [displayed, setDisplayed] = useState<Animal[]>(MOCK_ANIMALS.slice(0, 8));
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = filterAnimals(MOCK_ANIMALS, activeFilter);
|
||||
setDisplayed(filtered.slice(0, 8));
|
||||
}, [activeFilter]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
|
||||
<main style={{ flex: 1 }}>
|
||||
{/* ── Hero ───────────────────────────────────────────────── */}
|
||||
<section className="hero" aria-labelledby="hero-heading">
|
||||
<div className="hero-grain" aria-hidden="true" />
|
||||
<div className="hero-glow" aria-hidden="true" />
|
||||
|
||||
<div className="hero-content container">
|
||||
<p className="hero-eyebrow">Portugal · Adopção responsável</p>
|
||||
|
||||
<h1
|
||||
id="hero-heading"
|
||||
className="hero-title"
|
||||
>
|
||||
Encontra o teu<br />
|
||||
companheiro<br />
|
||||
para a <em>vida.</em>
|
||||
</h1>
|
||||
|
||||
<p className="hero-sub">
|
||||
Mais de 1.200 animais à espera de uma família em canis por todo o país.
|
||||
</p>
|
||||
|
||||
<div className="hero-actions">
|
||||
<Link
|
||||
href="/main/animals"
|
||||
className="btn btn-primary"
|
||||
id="hero-cta-explore"
|
||||
aria-label="Explorar animais disponíveis para adopção"
|
||||
>
|
||||
<PawPrint size={15} />
|
||||
Explorar animais
|
||||
</Link>
|
||||
<Link
|
||||
href="#como-funciona"
|
||||
className="btn btn-ghost"
|
||||
id="hero-cta-how"
|
||||
>
|
||||
Como funciona →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 'var(--space-8)',
|
||||
paddingTop: 'var(--space-7)',
|
||||
borderTop: '1px solid var(--parchment)',
|
||||
marginTop: 'var(--space-7)',
|
||||
animation: 'heroReveal 600ms 550ms ease both',
|
||||
}}
|
||||
aria-label="Estatísticas da plataforma"
|
||||
>
|
||||
<AnimatedCounter target={1247} label="animais à espera" />
|
||||
<div style={{ width: '1px', background: 'var(--parchment)', alignSelf: 'stretch' }} aria-hidden="true" />
|
||||
<AnimatedCounter target={38} label="canis parceiros" />
|
||||
<div style={{ width: '1px', background: 'var(--parchment)', alignSelf: 'stretch' }} aria-hidden="true" />
|
||||
<AnimatedCounter target={892} label="adopções este ano" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Animais em Destaque ────────────────────────────────── */}
|
||||
<section
|
||||
style={{ padding: 'var(--space-9) 0', background: 'var(--cream)' }}
|
||||
aria-labelledby="animals-heading"
|
||||
>
|
||||
<div className="container">
|
||||
{/* Cabeçalho da secção */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 'var(--space-4)',
|
||||
marginBottom: 'var(--space-5)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-accent)',
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.14em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--terra)',
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
Disponíveis agora
|
||||
</p>
|
||||
<h2 id="animals-heading" className="section-title">
|
||||
Animais à espera<br />
|
||||
<em style={{ fontStyle: 'italic', color: 'var(--terra)' }}>de ti.</em>
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/main/animals"
|
||||
className="btn btn-ghost"
|
||||
style={{ color: 'var(--terra)', display: 'flex', alignItems: 'center', gap: '6px' }}
|
||||
aria-label="Ver todos os animais disponíveis"
|
||||
>
|
||||
Ver todos
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<div style={{ marginBottom: 'var(--space-6)' }}>
|
||||
<FilterChips
|
||||
options={FILTER_OPTIONS}
|
||||
active={activeFilter}
|
||||
onChange={setActiveFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grelha */}
|
||||
{displayed.length > 0 ? (
|
||||
<div className="animal-grid">
|
||||
{displayed.map((animal, index) => (
|
||||
<AnimalCard key={animal.id} animal={animal} index={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: 'var(--space-9) var(--space-5)',
|
||||
color: 'var(--soil-mid)',
|
||||
}}
|
||||
>
|
||||
<PawPrint size={40} style={{ margin: '0 auto 16px', opacity: 0.25 }} />
|
||||
<p style={{ fontFamily: 'var(--font-body)', fontSize: '18px' }}>
|
||||
Nenhum animal encontrado com esse filtro.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setActiveFilter('all')}
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: 'var(--space-4)' }}
|
||||
>
|
||||
Ver todos
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayed.length > 0 && (
|
||||
<div style={{ textAlign: 'center', marginTop: 'var(--space-7)' }}>
|
||||
<Link href="/main/animals" className="btn btn-primary" id="homepage-see-all">
|
||||
Ver todos os animais
|
||||
<ChevronRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Como Funciona ──────────────────────────────────────── */}
|
||||
<section
|
||||
id="como-funciona"
|
||||
style={{ padding: 'var(--space-9) 0', background: 'var(--linen)' }}
|
||||
aria-labelledby="how-heading"
|
||||
>
|
||||
<div className="container">
|
||||
<div style={{ textAlign: 'center', marginBottom: 'var(--space-7)' }}>
|
||||
<p style={{ fontFamily: 'var(--font-accent)', fontSize: '11px', letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--terra)', marginBottom: '10px' }}>
|
||||
Simples e transparente
|
||||
</p>
|
||||
<h2 id="how-heading" className="section-title">Como funciona</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
||||
gap: 'var(--space-5)',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ step: '01', emoji: '🐾', title: 'Descobre', desc: 'Navega pelos animais disponíveis e filtra por distrito, espécie ou características.' },
|
||||
{ step: '02', emoji: '💛', title: 'Liga-te', desc: 'Lê a ficha completa, vê as fotos e conhece a história do animal.' },
|
||||
{ step: '03', emoji: '📅', title: 'Reserva', desc: 'Faz a reserva online e recebe confirmação por email. Simples e seguro.' },
|
||||
{ step: '04', emoji: '🏡', title: 'Adopta', desc: 'Vai ao canil na data marcada e leva o teu novo companheiro para casa.' },
|
||||
].map(({ step, emoji, title, desc }) => (
|
||||
<div
|
||||
key={step}
|
||||
style={{
|
||||
background: 'var(--cream)',
|
||||
border: '1px solid var(--parchment)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: 'var(--space-6)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--space-3)',
|
||||
transition: 'transform 220ms var(--ease-spring), box-shadow 220ms ease',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.transform = 'translateY(-4px)';
|
||||
el.style.boxShadow = 'var(--shadow-warm-md)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.transform = 'translateY(0)';
|
||||
el.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: '36px', lineHeight: 1 }}>{emoji}</span>
|
||||
<span style={{ fontFamily: 'var(--font-accent)', fontSize: '11px', color: 'var(--soil-faint)', letterSpacing: '0.1em' }}>{step}</span>
|
||||
</div>
|
||||
<h3 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '20px', fontStyle: 'italic', color: 'var(--soil)' }}>
|
||||
{title}
|
||||
</h3>
|
||||
<p style={{ fontFamily: 'var(--font-body)', fontSize: '15px', lineHeight: 1.65, color: 'var(--soil-mid)' }}>
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Doações ────────────────────────────────────────────── */}
|
||||
<section
|
||||
style={{ padding: 'var(--space-9) 0', background: 'var(--cream)' }}
|
||||
aria-labelledby="donate-heading"
|
||||
>
|
||||
<div className="container">
|
||||
<div style={{ textAlign: 'center', marginBottom: 'var(--space-7)' }}>
|
||||
<p style={{ fontFamily: 'var(--font-accent)', fontSize: '11px', letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--terra)', marginBottom: '10px' }}>
|
||||
Também podes ajudar assim
|
||||
</p>
|
||||
<h2 id="donate-heading" className="section-title" style={{ marginBottom: 'var(--space-3)' }}>
|
||||
Os canis precisam de<br />
|
||||
<em style={{ fontStyle: 'italic', color: 'var(--terra)' }}>mais</em> do que adopções.
|
||||
</h2>
|
||||
<p className="section-subtitle" style={{ maxWidth: '520px', margin: '0 auto' }}>
|
||||
Doa ração, brinquedos ou apoio financeiro. Tu escolhes como ajudar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 'var(--space-5)' }}>
|
||||
{[
|
||||
{ href: '/main/donate?type=monetary', emoji: '💸', label: 'Doação Monetária', desc: 'Contribui directamente para os cuidados veterinários e alimentação.' },
|
||||
{ href: '/main/donate?type=food', emoji: '🥩', label: 'Doação de Ração', desc: 'Escolhe a quantidade e enviamos ao canil da tua escolha.' },
|
||||
{ href: '/main/donate?type=toys', emoji: '🎾', label: 'Brinquedos', desc: 'Enriquecimento ambiental para animais em espera de adopção.' },
|
||||
].map(({ href, emoji, label, desc }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
style={{ textDecoration: 'none' }}
|
||||
aria-label={label}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--linen)',
|
||||
border: '1px solid var(--parchment)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: 'var(--space-6)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--space-4)',
|
||||
height: '100%',
|
||||
boxShadow: 'var(--shadow-warm-sm)',
|
||||
transition: 'transform 250ms var(--ease-spring), box-shadow 250ms ease',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.transform = 'translateY(-8px)';
|
||||
el.style.boxShadow = 'var(--shadow-warm-lg)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.transform = 'translateY(0)';
|
||||
el.style.boxShadow = 'var(--shadow-warm-sm)';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '56px',
|
||||
lineHeight: 1,
|
||||
filter: 'drop-shadow(0 8px 12px rgba(35,20,8,0.15))',
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontWeight: 700,
|
||||
fontSize: '22px',
|
||||
color: 'var(--soil)',
|
||||
marginBottom: '8px',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</h3>
|
||||
<p style={{ fontFamily: 'var(--font-body)', fontSize: '15px', color: 'var(--soil-mid)', lineHeight: 1.6 }}>
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontFamily: 'var(--font-accent)',
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--terra)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Saber mais
|
||||
<ChevronRight size={13} />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
173
app/shelter/dashboard/page.tsx
Normal file
173
app/shelter/dashboard/page.tsx
Normal 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
46
app/shelter/layout.tsx
Normal 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 | PawLink',
|
||||
description: 'Área de gestão do canil na plataforma PawLink.',
|
||||
};
|
||||
|
||||
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)' }}>
|
||||
PawLink 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user