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