foamking/components/calculator/calculator-form.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

477 lines
19 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 { Calculator, Loader2, Thermometer, Layers, PaintBucket } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Switch } from "@/components/ui/switch"
import { Slider } from "@/components/ui/slider"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Progress } from "@/components/ui/progress"
import { CONSTRAINTS, FLOORING_TYPES, type FlooringType } from "@/lib/constants"
import { validateDanishPostalCode, getDistance } from "@/lib/distance"
import { calculatePrice, type CalculationDetails } from "@/lib/calculations"
const formSchema = z.object({
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"),
postalCode: z
.string()
.length(4, "Postnummer skal være 4 cifre")
.refine(validateDanishPostalCode, "Ugyldigt dansk postnummer"),
address: z.string().optional(),
area: z
.number()
.min(CONSTRAINTS.MIN_AREA, `Minimum areal er ${CONSTRAINTS.MIN_AREA}`)
.max(CONSTRAINTS.MAX_AREA, `Maximum areal er ${CONSTRAINTS.MAX_AREA}`),
height: z
.number()
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum højde er ${CONSTRAINTS.MIN_HEIGHT} cm`)
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum højde er ${CONSTRAINTS.MAX_HEIGHT} cm`),
remarks: z.string().optional(),
includeInsulation: z.boolean(),
includeFloorHeating: z.boolean(),
includeCompound: z.boolean(),
flooringType: z.string(),
})
type FormData = z.infer<typeof formSchema>
interface CalculatorFormProps {
onCalculation: (
result: CalculationDetails,
formData?: FormData,
distanceSource?: "openrouteservice" | "table"
) => void
showDetails?: boolean
}
interface CalculationProgress {
step: string
progress: number
}
export function CalculatorForm({ onCalculation, showDetails = false }: CalculatorFormProps) {
const [isCalculating, setIsCalculating] = useState(false)
const [calculationProgress, setCalculationProgress] = useState<CalculationProgress | null>(null)
const [result, setResult] = useState<CalculationDetails | null>(null)
const [distanceSource, setDistanceSource] = useState<"openrouteservice" | "table" | null>(null)
const {
register,
handleSubmit,
formState: { errors },
watch,
control,
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
email: "",
phone: "",
postalCode: "",
address: "",
area: 50,
height: 15,
remarks: "",
includeInsulation: true,
includeFloorHeating: true,
includeCompound: true,
flooringType: "STANDARD",
},
})
const watchedIncludeCompound = watch("includeCompound")
const onSubmit = async (data: FormData) => {
setIsCalculating(true)
setDistanceSource(null)
setCalculationProgress({ step: "Finder din adresse...", progress: 20 })
try {
let distance: number
let source: "openrouteservice" | "table" = "table"
try {
setCalculationProgress({ step: "Beregner afstand...", progress: 40 })
const params = new URLSearchParams({
postalCode: data.postalCode,
...(data.address && { address: data.address }),
})
const distanceResponse = await fetch(`/api/distance?${params}`)
const distanceData = await distanceResponse.json()
distance = distanceData.distance
source = distanceData.source
} catch {
distance = getDistance(data.postalCode)
source = "table"
}
setDistanceSource(source)
setCalculationProgress({ step: "Beregner pris...", progress: 70 })
await new Promise((resolve) => setTimeout(resolve, 200))
const calculationResult = 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,
})
setCalculationProgress({ step: "Færdig!", progress: 100 })
await new Promise((resolve) => setTimeout(resolve, 300))
setResult(calculationResult)
onCalculation(calculationResult, data, source)
} finally {
setIsCalculating(false)
setCalculationProgress(null)
}
}
return (
<Card className="w-full max-w-2xl shadow-lg">
<CardHeader className="rounded-t-lg bg-gradient-to-r from-secondary/10 to-secondary/5">
<CardTitle className="flex items-center gap-3 text-2xl">
<div className="rounded-full bg-primary p-2">
<Calculator className="h-5 w-5 text-secondary-foreground" />
</div>
Prisberegner
</CardTitle>
<CardDescription className="text-base">
et hurtigt overslag din nye gulvløsning
</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
{/* Contact Section */}
<section>
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Kontaktoplysninger
</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Navn</Label>
<Input id="name" {...register("name")} placeholder="Dit navn" />
{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" />
{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" />
{errors.phone && <p className="text-sm text-destructive">{errors.phone.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="postalCode">Postnummer</Label>
<Input id="postalCode" {...register("postalCode")} placeholder="4550" />
{errors.postalCode && (
<p className="text-sm text-destructive">{errors.postalCode.message}</p>
)}
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="address">
Adresse <span className="font-normal text-muted-foreground">(valgfrit)</span>
</Label>
<Input id="address" {...register("address")} placeholder="Vejnavn og nummer" />
</div>
</div>
</section>
{/* Floor Dimensions Section */}
<section>
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Gulvmål
</h3>
<div className="space-y-6 rounded-xl bg-muted/30 p-5">
{/* Area Slider */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-base">Gulvareal</Label>
<div className="flex items-baseline gap-1">
<Controller
name="area"
control={control}
render={({ field }) => (
<Input
type="number"
value={field.value}
onChange={(e) => field.onChange(Number(e.target.value))}
className="h-8 w-16 border-0 bg-transparent p-0 text-right text-lg font-semibold"
min={CONSTRAINTS.MIN_AREA}
max={CONSTRAINTS.MAX_AREA}
/>
)}
/>
<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={5}
value={[field.value || CONSTRAINTS.MIN_AREA]}
onValueChange={([value]) => field.onChange(value)}
className="py-2"
/>
)}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{CONSTRAINTS.MIN_AREA} m²</span>
<span>{CONSTRAINTS.MAX_AREA} m²</span>
</div>
{errors.area && <p className="text-sm text-destructive">{errors.area.message}</p>}
</div>
{/* Height Slider */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-base">Gulvhøjde</Label>
<div className="flex items-baseline gap-1">
<Controller
name="height"
control={control}
render={({ field }) => (
<Input
type="number"
value={field.value}
onChange={(e) => field.onChange(Number(e.target.value))}
className="h-8 w-16 border-0 bg-transparent p-0 text-right text-lg font-semibold"
min={CONSTRAINTS.MIN_HEIGHT}
max={CONSTRAINTS.MAX_HEIGHT}
/>
)}
/>
<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 || CONSTRAINTS.MIN_HEIGHT]}
onValueChange={([value]) => field.onChange(value)}
className="py-2"
/>
)}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{CONSTRAINTS.MIN_HEIGHT} cm</span>
<span>{CONSTRAINTS.MAX_HEIGHT} cm</span>
</div>
{errors.height && (
<p className="text-sm text-destructive">{errors.height.message}</p>
)}
</div>
</div>
</section>
{/* Components Section */}
<section>
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Vælg komponenter
</h3>
<div className="grid gap-3">
{/* Insulation Toggle */}
<Controller
name="includeInsulation"
control={control}
render={({ field }) => (
<label
className={`flex cursor-pointer items-center gap-4 rounded-xl border-2 p-4 transition-all ${
field.value
? "border-secondary bg-secondary/10"
: "border-muted hover:border-muted-foreground/30"
}`}
>
<div
className={`rounded-lg p-2 ${field.value ? "bg-secondary text-secondary-foreground" : "bg-muted"}`}
>
<Layers className="h-5 w-5" />
</div>
<div className="flex-1">
<div className="font-medium">Isolering</div>
<div className="text-sm text-muted-foreground">
Gulvisolering under varmeanlæg
</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</label>
)}
/>
{/* Floor Heating Toggle */}
<Controller
name="includeFloorHeating"
control={control}
render={({ field }) => (
<label
className={`flex cursor-pointer items-center gap-4 rounded-xl border-2 p-4 transition-all ${
field.value
? "border-secondary bg-secondary/10"
: "border-muted hover:border-muted-foreground/30"
}`}
>
<div
className={`rounded-lg p-2 ${field.value ? "bg-secondary text-secondary-foreground" : "bg-muted"}`}
>
<Thermometer className="h-5 w-5" />
</div>
<div className="flex-1">
<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>
)}
/>
{/* Compound Toggle */}
<Controller
name="includeCompound"
control={control}
render={({ field }) => (
<label
className={`flex cursor-pointer items-center gap-4 rounded-xl border-2 p-4 transition-all ${
field.value
? "border-secondary bg-secondary/10"
: "border-muted hover:border-muted-foreground/30"
}`}
>
<div
className={`rounded-lg p-2 ${field.value ? "bg-secondary text-secondary-foreground" : "bg-muted"}`}
>
<PaintBucket className="h-5 w-5" />
</div>
<div className="flex-1">
<div className="font-medium">Gulvstøbning</div>
<div className="text-sm text-muted-foreground">
Flydespartel til færdigt gulv
</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</label>
)}
/>
</div>
</section>
{/* Flooring Type Section */}
{watchedIncludeCompound && (
<section>
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Gulvbelægning
</h3>
<Controller
name="flooringType"
control={control}
render={({ field }) => (
<div className="grid gap-2 sm:grid-cols-3">
{Object.entries(FLOORING_TYPES).map(([key, type]) => (
<label
key={key}
className={`flex cursor-pointer flex-col items-center gap-2 rounded-xl border-2 p-4 text-center transition-all ${
field.value === key
? "border-secondary bg-secondary/10"
: "border-muted hover:border-muted-foreground/30"
}`}
>
<input
type="radio"
value={key}
checked={field.value === key}
onChange={() => field.onChange(key)}
className="sr-only"
/>
<span className="font-medium">{type.name}</span>
<span className="text-xs text-muted-foreground">{type.description}</span>
{type.compoundMultiplier > 1 && (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-600">
+28% spartel
</span>
)}
</label>
))}
</div>
)}
/>
</section>
)}
{/* Remarks Section */}
<section>
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Bemærkninger <span className="font-normal">(valgfrit)</span>
</h3>
<Textarea
{...register("remarks")}
placeholder="Eventuelle særlige ønsker eller spørgsmål"
rows={3}
className="resize-none"
/>
</section>
{/* Progress Indicator */}
{calculationProgress && (
<div className="space-y-2 rounded-xl border border-secondary/30 bg-secondary/10 p-4">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{calculationProgress.step}</span>
<span className="text-muted-foreground">{calculationProgress.progress}%</span>
</div>
<Progress value={calculationProgress.progress} className="h-2" />
</div>
)}
<Button
type="submit"
size="lg"
className="h-12 w-full text-base font-semibold"
disabled={isCalculating}
>
{isCalculating ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Beregner...
</>
) : (
"Beregn pris"
)}
</Button>
</form>
</CardContent>
</Card>
)
}