foamking/components/calculator/step-wizard.tsx
mikl0s 3ebb63dc6c Add admin dashboard, authentication, step wizard, and quote management
Expand the calculator with a multi-step wizard flow, admin dashboard with
quote tracking, login/auth system, distance API integration, and history
page. Add new UI components (dialog, progress, select, slider, switch),
update pricing logic, and improve the overall design with new assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:59:11 +00:00

570 lines
21 KiB
TypeScript

"use client"
import { useState } from "react"
import { useForm, Controller } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import {
ArrowRight,
ArrowLeft,
MapPin,
Ruler,
Settings,
User,
Check,
Loader2,
CheckCircle2,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Switch } from "@/components/ui/switch"
import { Slider } from "@/components/ui/slider"
import { CONSTRAINTS, FLOORING_TYPES, type FlooringType } from "@/lib/constants"
import { validateDanishPostalCode, getDistance } from "@/lib/distance"
import {
calculatePrice,
formatPrice,
formatEstimate,
type CalculationDetails,
} from "@/lib/calculations"
const formSchema = z.object({
postalCode: z
.string()
.length(4, "Postnummer skal være 4 cifre")
.refine(validateDanishPostalCode, "Vi dækker desværre ikke dette område"),
address: z.string().optional(),
area: z.coerce
.number()
.min(CONSTRAINTS.MIN_AREA, `Minimum ${CONSTRAINTS.MIN_AREA}`)
.max(CONSTRAINTS.MAX_AREA, `Maximum ${CONSTRAINTS.MAX_AREA}`),
height: z.coerce
.number()
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum ${CONSTRAINTS.MIN_HEIGHT} cm`)
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum ${CONSTRAINTS.MAX_HEIGHT} cm`),
includeInsulation: z.boolean(),
includeFloorHeating: z.boolean(),
includeCompound: z.boolean(),
flooringType: z.string(),
name: z.string().refine((val) => {
const parts = val.trim().split(/\s+/)
return parts.length >= 2 && parts[0].length >= 3 && parts[1].length >= 3
}, "Indtast fornavn og efternavn (mindst 3 tegn hver)"),
email: z.string().email("Ugyldig email"),
phone: z.string().regex(/^\d{8}$/, "Telefonnummer skal være 8 cifre"),
remarks: z.string().optional(),
})
type FormData = z.infer<typeof formSchema>
interface StepWizardProps {
onComplete: (result: CalculationDetails, formData: FormData) => void
}
const steps = [
{ id: 1, name: "Placering", icon: MapPin },
{ id: 2, name: "Gulvmål", icon: Ruler },
{ id: 3, name: "Løsning", icon: Settings },
{ id: 4, name: "Kontakt", icon: User },
]
export function StepWizard({ onComplete }: StepWizardProps) {
const [currentStep, setCurrentStep] = useState(1)
const [isCalculating, setIsCalculating] = useState(false)
const [showHeightTip, setShowHeightTip] = useState(false)
const {
register,
handleSubmit,
formState: { errors },
watch,
control,
trigger,
getValues,
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
postalCode: "",
address: "",
area: 75,
height: 15,
includeInsulation: true,
includeFloorHeating: true,
includeCompound: true,
flooringType: "STANDARD",
name: "",
email: "",
phone: "",
remarks: "",
},
mode: "onChange",
})
const watchedValues = watch()
const validateStep = async (step: number): Promise<boolean> => {
let fieldsToValidate: (keyof FormData)[] = []
switch (step) {
case 1:
fieldsToValidate = ["postalCode"]
break
case 2:
fieldsToValidate = ["area", "height"]
break
case 3:
fieldsToValidate = []
break
case 4:
fieldsToValidate = ["name", "email", "phone"]
break
}
const result = await trigger(fieldsToValidate)
return result
}
const nextStep = async () => {
const isValid = await validateStep(currentStep)
if (isValid && currentStep < 4) {
setCurrentStep(currentStep + 1)
}
}
const prevStep = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
}
}
const onSubmit = async (data: FormData) => {
setIsCalculating(true)
try {
let distance: number
try {
const params = new URLSearchParams({
postalCode: data.postalCode,
...(data.address && { address: data.address }),
})
const response = await fetch(`/api/distance?${params}`)
const distanceData = await response.json()
distance = distanceData.distance
} catch {
distance = getDistance(data.postalCode)
}
const result = calculatePrice({
area: data.area,
height: data.height,
postalCode: data.postalCode,
distance,
includeInsulation: data.includeInsulation,
includeFloorHeating: data.includeFloorHeating,
includeCompound: data.includeCompound,
flooringType: data.flooringType as FlooringType,
})
onComplete(result, data)
} finally {
setIsCalculating(false)
}
}
return (
<div className="mx-auto w-full max-w-lg">
{/* Progress Steps */}
<div className="mb-8">
<div className="flex justify-between">
{steps.map((step, index) => {
const Icon = step.icon
const isActive = currentStep === step.id
const isCompleted = currentStep > step.id
return (
<div key={step.id} className="flex flex-1 flex-col items-center">
<div className="relative flex w-full items-center justify-center">
{index > 0 && (
<div
className={`absolute left-0 right-1/2 top-5 h-0.5 -translate-y-1/2 ${
isCompleted || isActive ? "bg-secondary" : "bg-muted"
}`}
/>
)}
{index < steps.length - 1 && (
<div
className={`absolute left-1/2 right-0 top-5 h-0.5 -translate-y-1/2 ${
isCompleted ? "bg-secondary" : "bg-muted"
}`}
/>
)}
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
isActive
? "border-secondary bg-secondary text-secondary-foreground"
: isCompleted
? "border-secondary bg-secondary text-secondary-foreground"
: "border-muted bg-background text-muted-foreground"
}`}
>
{isCompleted ? <Check className="h-5 w-5" /> : <Icon className="h-5 w-5" />}
</div>
</div>
<span
className={`mt-2 text-xs font-medium ${
isActive || isCompleted ? "text-foreground" : "text-muted-foreground"
}`}
>
{step.name}
</span>
</div>
)
})}
</div>
</div>
{/* Form Card */}
<div className="rounded-2xl bg-white p-6 shadow-lg sm:p-8">
<form onSubmit={handleSubmit(onSubmit)}>
{/* Step 1: Location */}
{currentStep === 1 && (
<div className="space-y-6">
<div className="mb-6 text-center">
<h2 className="text-xl font-semibold">Hvor skal gulvet lægges?</h2>
<p className="mt-1 text-muted-foreground">
Vi dækker Sjælland, Lolland-Falster og Fyn
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="postalCode">Postnummer *</Label>
<Input
id="postalCode"
{...register("postalCode")}
placeholder="F.eks. 4550"
className="h-12 text-lg"
maxLength={4}
/>
{errors.postalCode && (
<p className="text-sm text-destructive">{errors.postalCode.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="address">
Adresse <span className="font-normal text-muted-foreground">(valgfrit)</span>
</Label>
<Input
id="address"
{...register("address")}
placeholder="Vejnavn og nummer"
className="h-12"
/>
</div>
</div>
</div>
)}
{/* Step 2: Floor Dimensions */}
{currentStep === 2 && (
<div className="space-y-6">
<div className="mb-6 text-center">
<h2 className="text-xl font-semibold">Hvor stort er gulvet?</h2>
<p className="mt-1 text-muted-foreground">Angiv areal og ønsket gulvhøjde</p>
</div>
<div className="space-y-8">
{/* Area Slider */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-base">Gulvareal</Label>
<div className="flex items-baseline gap-1 rounded-lg bg-muted/50 px-3 py-1">
<span className="text-2xl font-bold">{watchedValues.area}</span>
<span className="text-muted-foreground">m²</span>
</div>
</div>
<Controller
name="area"
control={control}
render={({ field }) => (
<Slider
min={CONSTRAINTS.MIN_AREA}
max={CONSTRAINTS.MAX_AREA}
step={1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
className="py-4"
/>
)}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{CONSTRAINTS.MIN_AREA} m²</span>
<span>{CONSTRAINTS.MAX_AREA} m²</span>
</div>
</div>
{/* Height Slider */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="relative text-base">
Gulvhøjde
<button
type="button"
onClick={() => setShowHeightTip(!showHeightTip)}
className="ml-1.5 inline-flex cursor-pointer items-center justify-center rounded-full border border-muted-foreground/30 text-muted-foreground hover:bg-muted/50"
style={{ width: "16px", height: "16px", fontSize: "11px", position: "relative", top: "-1px" }}
>
?
</button>
{showHeightTip && (
<span className="absolute left-0 top-full z-10 mt-1 w-48 rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-lg">
Angiv dybde fra bund til ønsket niveau
</span>
)}
</Label>
<div className="flex items-baseline gap-1 rounded-lg bg-muted/50 px-3 py-1">
<span className="text-2xl font-bold">{watchedValues.height}</span>
<span className="text-muted-foreground">cm</span>
</div>
</div>
<Controller
name="height"
control={control}
render={({ field }) => (
<Slider
min={CONSTRAINTS.MIN_HEIGHT}
max={CONSTRAINTS.MAX_HEIGHT}
step={1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
className="py-4"
/>
)}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{CONSTRAINTS.MIN_HEIGHT} cm</span>
<span>{CONSTRAINTS.MAX_HEIGHT} cm</span>
</div>
</div>
</div>
</div>
)}
{/* Step 3: Components */}
{currentStep === 3 && (
<div className="space-y-6">
<div className="mb-6 text-center">
<h2 className="text-xl font-semibold">Hvad skal inkluderes?</h2>
<p className="mt-1 text-muted-foreground">Vælg de komponenter du ønsker</p>
</div>
{/* Always included */}
<div className="mb-4 rounded-xl border border-green-200 bg-green-50 p-4">
<p className="mb-2 text-sm font-medium text-green-800">Altid inkluderet:</p>
<div className="space-y-2">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 flex-shrink-0 text-green-600" />
<div>
<span className="font-medium">Isolering</span>
<span className="ml-2 text-sm text-muted-foreground">PUR skumisolering</span>
</div>
</div>
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 flex-shrink-0 text-green-600" />
<div>
<span className="font-medium">Gulvstøbning</span>
<span className="ml-2 text-sm text-muted-foreground">Flydespartel</span>
</div>
</div>
</div>
</div>
<p className="mb-2 text-sm font-medium text-muted-foreground">Tilvalg:</p>
<div className="space-y-3">
<Controller
name="includeFloorHeating"
control={control}
render={({ field }) => (
<label
className={`flex cursor-pointer items-center justify-between rounded-xl border-2 p-4 transition-all ${
field.value ? "border-secondary bg-secondary/5" : "border-muted"
}`}
>
<div>
<div className="font-medium">Gulvvarme</div>
<div className="text-sm text-muted-foreground">
Syntetisk net + Ø16 PEX (excl. tilslutning)
</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</label>
)}
/>
</div>
{/* Flooring Type */}
{true && (
<div className="border-t pt-4">
<Label className="mb-3 block text-sm text-muted-foreground">
Hvilken gulvbelægning skal lægges?
</Label>
<Controller
name="flooringType"
control={control}
render={({ field }) => (
<div className="grid gap-2">
{Object.entries(FLOORING_TYPES).map(([key, type]) => (
<label
key={key}
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-all ${
field.value === key
? "border-secondary bg-secondary/5"
: "border-muted hover:border-muted-foreground/30"
}`}
>
<input
type="radio"
value={key}
checked={field.value === key}
onChange={() => field.onChange(key)}
className="sr-only"
/>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
field.value === key
? "border-secondary"
: "border-muted-foreground/30"
}`}
>
{field.value === key && (
<div className="h-2 w-2 rounded-full bg-secondary" />
)}
</div>
<div className="flex-1">
<span className="font-medium">{type.name}</span>
{type.compoundMultiplier > 1 && (
<span className="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-xs text-amber-600">
+28% spartel
</span>
)}
</div>
</label>
))}
</div>
)}
/>
</div>
)}
</div>
)}
{/* Step 4: Contact */}
{currentStep === 4 && (
<div className="space-y-6">
<div className="mb-6 text-center">
<h2 className="text-xl font-semibold">Dine kontaktoplysninger</h2>
<p className="mt-1 text-muted-foreground"> vi kan sende dit prisoverslag</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Navn *</Label>
<Input
id="name"
{...register("name")}
placeholder="Dit fulde navn"
className="h-12"
/>
{errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
{...register("email")}
placeholder="din@email.dk"
className="h-12"
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon *</Label>
<Input
id="phone"
{...register("phone")}
placeholder="12345678"
className="h-12"
maxLength={8}
/>
{errors.phone && (
<p className="text-sm text-destructive">{errors.phone.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="remarks">
Bemærkninger{" "}
<span className="font-normal text-muted-foreground">(valgfrit)</span>
</Label>
<Textarea
id="remarks"
{...register("remarks")}
placeholder="Særlige ønsker eller spørgsmål"
rows={3}
className="resize-none"
/>
</div>
</div>
</div>
)}
{/* Navigation */}
<div className="mt-8 flex gap-3">
{currentStep > 1 && (
<Button type="button" variant="outline" onClick={prevStep} className="h-12 flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Tilbage
</Button>
)}
{currentStep < 4 ? (
<Button
type="button"
onClick={nextStep}
className="h-12 flex-1 bg-secondary text-secondary-foreground hover:bg-secondary/90"
>
Næste
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : (
<Button
type="submit"
className="h-12 flex-1 bg-secondary text-secondary-foreground hover:bg-secondary/90"
disabled={isCalculating}
>
{isCalculating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Beregner...
</>
) : (
"Se mit prisoverslag"
)}
</Button>
)}
</div>
</form>
</div>
</div>
)
}