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>
570 lines
21 KiB
TypeScript
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} m²`)
|
|
.max(CONSTRAINTS.MAX_AREA, `Maximum ${CONSTRAINTS.MAX_AREA} m²`),
|
|
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">Så 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>
|
|
)
|
|
}
|