Update
This commit is contained in:
+105
-38
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { CircleAlert, LogOut, UserRound } from 'lucide-react'
|
||||
import { CircleAlert, LogOut, ShieldCheck, UserRound } from 'lucide-react'
|
||||
import '@fontsource/manrope/latin-500.css'
|
||||
import '@fontsource/manrope/latin-600.css'
|
||||
import '@fontsource/manrope/latin-700.css'
|
||||
@@ -10,6 +10,7 @@ import './styles/app.css'
|
||||
import './styles/premium.css'
|
||||
import './styles/atlas-workbench.css'
|
||||
import './styles/atlas-premium-v2.css'
|
||||
import './styles/professionalization.css'
|
||||
import { LandingPage } from './components/auth/LandingPage'
|
||||
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
|
||||
import { GeoAssistantPanel } from './components/assistant/GeoAssistantPanel'
|
||||
@@ -86,20 +87,39 @@ const workspaceNavGroups: WorkspaceNavigationGroup[] = [
|
||||
{ label: 'Beheer', keys: ['overview', 'system'] },
|
||||
]
|
||||
|
||||
const guestWorkspaceKeys = new Set<WorkspaceKey>(['map', 'analysis'])
|
||||
const guestWorkspaceGroups: WorkspaceNavigationGroup[] = [
|
||||
{ label: 'Demowerkruimte', keys: ['map', 'analysis'] },
|
||||
]
|
||||
|
||||
interface WorkbenchAppProps {
|
||||
username: string | null
|
||||
accessMode: 'open' | 'operator' | 'guest'
|
||||
loggingOut: boolean
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JSX.Element {
|
||||
function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchAppProps): JSX.Element {
|
||||
const isGuest = accessMode === 'guest'
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map')
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false)
|
||||
const [guestDemoReady, setGuestDemoReady] = useState(!isGuest)
|
||||
const guestDemoStartedRef = useRef(false)
|
||||
const visibleWorkspaceItems = useMemo(
|
||||
() => isGuest ? workspaceNavItems.filter((item) => guestWorkspaceKeys.has(item.key)) : workspaceNavItems,
|
||||
[isGuest],
|
||||
)
|
||||
const visibleWorkspaceGroups = isGuest ? guestWorkspaceGroups : workspaceNavGroups
|
||||
const [mapContentMode, setMapContentMode] = useState<'dataset' | 'analysis'>('dataset')
|
||||
const [mapContextSourceLabel, setMapContextSourceLabel] = useState<string | null>(null)
|
||||
const [mapContextLayerLabel, setMapContextLayerLabel] = useState<string | null>(null)
|
||||
const workbenchMainRef = useRef<HTMLElement | null>(null)
|
||||
const previousWorkspaceRef = useRef<WorkspaceKey>(activeWorkspace)
|
||||
useEffect(() => {
|
||||
if (isGuest && !guestWorkspaceKeys.has(activeWorkspace)) {
|
||||
setActiveWorkspace('map')
|
||||
}
|
||||
}, [activeWorkspace, isGuest])
|
||||
useEffect(() => {
|
||||
workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })
|
||||
if (previousWorkspaceRef.current !== activeWorkspace) {
|
||||
@@ -566,6 +586,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
||||
demoWorkflowMessage,
|
||||
loadDemoWorkflow,
|
||||
} = useDemoWorkflow({
|
||||
restrictedMode: isGuest,
|
||||
loadProjects,
|
||||
loadProjectData,
|
||||
loadDatasetDetails,
|
||||
@@ -586,7 +607,15 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
||||
setErrorMessage,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGuest || guestDemoStartedRef.current) return
|
||||
guestDemoStartedRef.current = true
|
||||
setGuestDemoReady(false)
|
||||
void loadDemoWorkflow().finally(() => setGuestDemoReady(true))
|
||||
}, [isGuest, loadDemoWorkflow])
|
||||
|
||||
useWorkbenchBootstrap({
|
||||
restrictedMode: isGuest,
|
||||
selectedProjectId,
|
||||
selectedDetectionRunId,
|
||||
detectionClassFilter,
|
||||
@@ -613,7 +642,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
||||
})
|
||||
|
||||
const selectedArea = areas.find((area) => area.id === selectedMapAreaId) ?? null
|
||||
const activeWorkspaceItem = workspaceNavItems.find((item) => item.key === activeWorkspace) ?? workspaceNavItems[0]
|
||||
const activeWorkspaceItem = visibleWorkspaceItems.find((item) => item.key === activeWorkspace) ?? visibleWorkspaceItems[0]
|
||||
const mapLayerSourceLabel = useMemo(() => {
|
||||
if (analysisMapLayerActive && changeDetectionResult?.geojson) {
|
||||
return 'Veranderingsanalyse'
|
||||
@@ -687,6 +716,10 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
||||
setActiveWorkspace('exports')
|
||||
}
|
||||
const openWorkflowGuidanceStep = (target: WorkspaceKey) => {
|
||||
if (isGuest && !guestWorkspaceKeys.has(target)) {
|
||||
setActiveWorkspace(target === 'exports' ? 'analysis' : 'map')
|
||||
return
|
||||
}
|
||||
if (target === 'map' && availableMapDatasets.length > 0 && !mapFeatureCollection) {
|
||||
openDatasetInMap(availableMapDatasets[0])
|
||||
return
|
||||
@@ -781,7 +814,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
||||
: 'Geen actieve laag'
|
||||
|
||||
return (
|
||||
<div className="app-shell workbench-shell">
|
||||
<div className={isGuest ? "app-shell workbench-shell workbench-shell-guest" : "app-shell workbench-shell"}>
|
||||
<a
|
||||
className="skip-link"
|
||||
href="#workspace-main"
|
||||
@@ -805,8 +838,8 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
||||
<div className="workbench-layout">
|
||||
<WorkbenchNavigation
|
||||
activeWorkspace={activeWorkspace}
|
||||
groups={workspaceNavGroups}
|
||||
items={workspaceNavItems}
|
||||
groups={visibleWorkspaceGroups}
|
||||
items={visibleWorkspaceItems}
|
||||
onSelect={setActiveWorkspace}
|
||||
/>
|
||||
|
||||
@@ -838,18 +871,39 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
||||
<span aria-hidden="true" />
|
||||
<strong>{workspaceDataLoading ? 'Laden' : errorMessage ? 'Aandacht' : 'Gereed'}</strong>
|
||||
</div>
|
||||
{username ? (
|
||||
<div className="context-account" aria-label="Aangemelde gebruiker">
|
||||
<UserRound aria-hidden="true" />
|
||||
<span title={username}>{username}</span>
|
||||
{username || isGuest ? (
|
||||
<div
|
||||
className={isGuest ? 'context-account context-account-guest' : 'context-account'}
|
||||
aria-label={isGuest ? 'Tijdelijke gastensessie' : 'Aangemelde gebruiker'}
|
||||
>
|
||||
{isGuest ? <ShieldCheck aria-hidden="true" /> : <UserRound aria-hidden="true" />}
|
||||
<span title={username ?? undefined}>{isGuest ? 'Gastmodus' : username}</span>
|
||||
<button type="button" onClick={onLogout} disabled={loggingOut}>
|
||||
<LogOut aria-hidden="true" />
|
||||
<span>{loggingOut ? 'Uitloggen…' : 'Uitloggen'}</span>
|
||||
<span>{loggingOut ? 'Afsluiten…' : isGuest ? 'Demo afsluiten' : 'Uitloggen'}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{isGuest ? (
|
||||
<div className="guest-mode-banner" role="status">
|
||||
<ShieldCheck aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Tijdelijke demowerkruimte</strong>
|
||||
<span>U verkent vooraf geladen voorbeelddata. Wijzigingen, nieuwe analyses en operatorfuncties zijn uitgeschakeld.</span>
|
||||
</div>
|
||||
<span className="guest-mode-badge">Alleen-lezen</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isGuest && (!guestDemoReady || loadingDemoWorkflow) ? (
|
||||
<div className="guest-demo-loading" role="status" aria-live="polite">
|
||||
<span aria-hidden="true" />
|
||||
<strong>Demodata en kwaliteitsbewijs worden voorbereid…</strong>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="workbench-content">
|
||||
|
||||
<main
|
||||
@@ -867,7 +921,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
||||
<WorkspaceSignal workspace={activeWorkspace} />
|
||||
<div className="workspace-heading-actions">
|
||||
<p>{activeWorkspaceItem.description}</p>
|
||||
{activeWorkspace !== 'overview' ? (
|
||||
{!isGuest && activeWorkspace !== 'overview' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={inspectorOpen ? 'inspector-toggle inspector-toggle-active' : 'inspector-toggle'}
|
||||
@@ -950,6 +1004,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
||||
|
||||
<div className="workspace-persistent-map" hidden={activeWorkspace !== 'map'}>
|
||||
<MapWorkspace
|
||||
readOnly={isGuest}
|
||||
selectedProjectId={selectedProjectId}
|
||||
projects={projects}
|
||||
areas={areas}
|
||||
@@ -1045,8 +1100,8 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
||||
onRefreshProjectData={() => (
|
||||
selectedProjectId ? loadProjectData(selectedProjectId) : Promise.resolve(null)
|
||||
)}
|
||||
onOpenAssistant={() => setActiveWorkspace('assistant')}
|
||||
onOpenExports={() => setActiveWorkspace('exports')}
|
||||
onOpenAssistant={() => setActiveWorkspace(isGuest ? 'analysis' : 'assistant')}
|
||||
onOpenExports={() => setActiveWorkspace(isGuest ? 'analysis' : 'exports')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1063,29 +1118,39 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
||||
evidenceLoading={qualityEvidenceLoading}
|
||||
evidenceError={qualityEvidenceError}
|
||||
onOpenMapWorkspace={() => setActiveWorkspace('map')}
|
||||
onOpenAnalysisWorkspace={() => setActiveWorkspace('ai')}
|
||||
onOpenAnalysisWorkspace={() => setActiveWorkspace(isGuest ? 'map' : 'ai')}
|
||||
/>
|
||||
<details className="secondary-analysis-disclosure">
|
||||
<summary>
|
||||
<span>Historische vectorlagen vergelijken</span>
|
||||
<strong>Geavanceerd</strong>
|
||||
</summary>
|
||||
<ChangeDetectionPanel
|
||||
vectorDatasets={availableVectorDatasets}
|
||||
sourceDatasetId={changeSourceDatasetId}
|
||||
targetDatasetId={changeTargetDatasetId}
|
||||
iouThreshold={changeIouThreshold}
|
||||
includeUnchanged={changeIncludeUnchanged}
|
||||
running={runningChangeDetection}
|
||||
result={changeDetectionResult}
|
||||
error={changeDetectionError}
|
||||
onSourceDatasetChange={setChangeSourceDatasetId}
|
||||
onTargetDatasetChange={setChangeTargetDatasetId}
|
||||
onIouThresholdChange={setChangeIouThreshold}
|
||||
onIncludeUnchangedChange={setChangeIncludeUnchanged}
|
||||
onRun={runChangeDetection}
|
||||
/>
|
||||
</details>
|
||||
{!isGuest ? (
|
||||
<details className="secondary-analysis-disclosure">
|
||||
<summary>
|
||||
<span>Historische vectorlagen vergelijken</span>
|
||||
<strong>Geavanceerd</strong>
|
||||
</summary>
|
||||
<ChangeDetectionPanel
|
||||
vectorDatasets={availableVectorDatasets}
|
||||
sourceDatasetId={changeSourceDatasetId}
|
||||
targetDatasetId={changeTargetDatasetId}
|
||||
iouThreshold={changeIouThreshold}
|
||||
includeUnchanged={changeIncludeUnchanged}
|
||||
running={runningChangeDetection}
|
||||
result={changeDetectionResult}
|
||||
error={changeDetectionError}
|
||||
onSourceDatasetChange={setChangeSourceDatasetId}
|
||||
onTargetDatasetChange={setChangeTargetDatasetId}
|
||||
onIouThresholdChange={setChangeIouThreshold}
|
||||
onIncludeUnchangedChange={setChangeIncludeUnchanged}
|
||||
onRun={runChangeDetection}
|
||||
/>
|
||||
</details>
|
||||
) : (
|
||||
<div className="guest-readonly-card">
|
||||
<ShieldCheck aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Controleerbaar voorbeeldresultaat</strong>
|
||||
<span>Deze kwaliteitsweergave gebruikt de vooraf berekende demo. Nieuwe vergelijkingen zijn alleen beschikbaar voor operators.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1250,7 +1315,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
{inspectorOpen ? (
|
||||
{inspectorOpen && !isGuest ? (
|
||||
<aside className="workbench-inspector" id="workbench-inspector" aria-label="Details van de huidige selectie">
|
||||
<WorkbenchInspector
|
||||
selectedProject={selectedProject}
|
||||
@@ -1357,6 +1422,7 @@ function App(): JSX.Element {
|
||||
return (
|
||||
<LandingPage
|
||||
serviceError={sessionError}
|
||||
guestAccessEnabled={session.guest_access_enabled}
|
||||
onAuthenticated={handleAuthenticated}
|
||||
/>
|
||||
)
|
||||
@@ -1364,7 +1430,8 @@ function App(): JSX.Element {
|
||||
|
||||
return (
|
||||
<WorkbenchApp
|
||||
username={session.authentication_required ? session.username : null}
|
||||
username={session.username}
|
||||
accessMode={session.role ?? (session.authentication_required ? 'operator' : 'open')}
|
||||
loggingOut={loggingOut}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import type { ExpressionSpecification } from '@maplibre/maplibre-gl-style-spec'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { NATIONAL_MAP_CENTER, NATIONAL_MAP_ZOOM } from '../config/primaryFocus'
|
||||
import { featureCollectionBounds } from '../lib/geojsonBounds'
|
||||
@@ -53,7 +54,7 @@ const DEFAULT_ROAD_BASEMAP_STYLE: maplibregl.StyleSpecification = {
|
||||
],
|
||||
}
|
||||
|
||||
function datasetFillColor(fallbackColor: string): maplibregl.ExpressionSpecification {
|
||||
function datasetFillColor(fallbackColor: string): ExpressionSpecification {
|
||||
return [
|
||||
'case',
|
||||
['==', ['get', 'layer_type'], 'municipality_boundary'],
|
||||
@@ -74,7 +75,7 @@ function datasetFillColor(fallbackColor: string): maplibregl.ExpressionSpecifica
|
||||
]
|
||||
}
|
||||
|
||||
function datasetLineColor(fallbackColor: string): maplibregl.ExpressionSpecification {
|
||||
function datasetLineColor(fallbackColor: string): ExpressionSpecification {
|
||||
return [
|
||||
'case',
|
||||
['==', ['get', 'layer_type'], 'municipality_boundary'],
|
||||
@@ -689,7 +690,7 @@ function GeoMap({
|
||||
'false_negative',
|
||||
'#d97706',
|
||||
'#475569',
|
||||
] as maplibregl.ExpressionSpecification
|
||||
] as ExpressionSpecification
|
||||
map.addLayer({
|
||||
id: 'qa-evidence-fill',
|
||||
type: 'fill',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/dom'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { AreaRead, DatasetCreateResponse, ProjectRead } from '../types'
|
||||
import { WorkbenchStatusStrip } from './WorkbenchStatusStrip'
|
||||
|
||||
@@ -1,48 +1,83 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LandingPage } from './LandingPage'
|
||||
import { login } from '../../services/api/auth'
|
||||
|
||||
import { login, loginAsGuest } from '../../services/api/auth'
|
||||
|
||||
vi.mock('../../services/api/auth', () => ({
|
||||
login: vi.fn(),
|
||||
loginAsGuest: vi.fn(),
|
||||
}))
|
||||
|
||||
const operatorSession = {
|
||||
authentication_required: true,
|
||||
authenticated: true,
|
||||
username: 'operator',
|
||||
expires_at: '2026-07-27T20:00:00Z',
|
||||
role: 'operator' as const,
|
||||
guest_access_enabled: true,
|
||||
guest_project_id: null,
|
||||
}
|
||||
|
||||
const guestSession = {
|
||||
authentication_required: true,
|
||||
authenticated: true,
|
||||
username: 'Gast',
|
||||
expires_at: '2026-07-27T20:00:00Z',
|
||||
role: 'guest' as const,
|
||||
guest_access_enabled: true,
|
||||
guest_project_id: '00000000-0000-0000-0000-000000000123',
|
||||
}
|
||||
|
||||
describe('LandingPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(login).mockReset()
|
||||
vi.mocked(loginAsGuest).mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('shows the Stitch-derived landing content and submits the real login flow', async () => {
|
||||
it('presents the professionalised landing page and submits the operator login', 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} />)
|
||||
vi.mocked(login).mockResolvedValue(operatorSession)
|
||||
render(<LandingPage onAuthenticated={onAuthenticated} guestAccessEnabled />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: /Operationele GIS-analyse/i })).toBeTruthy()
|
||||
expect(screen.getByRole('heading', { name: /Van kaartlaag naar aantoonbaar inzicht/i })).toBeTruthy()
|
||||
expect(screen.getByRole('img', { name: 'ITWorx.tech' })).toBeTruthy()
|
||||
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: 'operator' } })
|
||||
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: ' operator ' } })
|
||||
fireEvent.change(screen.getByLabelText('Wachtwoord'), { target: { value: 'correct' } })
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Inloggen' })[1])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Inloggen als operator' }))
|
||||
|
||||
await waitFor(() => expect(login).toHaveBeenCalledWith('operator', 'correct'))
|
||||
expect(onAuthenticated).toHaveBeenCalledWith(expect.objectContaining({ authenticated: true }))
|
||||
expect(onAuthenticated).toHaveBeenCalledWith(operatorSession)
|
||||
})
|
||||
|
||||
it('surfaces an authentication error without entering the workbench', async () => {
|
||||
it('opens a guest session without asking for credentials', async () => {
|
||||
const onAuthenticated = vi.fn()
|
||||
vi.mocked(loginAsGuest).mockResolvedValue(guestSession)
|
||||
render(<LandingPage onAuthenticated={onAuthenticated} guestAccessEnabled />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Als gast verkennen/i }))
|
||||
|
||||
await waitFor(() => expect(loginAsGuest).toHaveBeenCalledOnce())
|
||||
expect(login).not.toHaveBeenCalled()
|
||||
expect(onAuthenticated).toHaveBeenCalledWith(guestSession)
|
||||
})
|
||||
|
||||
it('does not advertise guest access when the runtime has it disabled', () => {
|
||||
render(<LandingPage onAuthenticated={vi.fn()} guestAccessEnabled={false} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /gast/i })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Open de workbench' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('surfaces a useful 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])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Inloggen als operator' }))
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('Gebruikersnaam of wachtwoord is onjuist.')
|
||||
})
|
||||
|
||||
@@ -2,19 +2,23 @@ import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
ArrowRight,
|
||||
BrainCircuit,
|
||||
CheckCircle2,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Database,
|
||||
Layers3,
|
||||
Eye,
|
||||
EyeOff,
|
||||
LockKeyhole,
|
||||
LogIn,
|
||||
MapPinned,
|
||||
Menu,
|
||||
MousePointer2,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { login } from '../../services/api/auth'
|
||||
import type { AuthSession } from '../../services/api/auth'
|
||||
import { formatError } from '../../lib/formatError'
|
||||
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'
|
||||
@@ -22,42 +26,54 @@ import { ItWorxSignature } from '../brand/ItWorxSignature'
|
||||
interface LandingPageProps {
|
||||
onAuthenticated: (session: AuthSession) => void
|
||||
serviceError?: string | null
|
||||
guestAccessEnabled?: boolean
|
||||
}
|
||||
|
||||
const capabilityItems = [
|
||||
{
|
||||
icon: MapPinned,
|
||||
title: 'Heel België in beeld',
|
||||
number: '01',
|
||||
title: 'Eén kaartgerichte werkruimte',
|
||||
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',
|
||||
'Selecteer een gebied in België of de Belgische Noordzee en werk verder vanuit dezelfde ruimtelijke context.',
|
||||
},
|
||||
{
|
||||
icon: BrainCircuit,
|
||||
title: 'AI met bewijsgrenzen',
|
||||
icon: Database,
|
||||
number: '02',
|
||||
title: 'Bronnen blijven herkenbaar',
|
||||
description:
|
||||
'Voer objectdetectie uit op geschikte luchtbeelden en beoordeel resultaten tegen referentiedata met zichtbare model- en validatiegrenzen.',
|
||||
tags: ['Objectdetectie', 'Lokale assistent'],
|
||||
tone: 'secondary',
|
||||
'Autoriteit, meetmoment, dekking, CRS en beperkingen blijven zichtbaar in plaats van achter één generieke kaartlaag te verdwijnen.',
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: 'Operationele kwaliteit',
|
||||
number: '03',
|
||||
title: 'Kwaliteit vóór resultaat',
|
||||
description:
|
||||
'Elke analyse bewaart bron, meetmoment, CRS, eenheid en beperkingen. Resultaten blijven inspecteerbaar vóór export of besluitvorming.',
|
||||
tags: ['QA/QC', 'Herleidbaar'],
|
||||
tone: 'attention',
|
||||
'Vergelijk referentie- en kandidaatgegevens, controleer bewijs en exporteer pas wanneer de context klopt.',
|
||||
},
|
||||
]
|
||||
|
||||
export function LandingPage({ onAuthenticated, serviceError = null }: LandingPageProps): JSX.Element {
|
||||
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 [submitting, setSubmitting] = useState(false)
|
||||
const [loginError, setLoginError] = useState<string | null>(null)
|
||||
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')
|
||||
@@ -66,167 +82,303 @@ export function LandingPage({ onAuthenticated, serviceError = null }: LandingPag
|
||||
|
||||
const submitLogin = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setSubmitting(true)
|
||||
setLoginError(null)
|
||||
setPendingAction('operator')
|
||||
setAuthError(null)
|
||||
try {
|
||||
const session = await login(username.trim(), password)
|
||||
onAuthenticated(session)
|
||||
} catch (error) {
|
||||
setLoginError(formatError(error, 'Aanmelden is niet gelukt. Probeer het opnieuw.'))
|
||||
setAuthError(formatAuthError(error, 'Aanmelden is momenteel niet gelukt. Controleer de verbinding en probeer opnieuw.'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
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 aanmelden</a>
|
||||
<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>GeoIntel Atlas</span>
|
||||
<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 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>
|
||||
|
||||
<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>
|
||||
<button className="landing-header-login" type="button" onClick={focusLogin}>
|
||||
Inloggen
|
||||
</button>
|
||||
|
||||
<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-background" aria-hidden="true" />
|
||||
<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"><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 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">
|
||||
<a className="landing-primary-action" href="#mogelijkheden">
|
||||
<MapPinned aria-hidden="true" /> Bekijk mogelijkheden
|
||||
{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>
|
||||
<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 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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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}
|
||||
<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>
|
||||
|
||||
{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>Van bron tot besluit</p>
|
||||
<h2 id="capabilities-title">Eén werkbank, controle over de hele keten</h2>
|
||||
<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, title, description, tags, tone }) => (
|
||||
<article key={title} className={`landing-capability landing-capability-${tone}`}>
|
||||
<span className="landing-capability-icon" aria-hidden="true"><Icon /></span>
|
||||
{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>
|
||||
<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-visual">
|
||||
<div className="landing-workflow-map-image" aria-hidden="true" />
|
||||
<div>
|
||||
<div className="landing-workflow-overlay">
|
||||
<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>
|
||||
<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-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 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><strong>GeoIntel Atlas Workbench</strong><p>Operationele GIS-analyse voor België en de Belgische Noordzee.</p></div>
|
||||
<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 · Interne operatoromgeving</p>
|
||||
<p>© {new Date().getFullYear()} GeoIntel · Professionele operator- en demonstratieomgeving</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -697,6 +697,7 @@ function floodScenarioLabel(dataset: DatasetCreateResponse): string {
|
||||
}
|
||||
|
||||
interface MapWorkspaceProps {
|
||||
readOnly?: boolean
|
||||
selectedProjectId: string | null
|
||||
projects: ProjectRead[]
|
||||
areas: AreaRead[]
|
||||
@@ -791,6 +792,7 @@ interface MapWorkspaceProps {
|
||||
}
|
||||
|
||||
export function MapWorkspace({
|
||||
readOnly = false,
|
||||
selectedProjectId,
|
||||
projects,
|
||||
areas,
|
||||
@@ -884,6 +886,9 @@ export function MapWorkspace({
|
||||
onOpenExports,
|
||||
}: MapWorkspaceProps): JSX.Element {
|
||||
const [advancedMode, setAdvancedMode] = useState(false)
|
||||
useEffect(() => {
|
||||
if (readOnly && advancedMode) setAdvancedMode(false)
|
||||
}, [advancedMode, readOnly])
|
||||
const [activeThemeId, setActiveThemeId] = useState<DataThemeId>(() => {
|
||||
const selectedDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null
|
||||
return themeIdForDataset(selectedDataset) ?? 'buildings'
|
||||
@@ -2213,27 +2218,39 @@ export function MapWorkspace({
|
||||
Evolutie
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-action geo-explorer-advanced"
|
||||
type="button"
|
||||
onClick={() => setAdvancedMode(true)}
|
||||
aria-expanded={advancedMode}
|
||||
aria-controls="geo-advanced-workbench"
|
||||
aria-label="Geavanceerde werkbank"
|
||||
title="Geavanceerde werkbank"
|
||||
>
|
||||
<SlidersHorizontal aria-hidden="true" />
|
||||
<span>Geavanceerde werkbank</span>
|
||||
</button>
|
||||
{!readOnly ? (
|
||||
<button
|
||||
className="secondary-action geo-explorer-advanced"
|
||||
type="button"
|
||||
onClick={() => setAdvancedMode(true)}
|
||||
aria-expanded={advancedMode}
|
||||
aria-controls="geo-advanced-workbench"
|
||||
aria-label="Geavanceerde werkbank"
|
||||
title="Geavanceerde werkbank"
|
||||
>
|
||||
<SlidersHorizontal aria-hidden="true" />
|
||||
<span>Geavanceerde werkbank</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MunicipalitySearch
|
||||
projectId={selectedProjectId}
|
||||
activeArea={selectedMapArea ?? null}
|
||||
disabled={workspaceLoading}
|
||||
onActivate={onActivateMunicipality}
|
||||
/>
|
||||
{readOnly ? (
|
||||
<div className="geo-guest-preview-note" role="note">
|
||||
<MapPinned aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Interactieve demo met bewaarde voorbeelddata</strong>
|
||||
<span>U kunt kaartlagen verkennen, een selectie meten en kwaliteitsbewijs bekijken. Nieuwe gebieden, bronimports en bewaarde analyses zijn uitgeschakeld.</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MunicipalitySearch
|
||||
projectId={selectedProjectId}
|
||||
activeArea={selectedMapArea ?? null}
|
||||
disabled={workspaceLoading}
|
||||
onActivate={onActivateMunicipality}
|
||||
/>
|
||||
)}
|
||||
|
||||
{workspaceLoading ? (
|
||||
<div className="geo-bootstrap-status" role="status" aria-live="polite">
|
||||
@@ -2266,6 +2283,8 @@ export function MapWorkspace({
|
||||
<small>
|
||||
{workspaceLoading
|
||||
? 'Gebieden en bronnen worden geladen'
|
||||
: readOnly
|
||||
? `${municipalityAreaCount || 1} vooraf ingestelde demogrens; vrije kaartselectie blijft beschikbaar`
|
||||
: municipalityAreaCount > 0
|
||||
? `${municipalityAreaCount} geactiveerde gemeentegrenzen; vrij tekenen blijft mogelijk`
|
||||
: 'Zoek optioneel een gemeente of teken vrij op de kaart'}
|
||||
@@ -2280,7 +2299,7 @@ export function MapWorkspace({
|
||||
const temporalGroup = temporalGroups[0]
|
||||
const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2)
|
||||
const available = !workspaceLoading && (analysisMode === 'current'
|
||||
? Boolean(dataset || onDemandProduct)
|
||||
? Boolean(dataset || (!readOnly && onDemandProduct))
|
||||
: Boolean(dataset) && evolutionAvailable)
|
||||
const active = activeThemeId === theme.id
|
||||
const temporalRange = temporalGroup ? temporalRangeLabel(temporalGroup.items) : null
|
||||
@@ -2308,7 +2327,7 @@ export function MapWorkspace({
|
||||
: dataset
|
||||
? `${datasetAvailabilityLabel(dataset, partitions)}${onDemandProduct ? ' · zo nodig automatisch aangevuld' : ''}`
|
||||
: onDemandProduct
|
||||
? onDemandProduct.availabilityLabel
|
||||
? readOnly ? 'Niet opgenomen in deze demo' : onDemandProduct.availabilityLabel
|
||||
: 'Bron nog niet ingeladen'}
|
||||
</small>
|
||||
</span>
|
||||
@@ -2317,7 +2336,7 @@ export function MapWorkspace({
|
||||
? 'Laden'
|
||||
: analysisMode === 'evolution'
|
||||
? evolutionAvailable ? 'Tijdreeks' : dataset ? 'Alleen huidig' : 'Ontbreekt'
|
||||
: dataset ? 'Beschikbaar' : onDemandProduct ? 'Automatisch' : 'Ontbreekt'}
|
||||
: dataset ? 'Beschikbaar' : onDemandProduct ? readOnly ? 'Niet in demo' : 'Automatisch' : 'Ontbreekt'}
|
||||
</i>
|
||||
</button>
|
||||
)
|
||||
@@ -2648,7 +2667,7 @@ export function MapWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysisMode === 'current' && activeTheme.id === 'buildings' && mapSelectionBbox ? (
|
||||
{!readOnly && analysisMode === 'current' && activeTheme.id === 'buildings' && mapSelectionBbox ? (
|
||||
<div className={`geo-image-analysis geo-image-analysis-${orthophotoAnalysisStage}`}>
|
||||
<div>
|
||||
<span>Beeldanalyse</span>
|
||||
@@ -2914,21 +2933,30 @@ export function MapWorkspace({
|
||||
) : null}
|
||||
|
||||
{activeSelectionResult || temporalComparison ? (
|
||||
<div className="geo-result-next-actions" aria-label="Volgende stap">
|
||||
<span>
|
||||
<strong>Analyse klaar</strong>
|
||||
<small>Stel een vraag over dit gebied of open je bewaarde resultaten.</small>
|
||||
</span>
|
||||
<button className="primary-action" type="button" onClick={onOpenAssistant}>Stel AI-vraag</button>
|
||||
<button
|
||||
className="secondary-action"
|
||||
type="button"
|
||||
disabled={selectionExporting}
|
||||
onClick={() => void persistActiveResultAndOpenDownloads()}
|
||||
>
|
||||
{selectionExporting ? 'Resultaat bewaren…' : 'Bewaar in downloads'}
|
||||
</button>
|
||||
</div>
|
||||
readOnly ? (
|
||||
<div className="geo-result-next-actions geo-result-next-actions-readonly" aria-label="Gastresultaat">
|
||||
<span>
|
||||
<strong>Analyse klaar</strong>
|
||||
<small>Dit resultaat blijft tijdelijk in de browser. Meld u aan als operator om analyses te bewaren of verder te verwerken.</small>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="geo-result-next-actions" aria-label="Volgende stap">
|
||||
<span>
|
||||
<strong>Analyse klaar</strong>
|
||||
<small>Stel een vraag over dit gebied of open je bewaarde resultaten.</small>
|
||||
</span>
|
||||
<button className="primary-action" type="button" onClick={onOpenAssistant}>Stel AI-vraag</button>
|
||||
<button
|
||||
className="secondary-action"
|
||||
type="button"
|
||||
disabled={selectionExporting}
|
||||
onClick={() => void persistActiveResultAndOpenDownloads()}
|
||||
>
|
||||
{selectionExporting ? 'Resultaat bewaren…' : 'Bewaar in downloads'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { areasApi } from '../../services/api/areas'
|
||||
import { MunicipalitySearch } from './MunicipalitySearch'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { fireEvent, screen } from '@testing-library/dom'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ProjectAtlasIllustration } from './ProjectAtlasIllustration'
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { DatasetCreateResponse, QualityCheckRead } from '../types'
|
||||
import { formatError } from '../lib/formatError'
|
||||
|
||||
interface DemoWorkflowOptions {
|
||||
restrictedMode?: boolean
|
||||
loadProjects: (preferredProjectId?: string | null) => Promise<void>
|
||||
loadProjectData: (projectId: string) => Promise<{ datasets: DatasetCreateResponse[] } | null>
|
||||
loadDatasetDetails: (projectId: string, dataset: DatasetCreateResponse) => Promise<void>
|
||||
@@ -25,6 +26,7 @@ interface DemoWorkflowOptions {
|
||||
}
|
||||
|
||||
export function useDemoWorkflow({
|
||||
restrictedMode = false,
|
||||
loadProjects,
|
||||
loadProjectData,
|
||||
loadDatasetDetails,
|
||||
@@ -47,7 +49,7 @@ export function useDemoWorkflow({
|
||||
const [loadingDemoWorkflow, setLoadingDemoWorkflow] = useState(false)
|
||||
const [demoWorkflowMessage, setDemoWorkflowMessage] = useState<string | null>(null)
|
||||
|
||||
const loadDemoWorkflow = async () => {
|
||||
const loadDemoWorkflow = async (): Promise<boolean> => {
|
||||
setLoadingDemoWorkflow(true)
|
||||
setDemoWorkflowMessage(null)
|
||||
setErrorMessage(null)
|
||||
@@ -65,12 +67,17 @@ export function useDemoWorkflow({
|
||||
setSegmentationReferenceDatasetId(result.reference_dataset_id)
|
||||
setDemoWorkflowMessage(result.message)
|
||||
await loadProjects(result.project_id)
|
||||
const operatorOnlyLoads = restrictedMode
|
||||
? Promise.resolve()
|
||||
: Promise.all([
|
||||
loadDetectionRuns(result.project_id),
|
||||
loadSegmentationRuns(result.project_id),
|
||||
loadExports(result.project_id),
|
||||
]).then(() => undefined)
|
||||
const [projectData] = await Promise.all([
|
||||
loadProjectData(result.project_id),
|
||||
loadDetectionRuns(result.project_id),
|
||||
loadSegmentationRuns(result.project_id),
|
||||
loadQualityChecks(result.project_id),
|
||||
loadExports(result.project_id),
|
||||
operatorOnlyLoads,
|
||||
])
|
||||
const candidateDataset = projectData?.datasets.find((dataset) => dataset.id === result.candidate_dataset_id)
|
||||
const rasterDataset = projectData?.datasets.find((dataset) => dataset.id === result.raster_dataset_id)
|
||||
@@ -79,8 +86,10 @@ export function useDemoWorkflow({
|
||||
} else if (rasterDataset) {
|
||||
await loadDatasetDetails(result.project_id, rasterDataset)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
setErrorMessage(formatError(error, 'Failed to load demo workflow'))
|
||||
setErrorMessage(formatError(error, 'De demowerkruimte kon niet worden geladen.'))
|
||||
return false
|
||||
} finally {
|
||||
setLoadingDemoWorkflow(false)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatError } from '../lib/formatError'
|
||||
import { formatAuthError } from '../lib/authError'
|
||||
import { getAuthSession, logout, type AuthSession } from '../services/api/auth'
|
||||
|
||||
const signedOutSession: AuthSession = {
|
||||
@@ -7,6 +7,9 @@ const signedOutSession: AuthSession = {
|
||||
authenticated: false,
|
||||
username: null,
|
||||
expires_at: null,
|
||||
role: null,
|
||||
guest_access_enabled: false,
|
||||
guest_project_id: null,
|
||||
}
|
||||
|
||||
export function useOperatorSession() {
|
||||
@@ -26,7 +29,7 @@ export function useOperatorSession() {
|
||||
.catch((error) => {
|
||||
if (active) {
|
||||
setSession(signedOutSession)
|
||||
setSessionError(formatError(error, 'De aanmeldservice is tijdelijk niet bereikbaar.'))
|
||||
setSessionError(formatAuthError(error, 'De aanmeldservice is tijdelijk niet bereikbaar. Probeer het over enkele ogenblikken opnieuw.'))
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
@@ -36,7 +39,10 @@ export function useOperatorSession() {
|
||||
|
||||
useEffect(() => {
|
||||
const expireSession = () => {
|
||||
setSession(signedOutSession)
|
||||
setSession((current) => ({
|
||||
...signedOutSession,
|
||||
guest_access_enabled: current?.guest_access_enabled ?? false,
|
||||
}))
|
||||
setSessionError('Uw sessie is verlopen. Meld u opnieuw aan.')
|
||||
}
|
||||
window.addEventListener('geointel:session-expired', expireSession)
|
||||
@@ -49,7 +55,7 @@ export function useOperatorSession() {
|
||||
setSession(await logout())
|
||||
setSessionError(null)
|
||||
} catch (error) {
|
||||
setSessionError(formatError(error, 'Uitloggen is niet gelukt.'))
|
||||
setSessionError(formatAuthError(error, 'Uitloggen is niet gelukt. Vernieuw de pagina en probeer opnieuw.'))
|
||||
} finally {
|
||||
setLoggingOut(false)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { waitFor } from '@testing-library/dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useWorkbenchBootstrap } from './useWorkbenchBootstrap'
|
||||
|
||||
@@ -63,4 +64,20 @@ describe('useWorkbenchBootstrap', () => {
|
||||
expect(state.loadQualityChecks).toHaveBeenCalledWith('project-1')
|
||||
expect(state.loadExports).toHaveBeenCalledWith('project-1')
|
||||
})
|
||||
|
||||
it('keeps the guest bootstrap inside the read-only demo surface', async () => {
|
||||
const state = { ...options('project-1'), restrictedMode: true }
|
||||
renderHook(() => useWorkbenchBootstrap(state))
|
||||
|
||||
await waitFor(() => expect(state.loadProjectData).toHaveBeenCalledWith('project-1'))
|
||||
expect(state.loadCapabilities).toHaveBeenCalledOnce()
|
||||
expect(state.loadQualityChecks).toHaveBeenCalledWith('project-1')
|
||||
expect(state.loadDetectionModels).not.toHaveBeenCalled()
|
||||
expect(state.loadSegmentationModels).not.toHaveBeenCalled()
|
||||
expect(state.loadDetectionRuns).not.toHaveBeenCalled()
|
||||
expect(state.loadSegmentationRuns).not.toHaveBeenCalled()
|
||||
expect(state.loadExports).not.toHaveBeenCalled()
|
||||
expect(state.loadDetectionResults).not.toHaveBeenCalled()
|
||||
expect(state.loadSegmentationResults).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ type AsyncAction = () => Promise<unknown>
|
||||
type ProjectAction = (projectId: string) => Promise<unknown>
|
||||
|
||||
interface WorkbenchBootstrapOptions {
|
||||
restrictedMode?: boolean
|
||||
selectedProjectId: string | null
|
||||
selectedDetectionRunId: string
|
||||
detectionClassFilter: string
|
||||
@@ -30,6 +31,7 @@ interface WorkbenchBootstrapOptions {
|
||||
}
|
||||
|
||||
export function useWorkbenchBootstrap({
|
||||
restrictedMode = false,
|
||||
selectedProjectId,
|
||||
selectedDetectionRunId,
|
||||
detectionClassFilter,
|
||||
@@ -57,9 +59,11 @@ export function useWorkbenchBootstrap({
|
||||
useEffect(() => {
|
||||
loadProjects().catch(() => null)
|
||||
loadCapabilities().catch(() => null)
|
||||
loadDetectionModels().catch(() => null)
|
||||
loadSegmentationModels().catch(() => null)
|
||||
}, [])
|
||||
if (!restrictedMode) {
|
||||
loadDetectionModels().catch(() => null)
|
||||
loadSegmentationModels().catch(() => null)
|
||||
}
|
||||
}, [restrictedMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId) {
|
||||
@@ -76,17 +80,21 @@ export function useWorkbenchBootstrap({
|
||||
resetSegmentationForProject()
|
||||
resetExportsForProject()
|
||||
loadProjectData(selectedProjectId).catch(() => null)
|
||||
loadDetectionRuns(selectedProjectId).catch(() => null)
|
||||
loadSegmentationRuns(selectedProjectId).catch(() => null)
|
||||
loadQualityChecks(selectedProjectId).catch(() => null)
|
||||
loadExports(selectedProjectId).catch(() => null)
|
||||
}, [selectedProjectId])
|
||||
if (!restrictedMode) {
|
||||
loadDetectionRuns(selectedProjectId).catch(() => null)
|
||||
loadSegmentationRuns(selectedProjectId).catch(() => null)
|
||||
loadExports(selectedProjectId).catch(() => null)
|
||||
}
|
||||
}, [restrictedMode, selectedProjectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (restrictedMode) return
|
||||
loadDetectionResults().catch(() => null)
|
||||
}, [selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter])
|
||||
}, [restrictedMode, selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter])
|
||||
|
||||
useEffect(() => {
|
||||
if (restrictedMode) return
|
||||
loadSegmentationResults().catch(() => null)
|
||||
}, [selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter])
|
||||
}, [restrictedMode, selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const PASSTHROUGH_CODES = new Set([
|
||||
'INVALID_CREDENTIALS',
|
||||
'LOGIN_RATE_LIMITED',
|
||||
'GUEST_ACCESS_DISABLED',
|
||||
'GUEST_READ_ONLY',
|
||||
'GUEST_PROJECT_SCOPE_REQUIRED',
|
||||
])
|
||||
|
||||
export function formatAuthError(error: unknown, fallback: string): string {
|
||||
if (!(error instanceof Error)) return fallback
|
||||
|
||||
const code = (error as { code?: string }).code
|
||||
if (code && PASSTHROUGH_CODES.has(code)) return error.message
|
||||
|
||||
if (
|
||||
code === 'REQUEST_ERROR'
|
||||
|| code === 'INTERNAL_ERROR'
|
||||
|| code === 'SESSION_CREATION_FAILED'
|
||||
|| /request failed|failed to fetch|networkerror/i.test(error.message)
|
||||
) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return error.message || fallback
|
||||
}
|
||||
@@ -5,6 +5,9 @@ export interface AuthSession {
|
||||
authenticated: boolean
|
||||
username: string | null
|
||||
expires_at: string | null
|
||||
role: 'operator' | 'guest' | null
|
||||
guest_access_enabled: boolean
|
||||
guest_project_id: string | null
|
||||
}
|
||||
|
||||
export function getAuthSession(): Promise<AuthSession> {
|
||||
@@ -15,6 +18,10 @@ export function login(username: string, password: string): Promise<AuthSession>
|
||||
return apiPost<AuthSession>('/api/v1/auth/login', { username, password })
|
||||
}
|
||||
|
||||
export function loginAsGuest(): Promise<AuthSession> {
|
||||
return apiPost<AuthSession>('/api/v1/auth/guest')
|
||||
}
|
||||
|
||||
export function logout(): Promise<AuthSession> {
|
||||
return apiPost<AuthSession>('/api/v1/auth/logout')
|
||||
}
|
||||
|
||||
+1584
-235
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* GeoIntel professionalisation pass — 2026-07-27
|
||||
*
|
||||
* This intentionally remains a small, final override layer. The historical
|
||||
* style sheets are consolidated in a separate refactor so this release does
|
||||
* not destabilise the map workbench or its responsive contracts.
|
||||
*/
|
||||
|
||||
.workbench-stage {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (min-width: 1361px) {
|
||||
.workbench-layout {
|
||||
grid-template-columns: 13.75rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workbench-sidebar {
|
||||
padding-inline: 0.72rem;
|
||||
}
|
||||
|
||||
.workbench-main:not(.workbench-main-map) {
|
||||
padding-inline: clamp(1.5rem, 2vw, 2.4rem);
|
||||
}
|
||||
}
|
||||
|
||||
.context-bar > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.context-bar strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.context-account-guest {
|
||||
border-color: #9fcfc4;
|
||||
color: #0c5e55;
|
||||
background: linear-gradient(180deg, #f7fffc, #e8f7f3);
|
||||
}
|
||||
|
||||
.context-account-guest > svg {
|
||||
color: #0d7d6f;
|
||||
}
|
||||
|
||||
.workbench-shell-guest .workbench-stage {
|
||||
grid-template-rows: var(--atlas-topbar-height) auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.guest-mode-banner {
|
||||
position: relative;
|
||||
z-index: 22;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 0.72rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
border-bottom: 1px solid #b7dbd3;
|
||||
padding: 0.68rem 1.35rem;
|
||||
color: #164d47;
|
||||
background:
|
||||
radial-gradient(circle at 12% 0%, rgba(57, 172, 151, 0.13), transparent 16rem),
|
||||
linear-gradient(90deg, #edf9f6, #f7fcfa);
|
||||
}
|
||||
|
||||
.guest-mode-banner > svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.guest-mode-banner > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.guest-mode-banner strong {
|
||||
font-family: "Manrope", sans-serif;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.guest-mode-banner span:not(.guest-mode-badge) {
|
||||
overflow: hidden;
|
||||
color: #52736d;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.guest-mode-badge {
|
||||
border: 1px solid #9bcfc3;
|
||||
border-radius: 999px;
|
||||
padding: 0.33rem 0.62rem;
|
||||
color: #0d665b;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.045em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.guest-demo-loading {
|
||||
position: absolute;
|
||||
inset: calc(var(--atlas-topbar-height) + 3.75rem) 1.25rem auto auto;
|
||||
z-index: 85;
|
||||
display: inline-flex;
|
||||
max-width: min(28rem, calc(100vw - 2rem));
|
||||
gap: 0.65rem;
|
||||
align-items: center;
|
||||
border: 1px solid #b7d9d1;
|
||||
border-radius: 0.85rem;
|
||||
padding: 0.72rem 0.9rem;
|
||||
color: #164d47;
|
||||
background: rgba(250, 255, 253, 0.97);
|
||||
box-shadow: 0 18px 44px rgba(9, 65, 57, 0.16);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.guest-demo-loading > span {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
flex: 0 0 auto;
|
||||
border: 2px solid #b5ddd4;
|
||||
border-top-color: #0f766e;
|
||||
border-radius: 999px;
|
||||
animation: geointel-guest-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.guest-demo-loading strong {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@keyframes geointel-guest-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.geo-guest-preview-note,
|
||||
.guest-readonly-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.78rem;
|
||||
align-items: start;
|
||||
border: 1px solid #b9ddd5;
|
||||
color: #174f48;
|
||||
background: linear-gradient(135deg, #eef9f6, #fbfefd);
|
||||
}
|
||||
|
||||
.geo-guest-preview-note {
|
||||
border-width: 0 0 1px;
|
||||
padding: 0.78rem 1.25rem;
|
||||
}
|
||||
|
||||
.geo-guest-preview-note > svg,
|
||||
.guest-readonly-card > svg {
|
||||
width: 1.12rem;
|
||||
height: 1.12rem;
|
||||
margin-top: 0.08rem;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.geo-guest-preview-note > div,
|
||||
.guest-readonly-card > div {
|
||||
display: grid;
|
||||
gap: 0.16rem;
|
||||
}
|
||||
|
||||
.geo-guest-preview-note strong,
|
||||
.guest-readonly-card strong {
|
||||
font-family: "Manrope", sans-serif;
|
||||
font-size: 0.79rem;
|
||||
}
|
||||
|
||||
.geo-guest-preview-note span,
|
||||
.guest-readonly-card span {
|
||||
color: #54756f;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.guest-readonly-card {
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.9rem 1rem;
|
||||
box-shadow: 0 8px 22px rgba(20, 81, 71, 0.06);
|
||||
}
|
||||
|
||||
.geo-result-next-actions-readonly {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
border-color: #b9dcd4;
|
||||
background: #f1faf7;
|
||||
}
|
||||
|
||||
.geo-result-next-actions-readonly small {
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.workbench-shell-guest .geo-explorer-header {
|
||||
min-height: 5.25rem;
|
||||
}
|
||||
|
||||
.workbench-shell-guest .geo-explorer-layout {
|
||||
grid-template-columns: minmax(13.5rem, 0.7fr) minmax(28rem, 1.75fr) minmax(18rem, 0.8fr);
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.guest-mode-banner {
|
||||
padding-inline: 0.9rem;
|
||||
}
|
||||
|
||||
.guest-mode-banner span:not(.guest-mode-badge) {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.workbench-shell-guest .geo-explorer-layout {
|
||||
grid-template-columns: 13rem minmax(24rem, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.workbench-shell-guest .workbench-stage {
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.guest-mode-banner {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
padding: 0.65rem 0.8rem;
|
||||
}
|
||||
|
||||
.guest-mode-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guest-demo-loading {
|
||||
position: fixed;
|
||||
inset: 0.75rem 0.75rem auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.guest-mode-banner > svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guest-mode-banner {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.guest-mode-banner strong {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.guest-mode-banner span:not(.guest-mode-badge) {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.geo-guest-preview-note {
|
||||
padding: 0.72rem 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.guest-demo-loading > span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user