foamking/components/calculator/step-wizard.tsx
mikl0s 3a54ba40d3 fix: update quotes route to use checkAuth, fix zod type errors
Replace getCurrentUser import with checkAuth in quotes API route.
Fix z.coerce.number() type mismatch with zodResolver in calculator
forms by using z.number() directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:01:36 +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
.number()
.min(CONSTRAINTS.MIN_AREA, `Minimum ${CONSTRAINTS.MIN_AREA}`)
.max(CONSTRAINTS.MAX_AREA, `Maximum ${CONSTRAINTS.MAX_AREA}`),
height: z
.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>
)
}