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

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.coerce
.number()
.min(CONSTRAINTS.MIN_AREA, `Minimum areal er ${CONSTRAINTS.MIN_AREA}`)
.max(CONSTRAINTS.MAX_AREA, `Maximum areal er ${CONSTRAINTS.MAX_AREA}`),
height: z.coerce
.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>
)
}