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>
477 lines
19 KiB
TypeScript
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} m²`)
|
|
.max(CONSTRAINTS.MAX_AREA, `Maximum areal er ${CONSTRAINTS.MAX_AREA} m²`),
|
|
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">
|
|
Få et hurtigt overslag på 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>
|
|
)
|
|
}
|