feat: add operator landing and login
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LandingPage } from './LandingPage'
|
||||
import { login } from '../../services/api/auth'
|
||||
|
||||
|
||||
vi.mock('../../services/api/auth', () => ({
|
||||
login: vi.fn(),
|
||||
}))
|
||||
|
||||
|
||||
describe('LandingPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(login).mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('shows the Stitch-derived landing content and submits the real login flow', async () => {
|
||||
const onAuthenticated = vi.fn()
|
||||
vi.mocked(login).mockResolvedValue({
|
||||
authentication_required: true,
|
||||
authenticated: true,
|
||||
username: 'operator',
|
||||
expires_at: '2026-07-22T20:00:00Z',
|
||||
})
|
||||
render(<LandingPage onAuthenticated={onAuthenticated} />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: /Operationele GIS-analyse/i })).toBeTruthy()
|
||||
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: 'operator' } })
|
||||
fireEvent.change(screen.getByLabelText('Wachtwoord'), { target: { value: 'correct' } })
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Inloggen' })[1])
|
||||
|
||||
await waitFor(() => expect(login).toHaveBeenCalledWith('operator', 'correct'))
|
||||
expect(onAuthenticated).toHaveBeenCalledWith(expect.objectContaining({ authenticated: true }))
|
||||
})
|
||||
|
||||
it('surfaces an authentication error without entering the workbench', async () => {
|
||||
vi.mocked(login).mockRejectedValue(new Error('Gebruikersnaam of wachtwoord is onjuist.'))
|
||||
render(<LandingPage onAuthenticated={vi.fn()} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: 'operator' } })
|
||||
fireEvent.change(screen.getByLabelText('Wachtwoord'), { target: { value: 'wrong' } })
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Inloggen' })[1])
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('Gebruikersnaam of wachtwoord is onjuist.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
ArrowRight,
|
||||
BrainCircuit,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
Layers3,
|
||||
LockKeyhole,
|
||||
LogIn,
|
||||
MapPinned,
|
||||
Menu,
|
||||
ShieldCheck,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { login } from '../../services/api/auth'
|
||||
import type { AuthSession } from '../../services/api/auth'
|
||||
import { formatError } from '../../lib/formatError'
|
||||
import '../../styles/landing.css'
|
||||
|
||||
interface LandingPageProps {
|
||||
onAuthenticated: (session: AuthSession) => void
|
||||
serviceError?: string | null
|
||||
}
|
||||
|
||||
const capabilityItems = [
|
||||
{
|
||||
icon: MapPinned,
|
||||
title: 'Heel België in beeld',
|
||||
description:
|
||||
'Werk met officiële bronnen voor Vlaanderen, Wallonië, Brussel en de Belgische Noordzee, zonder regionale semantiek te vermengen.',
|
||||
tags: ['NGI', 'SPW', 'Digitaal Vlaanderen'],
|
||||
tone: 'primary',
|
||||
},
|
||||
{
|
||||
icon: BrainCircuit,
|
||||
title: 'AI met bewijsgrenzen',
|
||||
description:
|
||||
'Voer objectdetectie uit op geschikte luchtbeelden en beoordeel resultaten tegen referentiedata met zichtbare model- en validatiegrenzen.',
|
||||
tags: ['Objectdetectie', 'Lokale assistent'],
|
||||
tone: 'secondary',
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: 'Operationele kwaliteit',
|
||||
description:
|
||||
'Elke analyse bewaart bron, meetmoment, CRS, eenheid en beperkingen. Resultaten blijven inspecteerbaar vóór export of besluitvorming.',
|
||||
tags: ['QA/QC', 'Herleidbaar'],
|
||||
tone: 'attention',
|
||||
},
|
||||
]
|
||||
|
||||
export function LandingPage({ onAuthenticated, serviceError = null }: LandingPageProps): JSX.Element {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [loginError, setLoginError] = useState<string | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const usernameRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add('landing-body')
|
||||
return () => document.body.classList.remove('landing-body')
|
||||
}, [])
|
||||
|
||||
const submitLogin = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setSubmitting(true)
|
||||
setLoginError(null)
|
||||
try {
|
||||
const session = await login(username.trim(), password)
|
||||
onAuthenticated(session)
|
||||
} catch (error) {
|
||||
setLoginError(formatError(error, 'Aanmelden is niet gelukt. Probeer het opnieuw.'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const focusLogin = () => {
|
||||
setMenuOpen(false)
|
||||
window.requestAnimationFrame(() => usernameRef.current?.focus())
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="landing-page">
|
||||
<a className="landing-skip-link" href="#login-panel">Ga naar aanmelden</a>
|
||||
<header className="landing-header">
|
||||
<a className="landing-brand" href="#top" aria-label="GeoIntel Atlas startpagina">
|
||||
<span className="landing-brand-mark" aria-hidden="true">GI</span>
|
||||
<span>GeoIntel Atlas</span>
|
||||
</a>
|
||||
<button
|
||||
className="landing-menu-toggle"
|
||||
type="button"
|
||||
aria-label={menuOpen ? 'Navigatie sluiten' : 'Navigatie openen'}
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((current) => !current)}
|
||||
>
|
||||
{menuOpen ? <X aria-hidden="true" /> : <Menu aria-hidden="true" />}
|
||||
</button>
|
||||
<nav className={menuOpen ? 'landing-nav landing-nav-open' : 'landing-nav'} aria-label="Landingspagina">
|
||||
<a href="#mogelijkheden" onClick={() => setMenuOpen(false)}>Verkennen</a>
|
||||
<a href="#werkproces" onClick={() => setMenuOpen(false)}>Analyseren</a>
|
||||
<a href="#kwaliteit" onClick={() => setMenuOpen(false)}>Kwaliteit</a>
|
||||
</nav>
|
||||
<button className="landing-header-login" type="button" onClick={focusLogin}>
|
||||
Inloggen
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section className="landing-hero" aria-labelledby="landing-title">
|
||||
<div className="landing-hero-background" aria-hidden="true" />
|
||||
<div className="landing-hero-content">
|
||||
<div className="landing-hero-copy">
|
||||
<p className="landing-kicker"><CheckCircle2 aria-hidden="true" /> Operationele GeoAI-workbench</p>
|
||||
<h1 id="landing-title">Operationele GIS-analyse <span>op topniveau.</span></h1>
|
||||
<p className="landing-lead">
|
||||
De kaartgerichte workbench voor professionals die werken met gegevens van België en de Belgische Noordzee. Selecteer een gebied, meet officiële bronnen en controleer ieder resultaat.
|
||||
</p>
|
||||
<div className="landing-hero-actions">
|
||||
<a className="landing-primary-action" href="#mogelijkheden">
|
||||
<MapPinned aria-hidden="true" /> Bekijk mogelijkheden
|
||||
</a>
|
||||
<button className="landing-secondary-action" type="button" onClick={focusLogin}>
|
||||
Naar inloggen <ArrowRight aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<dl className="landing-trust-strip" aria-label="Platformbereik">
|
||||
<div><dt>Geografie</dt><dd>België + Noordzee</dd></div>
|
||||
<div><dt>Bronnen</dt><dd>Officieel per regio</dd></div>
|
||||
<div><dt>Uitvoer</dt><dd>GIS-herleidbaar</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="landing-login-card" id="login-panel">
|
||||
<div className="landing-login-heading">
|
||||
<span className="landing-login-icon" aria-hidden="true"><LockKeyhole /></span>
|
||||
<div>
|
||||
<h2>Toegang Workbench</h2>
|
||||
<p>Log in met uw GeoIntel-account.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={submitLogin} aria-busy={submitting}>
|
||||
<label htmlFor="login-username">Gebruikersnaam</label>
|
||||
<input
|
||||
ref={usernameRef}
|
||||
id="login-username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
<label htmlFor="login-password">Wachtwoord</label>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
{loginError || serviceError ? (
|
||||
<p className="landing-login-error" role="alert">{loginError ?? serviceError}</p>
|
||||
) : null}
|
||||
<button type="submit" disabled={submitting || !username.trim() || !password}>
|
||||
<LogIn aria-hidden="true" /> {submitting ? 'Aanmelden…' : 'Inloggen'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="landing-session-note"><ShieldCheck aria-hidden="true" /> Beveiligde, tijdelijke operatorsessie</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="landing-capabilities" id="mogelijkheden" aria-labelledby="capabilities-title">
|
||||
<div className="landing-section-heading">
|
||||
<p>Van bron tot besluit</p>
|
||||
<h2 id="capabilities-title">Eén werkbank, controle over de hele keten</h2>
|
||||
</div>
|
||||
<div className="landing-capability-grid">
|
||||
{capabilityItems.map(({ icon: Icon, title, description, tags, tone }) => (
|
||||
<article key={title} className={`landing-capability landing-capability-${tone}`}>
|
||||
<span className="landing-capability-icon" aria-hidden="true"><Icon /></span>
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
<div>{tags.map((tag) => <span key={tag}>{tag}</span>)}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="landing-workflow" id="werkproces" aria-labelledby="workflow-title">
|
||||
<div className="landing-workflow-map">
|
||||
<div className="landing-workflow-map-image" aria-hidden="true" />
|
||||
<div>
|
||||
<p>Kaart als werkomgeving</p>
|
||||
<h2 id="workflow-title">Van selectie naar aantoonbaar inzicht</h2>
|
||||
<span>Kies thema → teken gebied → controleer bron → analyseer → exporteer</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="landing-workflow-details">
|
||||
<article>
|
||||
<Database aria-hidden="true" />
|
||||
<div><h3>Bronnen per rechtsgebied</h3><p>Vlaamse, Waalse, Brusselse en maritieme bronnen blijven herkenbaar gescheiden.</p></div>
|
||||
</article>
|
||||
<article>
|
||||
<Layers3 aria-hidden="true" />
|
||||
<div><h3>Toestand en evolutie</h3><p>Vergelijk alleen meetmomenten die inhoudelijk en ruimtelijk verenigbaar zijn.</p></div>
|
||||
</article>
|
||||
<article id="kwaliteit">
|
||||
<ShieldCheck aria-hidden="true" />
|
||||
<div><h3>Kwaliteit vóór export</h3><p>CRS, eenheid, dekking, herkomst en beperkingen blijven naast het resultaat zichtbaar.</p></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="landing-footer">
|
||||
<div><strong>GeoIntel Atlas Workbench</strong><p>Operationele GIS-analyse voor België en de Belgische Noordzee.</p></div>
|
||||
<p>© {new Date().getFullYear()} GeoIntel · Interne operatoromgeving</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user