rapidUI

Examples

Whole screens for municipal services, built only from rapidUI components. Copy the source into your app.

Building permit e-service

A resident applies for a building permit in four steps, with validation and a summary.

Lindvik Municipality
ALSigned in as Anna Lindqvist with e-ID

Apply for a building permit

Apply for a new build, an extension, a carport or a change to a facade. It takes about 20 minutes. Have your drawings ready as PDF files.

Step 1 of 4

Property

The property

Shown on your title deed or property tax notice.

Do you own the property?
Your contact details

We use these to contact you about the application.

From your e-ID.

View source components/examples/building-permit.tsx
'use client'

import {
	CarIcon,
	CheckIcon,
	ChevronLeftIcon,
	ChevronRightIcon,
	CircleAlertIcon,
	CircleCheckIcon,
	CircleDashedIcon,
	ClockIcon,
	FileTextIcon,
	HouseIcon,
	HousePlusIcon,
	LandmarkIcon,
	LogOutIcon,
	MailIcon,
	PaintRollerIcon,
	PencilIcon,
	PhoneIcon,
	ReceiptTextIcon,
	SendIcon,
} from 'lucide-react'
import * as React from 'react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import {
	Breadcrumb,
	BreadcrumbItem,
	BreadcrumbLink,
	BreadcrumbList,
	BreadcrumbPage,
	BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import {
	Combobox,
	ComboboxContent,
	ComboboxEmpty,
	ComboboxInput,
	ComboboxItem,
	ComboboxList,
} from '@/components/ui/combobox'
import { DateTimePicker } from '@/components/ui/datetime-picker'
import {
	DescriptionList,
	DescriptionListDetails,
	DescriptionListItem,
	DescriptionListTerm,
} from '@/components/ui/description-list'
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia } from '@/components/ui/empty'
import {
	Field,
	FieldContent,
	FieldDescription,
	FieldError,
	FieldGroup,
	FieldLabel,
	FieldLegend,
	FieldSet,
	FieldTitle,
} from '@/components/ui/field'
import { FileDropzone } from '@/components/ui/file-dropzone'
import { Input } from '@/components/ui/input'
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '@/components/ui/input-group'
import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from '@/components/ui/item'
import { Progress, ProgressLabel, ProgressValue } from '@/components/ui/progress'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Separator } from '@/components/ui/separator'
import { Spinner } from '@/components/ui/spinner'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'

// ---------------------------------------------------------------------------------------------------------------------
// Data
// ---------------------------------------------------------------------------------------------------------------------

type Property = { value: string; label: string; address: string; zoning: string }

const PROPERTIES: Property[] = [
	{ value: 'bjorken-4-12', label: 'Björken 4:12', address: 'Storgatan 14', zoning: 'Detached houses, detailed plan' },
	{ value: 'bjorken-4-13', label: 'Björken 4:13', address: 'Storgatan 16', zoning: 'Detached houses, detailed plan' },
	{ value: 'eken-2-7', label: 'Eken 2:7', address: 'Kungsgatan 3', zoning: 'Town centre, detailed plan' },
	{ value: 'granen-11-2', label: 'Granen 11:2', address: 'Skolvägen 8', zoning: 'Terraced houses, detailed plan' },
	{ value: 'lonnen-1-45', label: 'Lönnen 1:45', address: 'Sjövägen 21', zoning: 'Outside detailed plan' },
	{ value: 'tallen-7-3', label: 'Tallen 7:3', address: 'Kyrkogatan 5', zoning: 'Detached houses, detailed plan' },
]

const OWNERSHIP = [
	{ value: 'sole', label: 'Yes, I own it' },
	{ value: 'joint', label: 'Yes, together with someone else' },
	{ value: 'other', label: 'No, I apply on behalf of the owner' },
]

const WORK_TYPES = [
	{
		value: 'new-build',
		title: 'New build',
		description: 'A new house or holiday home.',
		fee: 24_800,
		icon: HouseIcon,
	},
	{
		value: 'extension',
		title: 'Extension',
		description: 'Make an existing building larger.',
		fee: 12_400,
		icon: HousePlusIcon,
	},
	{
		value: 'carport',
		title: 'Carport or garage',
		description: 'Over 15 m² or close to the boundary.',
		fee: 7_900,
		icon: CarIcon,
	},
	{
		value: 'facade',
		title: 'Facade change',
		description: 'New colour, cladding or windows.',
		fee: 5_600,
		icon: PaintRollerIcon,
	},
]

const DOCUMENTS = [
	{ key: 'sitePlan', title: 'Site plan', description: 'Scale 1:400, showing the building and distances to boundaries.' },
	{
		key: 'floorPlans',
		title: 'Floor plans and sections',
		description: 'Scale 1:100, with room names and measurements.',
	},
	{ key: 'facades', title: 'Facade drawings', description: 'All affected facades, with materials and colours.' },
] as const

type DocumentKey = (typeof DOCUMENTS)[number]['key']

const OPENING_HOURS = [
	['Mon–Thu', '9–12, 13–15'],
	['Fri', '9–12'],
	['Drop-in', 'Tue 16–18, Town Hall, Kungsgatan 3'],
] as const

const STEPS = ['Property', 'Project', 'Documents', 'Review and send'] as const

const DESCRIPTION_MAX = 500

const kronor = new Intl.NumberFormat('sv-SE', { maximumFractionDigits: 0 })

// ---------------------------------------------------------------------------------------------------------------------
// Form state and validation
// ---------------------------------------------------------------------------------------------------------------------

type FormState = {
	property: Property | null
	address: string
	ownership: string
	ownerName: string
	email: string
	phone: string
	workType: string
	description: string
	startDate: string
	area: string
	sitePlan: File[]
	floorPlans: File[]
	facades: File[]
	consent: boolean
}

const INITIAL: FormState = {
	property: null,
	address: '',
	ownership: '',
	ownerName: '',
	email: '',
	phone: '',
	workType: '',
	description: '',
	startDate: '',
	area: '',
	sitePlan: [],
	floorPlans: [],
	facades: [],
	consent: false,
}

type FieldName = keyof FormState
type Errors = Partial<Record<FieldName, string>>

function validateStep(step: number, form: FormState): Errors {
	const errors: Errors = {}
	if (step === 0) {
		if (!form.property) errors.property = 'Choose the property designation, for example Björken 4:12.'
		if (!form.address.trim()) errors.address = 'Enter the street address of the property.'
		if (!form.ownership) errors.ownership = 'Answer whether you own the property.'
		if (form.ownership === 'other' && !form.ownerName.trim()) errors.ownerName = 'Enter the name of the owner.'
		if (!form.email.trim()) errors.email = 'Enter your email address.'
		else if (!/^\S+@\S+\.\S+$/.test(form.email.trim()))
			errors.email = 'Enter an email address in the format name@example.com.'
		if (form.phone.replace(/\D/g, '').length < 7) errors.phone = 'Enter a phone number with at least 7 digits.'
	}
	if (step === 1) {
		if (!form.workType) errors.workType = 'Choose the type of work.'
		if (form.description.trim().length < 20) errors.description = 'Describe the project in at least 20 characters.'
		if (!form.startDate) errors.startDate = 'Choose when you plan to start.'
		const area = Number(form.area.replace(',', '.'))
		if (!form.area.trim()) errors.area = 'Enter the building area in square metres.'
		else if (Number.isNaN(area) || area <= 0 || area > 2000) errors.area = 'Enter a building area between 1 and 2 000 m².'
	}
	if (step === 2) {
		for (const doc of DOCUMENTS) {
			if (form[doc.key].length === 0) errors[doc.key] = `Upload the ${doc.title.toLowerCase()}.`
		}
	}
	if (step === 3) {
		if (!form.consent) errors.consent = 'Confirm that the information is correct before you send.'
	}
	return errors
}

// ---------------------------------------------------------------------------------------------------------------------
// Screen
// ---------------------------------------------------------------------------------------------------------------------

export default function BuildingPermitExample() {
	const uid = React.useId()
	const id = React.useCallback((name: string) => `${uid}-${name}`, [uid])

	const [form, setForm] = React.useState<FormState>(INITIAL)
	const [step, setStep] = React.useState(0)
	const [reached, setReached] = React.useState(0)
	const [attempted, setAttempted] = React.useState<boolean[]>([false, false, false, false])
	const [sending, setSending] = React.useState(false)
	const [sent, setSent] = React.useState(false)

	const headingRef = React.useRef<HTMLHeadingElement>(null)
	const summaryRef = React.useRef<HTMLDivElement>(null)
	const confirmationRef = React.useRef<HTMLHeadingElement>(null)
	const focusTarget = React.useRef<'heading' | 'summary' | 'confirmation' | null>(null)

	const errors = attempted[step] ? validateStep(step, form) : {}
	const errorList = Object.entries(errors) as [FieldName, string][]

	// Move focus after the view changes, so keyboard and screen reader users land in the right place
	React.useEffect(() => {
		const target = focusTarget.current
		focusTarget.current = null
		if (target === 'heading') headingRef.current?.focus()
		if (target === 'summary') summaryRef.current?.focus()
		if (target === 'confirmation') confirmationRef.current?.focus()
	})

	const update = <K extends FieldName>(key: K, value: FormState[K]) => setForm((prev) => ({ ...prev, [key]: value }))

	function goTo(next: number) {
		setStep(next)
		focusTarget.current = 'heading'
	}

	function markAttempted(index: number) {
		setAttempted((prev) => prev.map((value, i) => (i === index ? true : value)))
	}

	function next() {
		if (Object.keys(validateStep(step, form)).length > 0) {
			markAttempted(step)
			focusTarget.current = 'summary'
			return
		}
		const nextStep = step + 1
		setReached((prev) => Math.max(prev, nextStep))
		goTo(nextStep)
	}

	function send() {
		// Re-check every step, since the resident may have changed an earlier step from the summary
		for (let i = 0; i < STEPS.length; i++) {
			if (Object.keys(validateStep(i, form)).length > 0) {
				markAttempted(i)
				setStep(i)
				focusTarget.current = 'summary'
				return
			}
		}
		setSending(true)
		window.setTimeout(() => {
			setSending(false)
			setSent(true)
			focusTarget.current = 'confirmation'
		}, 900)
	}

	function restart() {
		setForm(INITIAL)
		setStep(0)
		setReached(0)
		setAttempted([false, false, false, false])
		setSent(false)
		focusTarget.current = 'heading'
	}

	const workType = WORK_TYPES.find((type) => type.value === form.workType)
	const fee = workType?.fee ?? 12_400

	const stepProps = { form, update, errors, id }

	return (
		<div className="@container flex min-h-[720px] flex-col bg-background text-sm text-foreground">
			<SiteHeader />

			<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-4 py-6 @3xl:px-8">
				<div className="flex flex-col gap-2">
					<Breadcrumb>
						<BreadcrumbList>
							<BreadcrumbItem>
								<BreadcrumbLink href="#start">Start</BreadcrumbLink>
							</BreadcrumbItem>
							<BreadcrumbSeparator />
							<BreadcrumbItem>
								<BreadcrumbLink href="#building-and-housing">Building and housing</BreadcrumbLink>
							</BreadcrumbItem>
							<BreadcrumbSeparator />
							<BreadcrumbItem>
								<BreadcrumbPage>Apply for a building permit</BreadcrumbPage>
							</BreadcrumbItem>
						</BreadcrumbList>
					</Breadcrumb>
					<h3 className="font-heading text-2xl font-semibold tracking-tight">Apply for a building permit</h3>
					<p className="max-w-prose text-muted-foreground">
						Apply for a new build, an extension, a carport or a change to a facade. It takes about 20 minutes. Have your
						drawings ready as PDF files.
					</p>
				</div>

				<div className="grid items-start gap-6 @3xl:grid-cols-[minmax(0,1fr)_18rem]">
					<div className="flex min-w-0 flex-col gap-6">
						{sent ? (
							<Confirmation ref={confirmationRef} email={form.email} property={form.property} fee={fee} onRestart={restart} />
						) : (
							<>
								<StepIndicator step={step} reached={reached} onSelect={goTo} />

								<section
									aria-labelledby={id('step-heading')}
									className="flex flex-col gap-6 rounded-xl p-4 ring-1 ring-border @xl:p-6"
								>
									<div className="flex flex-col gap-1">
										<p className="text-muted-foreground">
											Step {step + 1} of {STEPS.length}
										</p>
										<h4
											id={id('step-heading')}
											ref={headingRef}
											tabIndex={-1}
											className="font-heading text-lg font-semibold outline-none"
										>
											{STEPS[step]}
										</h4>
									</div>

									{errorList.length > 0 && <ErrorSummary ref={summaryRef} errors={errorList} targetId={(name) => id(name)} />}

									{step === 0 && <PropertyStep {...stepProps} />}
									{step === 1 && <ProjectStep {...stepProps} />}
									{step === 2 && <DocumentsStep {...stepProps} />}
									{step === 3 && <ReviewStep {...stepProps} fee={fee} onEdit={goTo} />}

									<Separator />

									<div className="flex flex-col-reverse gap-2 @md:flex-row @md:justify-between">
										{step > 0 ? (
											<Button variant="outline" onClick={() => goTo(step - 1)} disabled={sending}>
												<ChevronLeftIcon data-icon="inline-start" aria-hidden="true" />
												Back
											</Button>
										) : (
											<span />
										)}
										{step < STEPS.length - 1 ? (
											<Button onClick={next}>
												Next: {STEPS[step + 1]}
												<ChevronRightIcon data-icon="inline-end" aria-hidden="true" />
											</Button>
										) : (
											<Button onClick={send} disabled={sending}>
												{sending ? (
													<Spinner data-icon="inline-start" aria-hidden="true" />
												) : (
													<SendIcon data-icon="inline-start" aria-hidden="true" />
												)}
												{sending ? 'Sending application…' : 'Send application'}
											</Button>
										)}
									</div>
								</section>
							</>
						)}
					</div>

					<HelpAside />
				</div>
			</main>
		</div>
	)
}

// ---------------------------------------------------------------------------------------------------------------------
// Chrome: header, step indicator, error summary, help
// ---------------------------------------------------------------------------------------------------------------------

function SiteHeader() {
	return (
		<header className="border-b bg-card">
			<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3 @3xl:px-8">
				<a href="#start" className="flex items-center gap-2 rounded-md font-heading font-semibold">
					<span className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
						<LandmarkIcon className="size-4" aria-hidden="true" />
					</span>
					Lindvik Municipality
				</a>
				<div className="flex items-center gap-2">
					{/* biome-ignore lint/a11y/useAnchorContent: the children are rendered inside the anchor */}
					<Button variant="ghost" render={<a href="#my-pages" />} nativeButton={false} className="hidden @md:inline-flex">
						My pages
					</Button>
					<div className="flex items-center gap-2">
						<Avatar>
							<AvatarFallback>AL</AvatarFallback>
						</Avatar>
						<div className="hidden flex-col leading-tight @xl:flex">
							<span className="font-medium">Anna Lindqvist</span>
							<span className="text-muted-foreground">Signed in with e-ID</span>
						</div>
						<span className="sr-only @xl:hidden">Signed in as Anna Lindqvist with e-ID</span>
					</div>
					<Button variant="ghost" size="icon" aria-label="Sign out">
						<LogOutIcon aria-hidden="true" />
					</Button>
				</div>
			</div>
		</header>
	)
}

function StepIndicator({
	step,
	reached,
	onSelect,
}: {
	step: number
	reached: number
	onSelect: (index: number) => void
}) {
	const percent = Math.round(((step + 1) / STEPS.length) * 100)

	return (
		<nav aria-label="Application steps" className="flex flex-col gap-4">
			<Progress value={percent}>
				<ProgressLabel>
					Step {step + 1} of {STEPS.length}: {STEPS[step]}
				</ProgressLabel>
				<ProgressValue />
			</Progress>
			<ol className="grid grid-cols-4 gap-2">
				{STEPS.map((label, index) => {
					const current = index === step
					const done = index < reached && !current
					const available = index <= reached
					return (
						<li key={label}>
							<button
								type="button"
								disabled={!available}
								aria-current={current ? 'step' : undefined}
								onClick={() => onSelect(index)}
								className="group flex min-h-10 w-full items-center gap-2 rounded-lg p-1 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring enabled:cursor-pointer enabled:hover:bg-muted disabled:cursor-not-allowed"
							>
								<span
									aria-hidden="true"
									className={cn(
										'flex size-7 shrink-0 items-center justify-center rounded-full border font-medium tabular-nums',
										current && 'border-primary bg-primary text-primary-foreground',
										done && 'border-primary bg-primary/10 text-primary',
										!current && !done && 'text-muted-foreground',
									)}
								>
									{done ? <CheckIcon className="size-4" /> : index + 1}
								</span>
								<span
									aria-hidden="true"
									className={cn('hidden min-w-0 leading-tight @2xl:inline', current ? 'font-medium' : 'text-muted-foreground')}
								>
									{label}
								</span>
								<span className="sr-only">
									{`${index + 1}. ${label}`}
									{current ? ', current step' : done ? ', completed' : available ? '' : ', not started'}
								</span>
							</button>
						</li>
					)
				})}
			</ol>
		</nav>
	)
}

function ErrorSummary({
	ref,
	errors,
	targetId,
}: {
	ref: React.Ref<HTMLDivElement>
	errors: [FieldName, string][]
	targetId: (name: FieldName) => string
}) {
	const headingId = React.useId()
	return (
		<Alert
			ref={ref}
			variant="destructive"
			tabIndex={-1}
			aria-labelledby={headingId}
			className="outline-none focus-visible:ring-3 focus-visible:ring-ring"
		>
			<CircleAlertIcon aria-hidden="true" />
			<p id={headingId} className="col-start-2 font-medium">
				{errors.length === 1 ? 'There is 1 problem to fix' : `There are ${errors.length} problems to fix`}
			</p>
			<AlertDescription className="col-start-2">
				<ul className="mt-1 flex list-disc flex-col gap-1 pl-4">
					{errors.map(([name, message]) => (
						<li key={name}>
							<a
								href={`#${targetId(name)}`}
								onClick={(event) => {
									event.preventDefault()
									document.getElementById(targetId(name))?.focus()
								}}
							>
								{message}
							</a>
						</li>
					))}
				</ul>
			</AlertDescription>
		</Alert>
	)
}

function HelpAside() {
	const headingId = React.useId()
	return (
		<aside aria-labelledby={headingId} className="flex flex-col gap-4">
			<Card size="sm">
				<CardHeader>
					<CardTitle as="h4" id={headingId}>
						Need help?
					</CardTitle>
					<CardDescription>The building permit unit answers questions about your application.</CardDescription>
				</CardHeader>
				<CardContent className="flex flex-col gap-3">
					<a href="tel:+4677144120" className="flex items-center gap-2 underline-offset-4 hover:underline">
						<PhoneIcon className="size-4 text-muted-foreground" aria-hidden="true" />
						0771-44 12 00
					</a>
					<a
						href="mailto:bygglov@lindvik.se"
						className="flex items-center gap-2 break-all underline-offset-4 hover:underline"
					>
						<MailIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
						bygglov@lindvik.se
					</a>
					<Separator />
					<div className="flex items-start gap-2">
						<ClockIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
						<DescriptionList
							orientation="horizontal"
							className="flex-1 gap-y-1 [--description-list-details-min:--spacing(24)] [--description-list-term-width:--spacing(16)]"
						>
							{OPENING_HOURS.map(([term, value]) => (
								<DescriptionListItem key={term}>
									<DescriptionListTerm>{term}</DescriptionListTerm>
									<DescriptionListDetails>{value}</DescriptionListDetails>
								</DescriptionListItem>
							))}
						</DescriptionList>
					</div>
				</CardContent>
			</Card>
			<Card size="sm">
				<CardHeader>
					<CardTitle as="h4">Good to know</CardTitle>
				</CardHeader>
				<CardContent>
					<ul className="flex list-disc flex-col gap-2 pl-4 text-muted-foreground">
						<li>We make a decision within 10 weeks of receiving a complete application.</li>
						<li>You may not start building until you have a start notice.</li>
						<li>
							Your draft is saved on <a href="#my-pages">My pages</a> for 30 days.
						</li>
					</ul>
				</CardContent>
			</Card>
		</aside>
	)
}

// ---------------------------------------------------------------------------------------------------------------------
// Steps
// ---------------------------------------------------------------------------------------------------------------------

type StepProps = {
	form: FormState
	update: <K extends FieldName>(key: K, value: FormState[K]) => void
	errors: Errors
	id: (name: string) => string
}

/** aria props that link a control to its error message */
function describe(errors: Errors, name: FieldName, id: (name: string) => string, descriptionId?: string) {
	const ids = [descriptionId, errors[name] ? id(`${name}-error`) : undefined].filter(Boolean).join(' ')
	return {
		'aria-invalid': errors[name] ? true : undefined,
		'aria-describedby': ids || undefined,
	} as const
}

function PropertyStep({ form, update, errors, id }: StepProps) {
	return (
		<FieldGroup>
			<FieldSet>
				<FieldLegend>The property</FieldLegend>
				<FieldGroup className="grid gap-5 @xl:grid-cols-2">
					<Field data-invalid={!!errors.property || undefined}>
						<FieldLabel htmlFor={id('property')}>Property designation</FieldLabel>
						<Combobox
							items={PROPERTIES}
							value={form.property}
							onValueChange={(property: Property | null) => {
								update('property', property)
								if (property && !form.address) update('address', property.address)
							}}
						>
							<ComboboxInput
								id={id('property')}
								placeholder="Search, e.g. Björken 4:12"
								showClear
								{...describe(errors, 'property', id, id('property-description'))}
							/>
							<ComboboxContent>
								<ComboboxEmpty>No property found. Check the spelling.</ComboboxEmpty>
								<ComboboxList>
									{(property: Property) => (
										<ComboboxItem key={property.value} value={property}>
											<span className="flex flex-col">
												<span>{property.label}</span>
												<span className="text-muted-foreground">
													{property.address} · {property.zoning}
												</span>
											</span>
										</ComboboxItem>
									)}
								</ComboboxList>
							</ComboboxContent>
						</Combobox>
						<FieldDescription id={id('property-description')}>
							{form.property ? form.property.zoning : 'Shown on your title deed or property tax notice.'}
						</FieldDescription>
						<FieldError id={id('property-error')}>{errors.property}</FieldError>
					</Field>
					<Field data-invalid={!!errors.address || undefined}>
						<FieldLabel htmlFor={id('address')}>Property address</FieldLabel>
						<Input
							id={id('address')}
							value={form.address}
							onChange={(event) => update('address', event.target.value)}
							placeholder="Storgatan 14"
							{...describe(errors, 'address', id)}
						/>
						<FieldError id={id('address-error')}>{errors.address}</FieldError>
					</Field>
				</FieldGroup>
			</FieldSet>

			<FieldSet data-invalid={!!errors.ownership || undefined}>
				<FieldLegend variant="label">Do you own the property?</FieldLegend>
				<RadioGroup
					value={form.ownership}
					onValueChange={(value) => update('ownership', value as string)}
					aria-describedby={errors.ownership ? id('ownership-error') : undefined}
				>
					{OWNERSHIP.map((option, index) => (
						<Field key={option.value} orientation="horizontal" data-invalid={!!errors.ownership || undefined}>
							<RadioGroupItem
								value={option.value}
								id={index === 0 ? id('ownership') : id(`ownership-${option.value}`)}
								aria-invalid={errors.ownership ? true : undefined}
							/>
							<FieldLabel htmlFor={index === 0 ? id('ownership') : id(`ownership-${option.value}`)} className="font-normal">
								{option.label}
							</FieldLabel>
						</Field>
					))}
				</RadioGroup>
				<FieldError id={id('ownership-error')}>{errors.ownership}</FieldError>
				{form.ownership === 'other' && (
					<Field data-invalid={!!errors.ownerName || undefined} className="@xl:max-w-sm">
						<FieldLabel htmlFor={id('ownerName')}>Owner’s name</FieldLabel>
						<Input
							id={id('ownerName')}
							value={form.ownerName}
							onChange={(event) => update('ownerName', event.target.value)}
							{...describe(errors, 'ownerName', id, id('ownerName-description'))}
						/>
						<FieldDescription id={id('ownerName-description')}>
							We will ask the owner to confirm the application.
						</FieldDescription>
						<FieldError id={id('ownerName-error')}>{errors.ownerName}</FieldError>
					</Field>
				)}
			</FieldSet>

			<FieldSet>
				<FieldLegend>Your contact details</FieldLegend>
				<FieldDescription>We use these to contact you about the application.</FieldDescription>
				<FieldGroup className="grid gap-5 @xl:grid-cols-2">
					<Field className="@xl:col-span-2">
						<FieldLabel htmlFor={id('name')}>Name</FieldLabel>
						<Input
							id={id('name')}
							value="Anna Lindqvist"
							readOnly
							autoComplete="name"
							aria-describedby={id('name-description')}
						/>
						<FieldDescription id={id('name-description')}>From your e-ID.</FieldDescription>
					</Field>
					<Field data-invalid={!!errors.email || undefined}>
						<FieldLabel htmlFor={id('email')}>Email</FieldLabel>
						<Input
							id={id('email')}
							type="email"
							autoComplete="email"
							value={form.email}
							onChange={(event) => update('email', event.target.value)}
							placeholder="name@example.com"
							{...describe(errors, 'email', id)}
						/>
						<FieldError id={id('email-error')}>{errors.email}</FieldError>
					</Field>
					<Field data-invalid={!!errors.phone || undefined}>
						<FieldLabel htmlFor={id('phone')}>Phone</FieldLabel>
						<Input
							id={id('phone')}
							type="tel"
							autoComplete="tel"
							value={form.phone}
							onChange={(event) => update('phone', event.target.value)}
							placeholder="070-123 45 67"
							{...describe(errors, 'phone', id)}
						/>
						<FieldError id={id('phone-error')}>{errors.phone}</FieldError>
					</Field>
				</FieldGroup>
			</FieldSet>
		</FieldGroup>
	)
}

function ProjectStep({ form, update, errors, id }: StepProps) {
	const remaining = DESCRIPTION_MAX - form.description.length

	return (
		<FieldGroup>
			<FieldSet data-invalid={!!errors.workType || undefined}>
				<FieldLegend variant="label">Type of work</FieldLegend>
				<RadioGroup
					value={form.workType}
					onValueChange={(value) => update('workType', value as string)}
					aria-describedby={errors.workType ? id('workType-error') : undefined}
					className="grid gap-2 @xl:grid-cols-2"
				>
					{WORK_TYPES.map((type, index) => {
						const itemId = index === 0 ? id('workType') : id(`workType-${type.value}`)
						return (
							<FieldLabel key={type.value} htmlFor={itemId}>
								<Field orientation="horizontal">
									<RadioGroupItem value={type.value} id={itemId} aria-invalid={errors.workType ? true : undefined} />
									<FieldContent>
										<FieldTitle>{type.title}</FieldTitle>
										<FieldDescription>{type.description}</FieldDescription>
									</FieldContent>
									<type.icon className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
								</Field>
							</FieldLabel>
						)
					})}
				</RadioGroup>
				<FieldError id={id('workType-error')}>{errors.workType}</FieldError>
			</FieldSet>

			<Field data-invalid={!!errors.description || undefined}>
				<FieldLabel htmlFor={id('description')}>Describe the project</FieldLabel>
				<Textarea
					id={id('description')}
					value={form.description}
					maxLength={DESCRIPTION_MAX}
					rows={4}
					onChange={(event) => update('description', event.target.value)}
					placeholder="For example: a 24 m² extension on the garden side with a new bedroom and a terrace."
					{...describe(errors, 'description', id, id('description-count'))}
				/>
				<div className="flex flex-wrap justify-between gap-2">
					<FieldError id={id('description-error')}>{errors.description}</FieldError>
					<FieldDescription id={id('description-count')} className="ml-auto tabular-nums">
						{remaining} of {DESCRIPTION_MAX} characters left
					</FieldDescription>
				</div>
			</Field>

			<div className="grid gap-5 @xl:grid-cols-2">
				<Field data-invalid={!!errors.startDate || undefined}>
					<FieldLabel htmlFor={id('startDate')}>Planned start</FieldLabel>
					<DateTimePicker
						id={id('startDate')}
						value={form.startDate}
						onChange={(value) => update('startDate', value)}
						placeholder="Choose a date"
						startMonth={new Date(2026, 9)}
						endMonth={new Date(2028, 11)}
						disabledDates={{ before: new Date(2026, 9, 1) }}
						aria-invalid={errors.startDate ? true : undefined}
					/>
					<FieldDescription>Building can start once you have a start notice.</FieldDescription>
					<FieldError id={id('startDate-error')}>{errors.startDate}</FieldError>
				</Field>
				<Field data-invalid={!!errors.area || undefined}>
					<FieldLabel htmlFor={id('area')}>Building area</FieldLabel>
					<InputGroup>
						<InputGroupInput
							id={id('area')}
							inputMode="decimal"
							value={form.area}
							onChange={(event) => update('area', event.target.value)}
							placeholder="24"
							{...describe(errors, 'area', id, id('area-description'))}
						/>
						<InputGroupAddon align="inline-end">
							<InputGroupText>m²</InputGroupText>
						</InputGroupAddon>
					</InputGroup>
					<FieldDescription id={id('area-description')}>The new or changed area, measured outside walls.</FieldDescription>
					<FieldError id={id('area-error')}>{errors.area}</FieldError>
				</Field>
			</div>
		</FieldGroup>
	)
}

function DocumentsStep({ form, update, errors, id }: StepProps) {
	const uploaded = DOCUMENTS.filter((doc) => form[doc.key].length > 0).length

	function addExampleFiles() {
		const names: Record<DocumentKey, string> = {
			sitePlan: 'site-plan-bjorken-4-12.pdf',
			floorPlans: 'floor-plans-and-sections.pdf',
			facades: 'facades-north-south.pdf',
		}
		for (const doc of DOCUMENTS) {
			if (form[doc.key].length === 0) {
				update(doc.key, [new File(['%PDF-1.7 example'], names[doc.key], { type: 'application/pdf' })])
			}
		}
	}

	return (
		<div className="flex flex-col gap-6">
			<div className="flex flex-col gap-3 rounded-lg bg-muted/50 p-4">
				<div className="flex flex-wrap items-center justify-between gap-2">
					<p className="font-medium" id={id('checklist')}>
						Required documents
					</p>
					<Badge variant={uploaded === DOCUMENTS.length ? 'secondary' : 'outline'}>
						{uploaded} of {DOCUMENTS.length} uploaded
					</Badge>
				</div>
				<ul aria-labelledby={id('checklist')} className="flex flex-col gap-2">
					{DOCUMENTS.map((doc) => {
						const done = form[doc.key].length > 0
						return (
							<li key={doc.key} className="flex items-center gap-2">
								{done ? (
									<CircleCheckIcon className="size-4 shrink-0 text-primary" aria-hidden="true" />
								) : (
									<CircleDashedIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
								)}
								<span>{doc.title}</span>
								<span className={cn('ml-auto', done ? 'text-foreground' : 'text-muted-foreground')}>
									{done ? 'Uploaded' : 'Missing'}
								</span>
							</li>
						)
					})}
				</ul>
				<p className="text-muted-foreground">
					Trying out the example?{' '}
					<Button variant="link" className="h-auto p-0" onClick={addExampleFiles}>
						Add example files
					</Button>
				</p>
			</div>

			{DOCUMENTS.map((doc) => (
				<FieldSet
					key={doc.key}
					id={id(doc.key)}
					tabIndex={-1}
					aria-describedby={errors[doc.key] ? id(`${doc.key}-error`) : undefined}
					className="gap-2 rounded-lg outline-none focus-visible:ring-3 focus-visible:ring-ring"
				>
					<FieldLegend variant="label" className={cn('mb-0', errors[doc.key] && 'text-destructive')}>
						{doc.title}
					</FieldLegend>
					<FieldDescription id={id(`${doc.key}-description`)}>{doc.description}</FieldDescription>
					<FileDropzone
						size="sm"
						value={form[doc.key]}
						onChange={(files) => update(doc.key, files)}
						maxFiles={3}
						maxFileSize={20}
						acceptedFileTypes={['.pdf', '.dwg', '.jpg', '.png']}
						regionLabel={`${doc.title} upload`}
						ariaLabel={`Choose ${doc.title.toLowerCase()} or drag here`}
					/>
					<FieldError id={id(`${doc.key}-error`)}>{errors[doc.key]}</FieldError>
				</FieldSet>
			))}
		</div>
	)
}

function ReviewStep({
	form,
	update,
	errors,
	id,
	fee,
	onEdit,
}: StepProps & { fee: number; onEdit: (step: number) => void }) {
	const workType = WORK_TYPES.find((type) => type.value === form.workType)
	const ownership = OWNERSHIP.find((option) => option.value === form.ownership)
	const startDate = form.startDate
		? new Date(`${form.startDate}T00:00:00`).toLocaleDateString('en-GB', {
				day: 'numeric',
				month: 'long',
				year: 'numeric',
			})
		: '—'

	const sections: { title: string; step: number; rows: [string, React.ReactNode][] }[] = [
		{
			title: 'Property',
			step: 0,
			rows: [
				['Property designation', form.property?.label ?? '—'],
				['Address', form.address || '—'],
				['Ownership', ownership?.label ?? '—'],
				...(form.ownership === 'other' ? ([['Owner', form.ownerName]] as [string, string][]) : []),
				['Contact', `Anna Lindqvist, ${form.email}, ${form.phone}`],
			],
		},
		{
			title: 'Project',
			step: 1,
			rows: [
				['Type of work', workType?.title ?? '—'],
				['Description', form.description || '—'],
				['Planned start', startDate],
				['Building area', form.area ? `${form.area} m²` : '—'],
			],
		},
		{
			title: 'Documents',
			step: 2,
			rows: DOCUMENTS.map((doc) => [
				doc.title,
				form[doc.key].length > 0 ? (
					<span key={doc.key} className="flex flex-col gap-1">
						{form[doc.key].map((file) => (
							<span key={file.name} className="flex min-w-0 items-center gap-1.5">
								<FileTextIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
								<span className="truncate">{file.name}</span>
							</span>
						))}
					</span>
				) : (
					'Missing'
				),
			]),
		},
	]

	return (
		<div className="flex flex-col gap-6">
			<p className="text-muted-foreground">Check your answers before you send the application.</p>

			{sections.map((section) => (
				<section key={section.title} aria-labelledby={id(`review-${section.step}`)} className="flex flex-col gap-3">
					<div className="flex items-center justify-between gap-2">
						<h5 id={id(`review-${section.step}`)} className="font-heading text-base font-medium">
							{section.title}
						</h5>
						<Button
							variant="ghost"
							size="sm"
							onClick={() => onEdit(section.step)}
							aria-label={`Edit ${section.title.toLowerCase()}`}
						>
							<PencilIcon data-icon="inline-start" aria-hidden="true" />
							Edit
						</Button>
					</div>
					<DescriptionList
						orientation="horizontal"
						className="gap-y-0 divide-y rounded-lg ring-1 ring-border [--description-list-term-width:--spacing(48)]"
					>
						{section.rows.map(([term, value]) => (
							<DescriptionListItem key={term} className="px-3 py-2.5">
								<DescriptionListTerm>{term}</DescriptionListTerm>
								<DescriptionListDetails>{value}</DescriptionListDetails>
							</DescriptionListItem>
						))}
					</DescriptionList>
				</section>
			))}

			<Alert role="note">
				<ReceiptTextIcon aria-hidden="true" />
				<p className="col-start-2 font-medium">Fee: {kronor.format(fee)} kr</p>
				<AlertDescription className="col-start-2 text-muted-foreground">
					The fee for {workType ? workType.title.toLowerCase() : 'this application'} follows the municipality’s building
					permit tariff for 2026. You get an invoice when we have made a decision.
				</AlertDescription>
			</Alert>

			<Field orientation="horizontal" data-invalid={!!errors.consent || undefined}>
				<Checkbox
					id={id('consent')}
					checked={form.consent}
					onCheckedChange={(checked) => update('consent', checked === true)}
					{...describe(errors, 'consent', id)}
				/>
				<FieldContent>
					<FieldLabel htmlFor={id('consent')} className="font-normal">
						I confirm that the information is correct and that I have read how Lindvik Municipality handles personal data.
					</FieldLabel>
					<FieldError id={id('consent-error')}>{errors.consent}</FieldError>
				</FieldContent>
			</Field>
		</div>
	)
}

// ---------------------------------------------------------------------------------------------------------------------
// Confirmation
// ---------------------------------------------------------------------------------------------------------------------

const NEXT_STEPS = [
	{
		title: 'Confirmation by email',
		description: 'A receipt with your answers and documents is on its way.',
		icon: MailIcon,
	},
	{
		title: 'Completeness check within 2 weeks',
		description: 'Ingrid Hansen, case officer, checks that nothing is missing.',
		icon: FileTextIcon,
	},
	{
		title: 'Decision within 10 weeks',
		description: 'Counted from the day the application is complete. Neighbours may be asked for their views.',
		icon: ClockIcon,
	},
	{
		title: 'Invoice after the decision',
		description: 'Sent to your digital mailbox.',
		icon: ReceiptTextIcon,
	},
]

function Confirmation({
	ref,
	email,
	property,
	fee,
	onRestart,
}: {
	ref: React.Ref<HTMLHeadingElement>
	email: string
	property: Property | null
	fee: number
	onRestart: () => void
}) {
	const headingId = React.useId()
	return (
		<section aria-labelledby={headingId} className="flex flex-col gap-6 rounded-xl p-4 ring-1 ring-border @xl:p-6">
			<Empty className="p-0 @xl:p-4">
				<EmptyHeader>
					<EmptyMedia variant="icon" className="size-12 rounded-full bg-primary/10 text-primary">
						<CircleCheckIcon className="size-6" aria-hidden="true" />
					</EmptyMedia>
					<h4
						ref={ref}
						tabIndex={-1}
						id={headingId}
						className="rounded-md font-heading text-lg font-medium tracking-tight outline-none"
					>
						Your application has been sent
					</h4>
					<EmptyDescription>
						Building permit for {property?.label ?? 'your property'}. We have sent a receipt to{' '}
						<span className="font-medium text-foreground">{email}</span>.
					</EmptyDescription>
				</EmptyHeader>
				<EmptyContent>
					<div className="flex flex-col items-center gap-1 rounded-lg bg-muted/50 px-6 py-3">
						<span className="text-muted-foreground">Case number</span>
						<span className="font-heading text-xl font-semibold tabular-nums">2026-04812</span>
					</div>
				</EmptyContent>
			</Empty>

			<div className="flex flex-col gap-3">
				<h5 className="font-heading text-base font-medium">What happens next</h5>
				<ItemGroup className="gap-2">
					{NEXT_STEPS.map((item) => (
						<Item key={item.title} variant="outline">
							<ItemMedia variant="icon">
								<item.icon aria-hidden="true" />
							</ItemMedia>
							<ItemContent>
								<ItemTitle>{item.title}</ItemTitle>
								<ItemDescription>
									{item.title.startsWith('Invoice') ? `${kronor.format(fee)} kr. ${item.description}` : item.description}
								</ItemDescription>
							</ItemContent>
						</Item>
					))}
				</ItemGroup>
			</div>

			<div className="flex flex-col gap-2 @md:flex-row @md:justify-end">
				<Button variant="outline" onClick={onRestart}>
					Start a new application
				</Button>
				{/* biome-ignore lint/a11y/useAnchorContent: the children are rendered inside the anchor */}
				<Button render={<a href="#my-pages" />} nativeButton={false}>
					Follow the case on My pages
				</Button>
			</div>
		</section>
	)
}