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:
76
components/offerte/form-context.tsx
Normal file
76
components/offerte/form-context.tsx
Normal 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;
|
||||
}
|
||||
83
components/offerte/step-contact.tsx
Normal file
83
components/offerte/step-contact.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
components/offerte/step-dimensions.tsx
Normal file
63
components/offerte/step-dimensions.tsx
Normal 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 — 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 — Max: 3000mm
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
components/offerte/step-options.tsx
Normal file
70
components/offerte/step-options.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
components/offerte/step-product.tsx
Normal file
79
components/offerte/step-product.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
components/offerte/step-summary.tsx
Normal file
84
components/offerte/step-summary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user