Add premium configurator with split-screen layout

- Redesigned configurator page with split-screen interface
- Left: Large visual preview with sticky positioning
- Right: Premium white controls container with form steps
- Added complete configurator wizard (5 steps)
- Updated hero CTA to "Zelf ontwerpen"
- Configured Shadcn UI with Slate theme
- Added layout components (Navbar, Footer)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Ubuntu
2026-02-10 15:59:37 +00:00
parent c283d7193a
commit 9cf5cea3ba
55 changed files with 8411 additions and 99 deletions

View File

@@ -0,0 +1,76 @@
"use client";
import {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
} from "react";
import type { QuoteData } from "@/lib/validators";
const TOTAL_STEPS = 5; // 4 form steps + 1 summary
type FormData = Partial<QuoteData>;
interface FormContextValue {
currentStep: number;
formData: FormData;
totalSteps: number;
nextStep: () => void;
prevStep: () => void;
goToStep: (step: number) => void;
updateData: (data: Partial<FormData>) => void;
reset: () => void;
}
const FormContext = createContext<FormContextValue | null>(null);
export function FormProvider({ children }: { children: ReactNode }) {
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState<FormData>({});
const nextStep = useCallback(() => {
setCurrentStep((s) => Math.min(s + 1, TOTAL_STEPS - 1));
}, []);
const prevStep = useCallback(() => {
setCurrentStep((s) => Math.max(s - 1, 0));
}, []);
const goToStep = useCallback((step: number) => {
setCurrentStep(Math.max(0, Math.min(step, TOTAL_STEPS - 1)));
}, []);
const updateData = useCallback((data: Partial<FormData>) => {
setFormData((prev) => ({ ...prev, ...data }));
}, []);
const reset = useCallback(() => {
setCurrentStep(0);
setFormData({});
}, []);
return (
<FormContext.Provider
value={{
currentStep,
formData,
totalSteps: TOTAL_STEPS,
nextStep,
prevStep,
goToStep,
updateData,
reset,
}}
>
{children}
</FormContext.Provider>
);
}
export function useFormContext() {
const ctx = useContext(FormContext);
if (!ctx) throw new Error("useFormContext must be used within <FormProvider>");
return ctx;
}

View File

@@ -0,0 +1,83 @@
"use client";
import { useFormContext } from "@/components/offerte/form-context";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { User, Mail, Phone, MessageSquare } from "lucide-react";
export function StepContact() {
const { formData, updateData } = useFormContext();
return (
<div>
<h2 className="mb-2 text-xl font-semibold">Contactgegevens</h2>
<p className="mb-6 text-sm text-muted-foreground">
Vul uw gegevens in zodat wij u een offerte kunnen sturen.
</p>
<div className="grid gap-5">
<div className="space-y-2">
<Label htmlFor="name" className="flex items-center gap-2">
<User className="size-4 text-brand-orange" />
Naam
</Label>
<Input
id="name"
placeholder="Uw volledige naam"
value={formData.name ?? ""}
onChange={(e) => updateData({ name: e.target.value })}
className="h-11 focus-visible:ring-brand-orange"
/>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">
<Mail className="size-4 text-brand-orange" />
E-mail
</Label>
<Input
id="email"
type="email"
placeholder="naam@bedrijf.nl"
value={formData.email ?? ""}
onChange={(e) => updateData({ email: e.target.value })}
className="h-11 focus-visible:ring-brand-orange"
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone" className="flex items-center gap-2">
<Phone className="size-4 text-brand-orange" />
Telefoon
</Label>
<Input
id="phone"
type="tel"
placeholder="06 1234 5678"
value={formData.phone ?? ""}
onChange={(e) => updateData({ phone: e.target.value })}
className="h-11 focus-visible:ring-brand-orange"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="note" className="flex items-center gap-2">
<MessageSquare className="size-4 text-brand-orange" />
Opmerking
<span className="text-xs font-normal text-muted-foreground">(optioneel)</span>
</Label>
<textarea
id="note"
rows={3}
placeholder="Bijv. specifieke wensen, RAL-kleur, plaatsing..."
value={formData.note ?? ""}
onChange={(e) => updateData({ note: e.target.value })}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import { useFormContext } from "@/components/offerte/form-context";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Ruler } from "lucide-react";
export function StepDimensions() {
const { formData, updateData } = useFormContext();
return (
<div>
<h2 className="mb-2 text-xl font-semibold">Afmetingen</h2>
<p className="mb-6 text-sm text-muted-foreground">
Voer de gewenste afmetingen in millimeters in.
</p>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="height" className="flex items-center gap-2">
<Ruler className="size-4 text-brand-orange" />
Hoogte (mm)
</Label>
<Input
id="height"
type="number"
placeholder="bijv. 2400"
value={formData.height ?? ""}
onChange={(e) => {
const val = e.target.value;
updateData({ height: val === "" ? undefined : Number(val) });
}}
className="h-12 text-lg focus-visible:ring-brand-orange"
/>
<p className="text-xs text-muted-foreground">
Min: 2000mm &mdash; Max: 3000mm
</p>
</div>
<div className="space-y-2">
<Label htmlFor="width" className="flex items-center gap-2">
<Ruler className="size-4 rotate-90 text-brand-orange" />
Breedte (mm)
</Label>
<Input
id="width"
type="number"
placeholder="bijv. 900"
value={formData.width ?? ""}
onChange={(e) => {
const val = e.target.value;
updateData({ width: val === "" ? undefined : Number(val) });
}}
className="h-12 text-lg focus-visible:ring-brand-orange"
/>
<p className="text-xs text-muted-foreground">
Min: 300mm &mdash; Max: 3000mm
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { useFormContext } from "@/components/offerte/form-context";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { glassTypes, finishTypes } from "@/lib/validators";
import { Paintbrush, GlassWater } from "lucide-react";
export function StepOptions() {
const { formData, updateData } = useFormContext();
return (
<div>
<h2 className="mb-2 text-xl font-semibold">Opties</h2>
<p className="mb-6 text-sm text-muted-foreground">
Kies de afwerking en het glastype voor uw product.
</p>
<div className="grid gap-8 sm:grid-cols-2">
{/* Glass Type */}
<div className="space-y-4">
<Label className="flex items-center gap-2 text-base font-semibold">
<GlassWater className="size-4 text-brand-orange" />
Glas Type
</Label>
<RadioGroup
value={formData.glassType ?? ""}
onValueChange={(val) => updateData({ glassType: val as typeof formData.glassType })}
className="space-y-2"
>
{glassTypes.map((type) => (
<Label
key={type}
htmlFor={`glass-${type}`}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-border p-4 transition-colors has-[[data-state=checked]]:border-brand-orange has-[[data-state=checked]]:bg-brand-orange/5"
>
<RadioGroupItem value={type} id={`glass-${type}`} />
<span className="text-sm font-medium">{type}</span>
</Label>
))}
</RadioGroup>
</div>
{/* Finish */}
<div className="space-y-4">
<Label className="flex items-center gap-2 text-base font-semibold">
<Paintbrush className="size-4 text-brand-orange" />
Afwerking
</Label>
<RadioGroup
value={formData.finish ?? ""}
onValueChange={(val) => updateData({ finish: val as typeof formData.finish })}
className="space-y-2"
>
{finishTypes.map((type) => (
<Label
key={type}
htmlFor={`finish-${type}`}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-border p-4 transition-colors has-[[data-state=checked]]:border-brand-orange has-[[data-state=checked]]:bg-brand-orange/5"
>
<RadioGroupItem value={type} id={`finish-${type}`} />
<span className="text-sm font-medium">{type}</span>
</Label>
))}
</RadioGroup>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import Image from "next/image";
import { useFormContext } from "@/components/offerte/form-context";
import { productTypes } from "@/lib/validators";
import { cn } from "@/lib/utils";
const productImages: Record<string, string> = {
Taatsdeur: "/images/taats.jpg",
Scharnierdeur: "/images/scharnier.jpg",
"Vast Paneel": "/images/paneel.jpg",
};
const productDescriptions: Record<string, string> = {
Taatsdeur: "Pivoterende deur",
Scharnierdeur: "Klassiek scharnier",
"Vast Paneel": "Vast glaspaneel",
};
export function StepProduct() {
const { formData, updateData, nextStep } = useFormContext();
function select(type: (typeof productTypes)[number]) {
updateData({ productType: type });
nextStep();
}
return (
<div>
<h2 className="mb-2 text-xl font-semibold">Kies uw product</h2>
<p className="mb-6 text-sm text-muted-foreground">
Selecteer het type stalen element dat u wilt configureren.
</p>
<div className="grid gap-4 sm:grid-cols-3">
{productTypes.map((type) => {
const selected = formData.productType === type;
return (
<button
key={type}
type="button"
onClick={() => select(type)}
className={cn(
"group relative aspect-[3/4] overflow-hidden text-left transition-all",
selected
? "ring-4 ring-brand-orange ring-offset-2"
: "ring-0 hover:ring-2 hover:ring-brand-orange/40 hover:ring-offset-1"
)}
>
{/* Image fills entire card */}
<Image
src={productImages[type]}
alt={type}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{/* Bottom gradient with label */}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-4 pb-5 pt-16">
<p className="text-xs font-medium uppercase tracking-wider text-white/60">
{productDescriptions[type]}
</p>
<h3 className="mt-1 text-lg font-semibold text-white">
{type}
</h3>
</div>
{/* Selected state overlay */}
{selected && (
<div className="absolute inset-0 border-4 border-brand-orange" />
)}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { useFormContext } from "@/components/offerte/form-context";
import { Button } from "@/components/ui/button";
import { Send, Check } from "lucide-react";
const fieldLabels: Record<string, string> = {
productType: "Product",
height: "Hoogte",
width: "Breedte",
glassType: "Glas Type",
finish: "Afwerking",
name: "Naam",
email: "E-mail",
phone: "Telefoon",
note: "Opmerking",
};
const fieldOrder = [
"productType",
"height",
"width",
"glassType",
"finish",
"name",
"email",
"phone",
"note",
];
function formatValue(key: string, value: unknown): string {
if (value === undefined || value === null || value === "") return "—";
if (key === "height" || key === "width") return `${value} mm`;
return String(value);
}
export function StepSummary() {
const { formData } = useFormContext();
return (
<div>
<h2 className="mb-2 text-xl font-semibold">Overzicht</h2>
<p className="mb-6 text-sm text-muted-foreground">
Controleer uw configuratie en verstuur de aanvraag.
</p>
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-sm">
<tbody>
{fieldOrder.map((key, i) => {
const value = formData[key as keyof typeof formData];
return (
<tr
key={key}
className={i % 2 === 0 ? "bg-muted/30" : "bg-card"}
>
<td className="w-1/3 px-4 py-3 font-medium text-muted-foreground">
{fieldLabels[key]}
</td>
<td className="px-4 py-3 font-medium">
<span className="flex items-center gap-2">
{value !== undefined && value !== "" && (
<Check className="size-3.5 text-green-600" />
)}
{formatValue(key, value)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<Button
size="lg"
className="mt-8 w-full bg-brand-orange text-white hover:bg-brand-orange/90"
>
<Send className="size-4" />
Verzend Aanvraag
</Button>
</div>
);
}