foamking/components/calculator/calculator-form.tsx
mikl0s 7d2bbae1c6 Initial implementation of Foam King Gulve price calculator
Features:
- Complete Next.js 16 app with TypeScript and Tailwind CSS
- Customer-facing price calculator form with validation
- Admin mode showing detailed price breakdowns
- Accurate price calculations matching business requirements
- Responsive design with custom shadcn/ui theme
- API endpoint for quote requests
- Danish postal code distance calculations
- Complete test coverage against documentation examples

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-10 14:27:28 +00:00

233 lines
No EOL
7.6 KiB
TypeScript

"use client"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Calculator, Loader2 } 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 { CONSTRAINTS } from "@/lib/constants"
import { validateDanishPostalCode, isInCoverageArea, getDistance } from "@/lib/distance"
import { calculatePrice, formatEstimate, type CalculationDetails } from "@/lib/calculations"
const formSchema = z.object({
name: z.string().min(2, "Navn skal være mindst 2 tegn"),
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")
.refine(isInCoverageArea, "Beklager, vi dækker ikke dette område"),
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(),
})
type FormData = z.infer<typeof formSchema>
interface CalculatorFormProps {
onCalculation: (result: CalculationDetails, formData?: FormData) => void
showDetails?: boolean
}
export function CalculatorForm({ onCalculation, showDetails = false }: CalculatorFormProps) {
const [isCalculating, setIsCalculating] = useState(false)
const [result, setResult] = useState<CalculationDetails | null>(null)
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<FormData>({
resolver: zodResolver(formSchema),
})
const onSubmit = async (data: FormData) => {
setIsCalculating(true)
try {
// Simulate API delay
await new Promise((resolve) => setTimeout(resolve, 500))
const distance = getDistance(data.postalCode)
const calculationResult = calculatePrice({
area: data.area,
height: data.height,
postalCode: data.postalCode,
distance,
})
setResult(calculationResult)
onCalculation(calculationResult, data)
} finally {
setIsCalculating(false)
}
}
return (
<Card className="w-full max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-2xl">
<Calculator className="h-6 w-6" />
Prisberegner
</CardTitle>
<CardDescription>
et hurtigt overslag din nye gulvløsning
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="name">Navn *</Label>
<Input
id="name"
{...register("name")}
placeholder="Dit navn"
className="mt-1"
/>
{errors.name && (
<p className="mt-1 text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<div>
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
{...register("email")}
placeholder="din@email.dk"
className="mt-1"
/>
{errors.email && (
<p className="mt-1 text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div>
<Label htmlFor="phone">Telefon *</Label>
<Input
id="phone"
{...register("phone")}
placeholder="12345678"
className="mt-1"
/>
{errors.phone && (
<p className="mt-1 text-sm text-destructive">{errors.phone.message}</p>
)}
</div>
<div>
<Label htmlFor="postalCode">Postnummer *</Label>
<Input
id="postalCode"
{...register("postalCode")}
placeholder="4550"
className="mt-1"
/>
{errors.postalCode && (
<p className="mt-1 text-sm text-destructive">{errors.postalCode.message}</p>
)}
</div>
</div>
<div>
<Label htmlFor="address">Adresse</Label>
<Input
id="address"
{...register("address")}
placeholder="Vejnavn og nummer (valgfrit)"
className="mt-1"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="area">
Gulvareal (m²) *
<span className="ml-1 text-xs text-muted-foreground">
({CONSTRAINTS.MIN_AREA}-{CONSTRAINTS.MAX_AREA} m²)
</span>
</Label>
<Input
id="area"
type="number"
{...register("area")}
placeholder="50"
className="mt-1"
/>
{errors.area && (
<p className="mt-1 text-sm text-destructive">{errors.area.message}</p>
)}
</div>
<div>
<Label htmlFor="height">
Gulvhøjde (cm) *
<span className="ml-1 text-xs text-muted-foreground">
({CONSTRAINTS.MIN_HEIGHT}-{CONSTRAINTS.MAX_HEIGHT} cm)
</span>
</Label>
<Input
id="height"
type="number"
{...register("height")}
placeholder="20"
className="mt-1"
/>
{errors.height && (
<p className="mt-1 text-sm text-destructive">{errors.height.message}</p>
)}
</div>
</div>
<div>
<Label htmlFor="remarks">Bemærkninger</Label>
<Textarea
id="remarks"
{...register("remarks")}
placeholder="Eventuelle særlige ønsker eller spørgsmål"
className="mt-1"
rows={3}
/>
</div>
<Button type="submit" size="lg" className="w-full" disabled={isCalculating}>
{isCalculating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Beregner...
</>
) : (
"Beregn pris"
)}
</Button>
</form>
{result && !showDetails && (
<div className="mt-6 rounded-lg bg-muted p-6 text-center">
<p className="text-3xl font-bold">{formatEstimate(result.totalInclVat)}</p>
<p className="mt-2 text-sm text-muted-foreground">
*Prisen er vejledende og kan variere afhængigt af konkrete forhold
</p>
</div>
)}
</CardContent>
</Card>
)
}