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
AL
Anna LindqvistSigned in with e-ID
Signed in as Anna Lindqvist with e-IDApply 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
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>
)
}