387 lines
16 KiB
TypeScript
387 lines
16 KiB
TypeScript
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
|
import {
|
|
ArrowRight,
|
|
BrainCircuit,
|
|
Check,
|
|
ChevronRight,
|
|
Database,
|
|
Eye,
|
|
EyeOff,
|
|
LockKeyhole,
|
|
LogIn,
|
|
MapPinned,
|
|
Menu,
|
|
MousePointer2,
|
|
ShieldCheck,
|
|
Sparkles,
|
|
UserRound,
|
|
X,
|
|
} from 'lucide-react'
|
|
import { login, loginAsGuest, type AuthSession } from '../../services/api/auth'
|
|
import { formatAuthError } from '../../lib/authError'
|
|
import '../../styles/landing.css'
|
|
import { GeoIntelMark } from '../brand/GeoIntelBrand'
|
|
import { ItWorxSignature } from '../brand/ItWorxSignature'
|
|
|
|
interface LandingPageProps {
|
|
onAuthenticated: (session: AuthSession) => void
|
|
serviceError?: string | null
|
|
guestAccessEnabled?: boolean
|
|
}
|
|
|
|
const capabilityItems = [
|
|
{
|
|
icon: MapPinned,
|
|
number: '01',
|
|
title: 'Eén kaartgerichte werkruimte',
|
|
description:
|
|
'Selecteer een gebied in België of de Belgische Noordzee en werk verder vanuit dezelfde ruimtelijke context.',
|
|
},
|
|
{
|
|
icon: Database,
|
|
number: '02',
|
|
title: 'Bronnen blijven herkenbaar',
|
|
description:
|
|
'Autoriteit, meetmoment, dekking, CRS en beperkingen blijven zichtbaar in plaats van achter één generieke kaartlaag te verdwijnen.',
|
|
},
|
|
{
|
|
icon: ShieldCheck,
|
|
number: '03',
|
|
title: 'Kwaliteit vóór resultaat',
|
|
description:
|
|
'Vergelijk referentie- en kandidaatgegevens, controleer bewijs en exporteer pas wanneer de context klopt.',
|
|
},
|
|
]
|
|
|
|
const workflowSteps = [
|
|
['Selecteer', 'Kies een officiële grens of teken zelf een gebied.'],
|
|
['Controleer', 'Bekijk dekking, bronkwaliteit en toepasselijke beperkingen.'],
|
|
['Analyseer', 'Meet toestand, evolutie of modelresultaten op de kaart.'],
|
|
['Onderbouw', 'Bewaar controleerbare resultaten en provenance.'],
|
|
]
|
|
|
|
export function LandingPage({
|
|
onAuthenticated,
|
|
serviceError = null,
|
|
guestAccessEnabled = false,
|
|
}: LandingPageProps): JSX.Element {
|
|
const [username, setUsername] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [pendingAction, setPendingAction] = useState<'operator' | 'guest' | null>(null)
|
|
const [authError, setAuthError] = useState<string | null>(null)
|
|
const [showPassword, setShowPassword] = useState(false)
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
const usernameRef = useRef<HTMLInputElement | null>(null)
|
|
const accessPanelRef = useRef<HTMLElement | null>(null)
|
|
const busy = pendingAction !== null
|
|
|
|
useEffect(() => {
|
|
document.body.classList.add('landing-body')
|
|
return () => document.body.classList.remove('landing-body')
|
|
}, [])
|
|
|
|
const submitLogin = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault()
|
|
setPendingAction('operator')
|
|
setAuthError(null)
|
|
try {
|
|
const session = await login(username.trim(), password)
|
|
onAuthenticated(session)
|
|
} catch (error) {
|
|
setAuthError(formatAuthError(error, 'Aanmelden is momenteel niet gelukt. Controleer de verbinding en probeer opnieuw.'))
|
|
} finally {
|
|
setPendingAction(null)
|
|
}
|
|
}
|
|
|
|
const submitGuestLogin = async () => {
|
|
setMenuOpen(false)
|
|
setPendingAction('guest')
|
|
setAuthError(null)
|
|
accessPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
try {
|
|
const session = await loginAsGuest()
|
|
onAuthenticated(session)
|
|
} catch (error) {
|
|
setAuthError(formatAuthError(error, 'De gastdemo kon niet worden voorbereid. Probeer het over enkele ogenblikken opnieuw.'))
|
|
} finally {
|
|
setPendingAction(null)
|
|
}
|
|
}
|
|
|
|
const focusLogin = () => {
|
|
setMenuOpen(false)
|
|
accessPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
window.requestAnimationFrame(() => usernameRef.current?.focus())
|
|
}
|
|
|
|
return (
|
|
<div className="landing-page">
|
|
<a className="landing-skip-link" href="#login-panel">Ga naar toegang</a>
|
|
|
|
<header className="landing-header">
|
|
<a className="landing-brand" href="#top" aria-label="GeoIntel Atlas startpagina">
|
|
<GeoIntelMark className="landing-brand-mark" />
|
|
<span className="landing-brand-copy">
|
|
<strong>GeoIntel</strong>
|
|
<small>Atlas Workbench</small>
|
|
</span>
|
|
</a>
|
|
|
|
<button
|
|
className="landing-menu-toggle"
|
|
type="button"
|
|
aria-label={menuOpen ? 'Navigatie sluiten' : 'Navigatie openen'}
|
|
aria-expanded={menuOpen}
|
|
aria-controls="landing-navigation"
|
|
onClick={() => setMenuOpen((current) => !current)}
|
|
>
|
|
{menuOpen ? <X aria-hidden="true" /> : <Menu aria-hidden="true" />}
|
|
</button>
|
|
|
|
<nav
|
|
id="landing-navigation"
|
|
className={menuOpen ? 'landing-nav landing-nav-open' : 'landing-nav'}
|
|
aria-label="Landingspagina"
|
|
>
|
|
<a href="#mogelijkheden" onClick={() => setMenuOpen(false)}>Mogelijkheden</a>
|
|
<a href="#werkproces" onClick={() => setMenuOpen(false)}>Werkproces</a>
|
|
<a href="#kwaliteit" onClick={() => setMenuOpen(false)}>Kwaliteit</a>
|
|
</nav>
|
|
|
|
<div className="landing-header-actions">
|
|
{guestAccessEnabled ? (
|
|
<button className="landing-header-guest" type="button" onClick={submitGuestLogin} disabled={busy}>
|
|
Gastdemo
|
|
</button>
|
|
) : null}
|
|
<button className="landing-header-login" type="button" onClick={focusLogin} disabled={busy}>
|
|
Inloggen
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main id="top">
|
|
<section className="landing-hero" aria-labelledby="landing-title">
|
|
<div className="landing-hero-orbit landing-hero-orbit-one" aria-hidden="true" />
|
|
<div className="landing-hero-orbit landing-hero-orbit-two" aria-hidden="true" />
|
|
|
|
<div className="landing-hero-content">
|
|
<div className="landing-hero-copy">
|
|
<p className="landing-kicker">
|
|
<Sparkles aria-hidden="true" /> GeoAI-workbench voor België en de Noordzee
|
|
</p>
|
|
<h1 id="landing-title">
|
|
Van kaartlaag naar <span>aantoonbaar inzicht.</span>
|
|
</h1>
|
|
<p className="landing-lead">
|
|
GeoIntel brengt officiële bronnen, ruimtelijke analyse, AI-resultaten en kwaliteitscontrole samen in één professionele werkomgeving. Niet alleen zien wat er op de kaart staat, maar ook weten waarop het resultaat steunt.
|
|
</p>
|
|
|
|
<div className="landing-hero-actions">
|
|
{guestAccessEnabled ? (
|
|
<button className="landing-primary-action" type="button" onClick={submitGuestLogin} disabled={busy}>
|
|
<UserRound aria-hidden="true" />
|
|
{pendingAction === 'guest' ? 'Gastdemo voorbereiden…' : 'Verkennen als gast'}
|
|
</button>
|
|
) : (
|
|
<button className="landing-primary-action" type="button" onClick={focusLogin}>
|
|
<LogIn aria-hidden="true" /> Open de workbench
|
|
</button>
|
|
)}
|
|
<a className="landing-secondary-action" href="#mogelijkheden">
|
|
Bekijk mogelijkheden <ArrowRight aria-hidden="true" />
|
|
</a>
|
|
</div>
|
|
|
|
<div className="landing-proof-row" aria-label="Kernkwaliteiten">
|
|
<div><Check aria-hidden="true" /><span><strong>Officiële bronnen</strong><small>per rechtsgebied</small></span></div>
|
|
<div><Check aria-hidden="true" /><span><strong>Meetbare kwaliteit</strong><small>vóór export</small></span></div>
|
|
<div><Check aria-hidden="true" /><span><strong>Volledige provenance</strong><small>bij ieder resultaat</small></span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<section
|
|
className="landing-access-card"
|
|
id="login-panel"
|
|
ref={accessPanelRef}
|
|
aria-labelledby="access-title"
|
|
>
|
|
<div className="landing-access-preview" aria-hidden="true">
|
|
<div className="landing-preview-toolbar">
|
|
<span><i /> Live werkcontext</span>
|
|
<span>België · Noordzee</span>
|
|
</div>
|
|
<div className="landing-preview-focus">
|
|
<span className="landing-preview-pin"><MapPinned /></span>
|
|
<div>
|
|
<small>Actieve analyse</small>
|
|
<strong>Gebouwen & referentiedata</strong>
|
|
<span>Brondekking gecontroleerd</span>
|
|
</div>
|
|
</div>
|
|
<div className="landing-preview-metrics">
|
|
<span><strong>4</strong><small>rechtsgebieden</small></span>
|
|
<span><strong>QA</strong><small>bewijs zichtbaar</small></span>
|
|
<span><strong>GIS</strong><small>exporteerbaar</small></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="landing-access-body">
|
|
<div className="landing-access-heading">
|
|
<span className="landing-login-icon" aria-hidden="true"><LockKeyhole /></span>
|
|
<div>
|
|
<p>Veilige toegang</p>
|
|
<h2 id="access-title">Open de GeoIntel-workbench</h2>
|
|
<span>Meld u aan als operator of start een beperkte demosessie.</span>
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={submitLogin} aria-busy={pendingAction === 'operator'}>
|
|
<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={busy}
|
|
required
|
|
/>
|
|
|
|
<label htmlFor="login-password">Wachtwoord</label>
|
|
<div className="landing-password-field">
|
|
<input
|
|
id="login-password"
|
|
name="password"
|
|
type={showPassword ? 'text' : 'password'}
|
|
autoComplete="current-password"
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
disabled={busy}
|
|
required
|
|
/>
|
|
<button
|
|
type="button"
|
|
aria-label={showPassword ? 'Wachtwoord verbergen' : 'Wachtwoord tonen'}
|
|
aria-pressed={showPassword}
|
|
onClick={() => setShowPassword((current) => !current)}
|
|
disabled={busy}
|
|
>
|
|
{showPassword ? <EyeOff aria-hidden="true" /> : <Eye aria-hidden="true" />}
|
|
</button>
|
|
</div>
|
|
|
|
{authError || serviceError ? (
|
|
<p className="landing-login-error" role="alert">{authError ?? serviceError}</p>
|
|
) : null}
|
|
|
|
<button
|
|
className="landing-operator-submit"
|
|
type="submit"
|
|
disabled={busy || !username.trim() || !password}
|
|
>
|
|
<LogIn aria-hidden="true" />
|
|
{pendingAction === 'operator' ? 'Veilig aanmelden…' : 'Inloggen als operator'}
|
|
</button>
|
|
</form>
|
|
|
|
{guestAccessEnabled ? (
|
|
<div className="landing-access-divider"><span>of verken zonder account</span></div>
|
|
) : null}
|
|
|
|
{guestAccessEnabled ? (
|
|
<div className="landing-guest-access">
|
|
<span className="landing-guest-icon" aria-hidden="true"><UserRound /></span>
|
|
<div>
|
|
<strong>Gastmodus</strong>
|
|
<p>Open een tijdelijke, alleen-lezen demowerkruimte met voorbeelddata en een vooraf uitgevoerde kwaliteitscontrole.</p>
|
|
</div>
|
|
<button type="button" onClick={submitGuestLogin} disabled={busy}>
|
|
{pendingAction === 'guest' ? 'Demo voorbereiden…' : 'Als gast verkennen'}
|
|
<ChevronRight aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
|
|
<p className="landing-session-note">
|
|
<ShieldCheck aria-hidden="true" /> HttpOnly-sessie · geen wachtwoordopslag in de browser
|
|
</p>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="landing-capabilities" id="mogelijkheden" aria-labelledby="capabilities-title">
|
|
<div className="landing-section-heading">
|
|
<p>Gebouwd voor controleerbare analyse</p>
|
|
<h2 id="capabilities-title">Geen los dashboard, maar één samenhangende GIS-keten</h2>
|
|
<span>De interface volgt de manier waarop een onderbouwde ruimtelijke analyse werkelijk tot stand komt.</span>
|
|
</div>
|
|
|
|
<div className="landing-capability-grid">
|
|
{capabilityItems.map(({ icon: Icon, number, title, description }) => (
|
|
<article key={title} className="landing-capability">
|
|
<div className="landing-capability-topline">
|
|
<span className="landing-capability-icon" aria-hidden="true"><Icon /></span>
|
|
<small>{number}</small>
|
|
</div>
|
|
<h3>{title}</h3>
|
|
<p>{description}</p>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="landing-workflow" id="werkproces" aria-labelledby="workflow-title">
|
|
<div className="landing-workflow-visual">
|
|
<div className="landing-workflow-map-image" aria-hidden="true" />
|
|
<div className="landing-workflow-overlay">
|
|
<p>Kaart als werkomgeving</p>
|
|
<h2 id="workflow-title">Van selectie naar een resultaat dat u kunt verdedigen</h2>
|
|
<span>Bron, gebied, meetmoment en kwaliteitsbewijs blijven samen zichtbaar.</span>
|
|
</div>
|
|
<div className="landing-workflow-floating" aria-hidden="true">
|
|
<MousePointer2 />
|
|
<span><small>Selectie gecontroleerd</small><strong>2,14 km² · 186 objecten</strong></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="landing-workflow-content">
|
|
<p className="landing-workflow-eyebrow">Een helder werkproces</p>
|
|
<h2>Vier stappen, zonder de technische context te verliezen</h2>
|
|
<div className="landing-workflow-steps">
|
|
{workflowSteps.map(([title, description], index) => (
|
|
<article key={title}>
|
|
<span>{String(index + 1).padStart(2, '0')}</span>
|
|
<div><h3>{title}</h3><p>{description}</p></div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
<div className="landing-quality-callout" id="kwaliteit">
|
|
<BrainCircuit aria-hidden="true" />
|
|
<div>
|
|
<strong>AI blijft een gecontroleerde analysemethode</strong>
|
|
<p>Modelversie, confidence, referentiedata en validatiegrenzen blijven zichtbaar; ontbrekende configuratie wordt niet als succes voorgesteld.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<footer className="landing-footer">
|
|
<div className="landing-footer-brand">
|
|
<GeoIntelMark className="landing-footer-mark" />
|
|
<div><strong>GeoIntel Atlas Workbench</strong><p>Operationele GIS-analyse voor België en de Belgische Noordzee.</p></div>
|
|
</div>
|
|
<div className="landing-footer-ownership">
|
|
<ItWorxSignature />
|
|
<p>© {new Date().getFullYear()} GeoIntel · Professionele operator- en demonstratieomgeving</p>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
)
|
|
}
|