feat: sessão #3 — lib (db/auth/email/validations), API routes, NextAuth v5, middleware, páginas account/shelters/shelter-dashboard, Prisma v7 fix

This commit is contained in:
2026-05-21 09:01:59 +01:00
parent e6ebc0909c
commit e62dc9d6e6
44 changed files with 5341 additions and 273 deletions

View File

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

View File

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

View File

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