feat: Latest production version with interior scene and glass

Includes room interior with floor, walls, glass you can see through,
and all uncommitted production changes that were running live.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu
2026-03-01 14:50:31 +00:00
parent 748a5814e7
commit 3d788740cb
110 changed files with 162553 additions and 13070 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { useRef, useMemo, Suspense } from "react";
import { useConfiguratorStore } from "@/lib/store";
import { useConfiguratorStore, type GlassColor, type Finish } from "@/lib/store";
import { RoundedBox, useTexture } from "@react-three/drei";
import * as THREE from "three";
import {
@@ -26,28 +26,58 @@ import {
type PhysicalPart,
} from "@/lib/door-models";
// ============================================
// FRAME COLOR MAPPING
// ============================================
const FRAME_COLORS: Record<Finish, string> = {
zwart: "#1a1a1a",
brons: "#8B6F47",
grijs: "#525252",
goud: "#B8860B",
beige: "#C8B88A",
ral: "#4A6741",
};
const FRAME_TEXTURE_PATHS: Record<Finish, string> = {
zwart: "/textures/proinn/proinn-metaalkleur-zwart.jpg",
brons: "/textures/proinn/proinn-metaalkleur-brons.jpg",
grijs: "/textures/proinn/proinn-metaalkleur-antraciet.jpg",
goud: "/textures/proinn/proinn-metaalkleur-goud.jpg",
beige: "/textures/proinn/proinn-metaalkleur-beige.jpg",
ral: "/textures/proinn/proinn-metaalkleur-ral-keuze.jpg",
};
// ============================================
// GLASS COLOR MAPPING
// ============================================
interface GlassColorProps {
color: string;
transmission: number;
roughness: number;
}
const GLASS_COLOR_MAP: Record<GlassColor, GlassColorProps> = {
helder: { color: "#eff6ff", transmission: 0.98, roughness: 0.05 },
grijs: { color: "#3a3a3a", transmission: 0.85, roughness: 0.1 },
brons: { color: "#8B6F47", transmission: 0.85, roughness: 0.1 },
"mat-blank": { color: "#e8e8e8", transmission: 0.7, roughness: 0.3 },
"mat-brons": { color: "#A0845C", transmission: 0.6, roughness: 0.35 },
"mat-zwart": { color: "#1a1a1a", transmission: 0.5, roughness: 0.4 },
};
// ============================================
// PHOTOREALISTIC MATERIALS
// ============================================
/**
* Steel Material with Aluwdoors Texture
* Vertical steel grain for industrial look
*/
function SteelMaterialTextured({ color, finish }: { color: string; finish: string }) {
function SteelMaterialTextured({ color, finish }: { color: string; finish: Finish }) {
try {
// Load texture based on finish
const texturePath = {
zwart: "/textures/aluwdoors/aluwdoors-configurator-metaalkleur-zwart.jpg",
brons: "/textures/aluwdoors/aluwdoors-configurator-metaalkleur-brons.jpg",
grijs: "/textures/aluwdoors/aluwdoors-configurator-metaalkleur-antraciet.jpg",
}[finish] || "/textures/aluwdoors/aluwdoors-configurator-metaalkleur-zwart.jpg";
const texturePath = FRAME_TEXTURE_PATHS[finish];
const texture = useTexture(texturePath);
// Configure texture for vertical steel grain
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(0.5, 3); // Vertical grain
texture.repeat.set(0.5, 3);
texture.colorSpace = THREE.SRGBColorSpace;
return (
@@ -59,14 +89,11 @@ function SteelMaterialTextured({ color, finish }: { color: string; finish: strin
envMapIntensity={1.5}
/>
);
} catch (error) {
} catch {
return <SteelMaterialFallback color={color} />;
}
}
/**
* Fallback Steel Material (Solid Color)
*/
function SteelMaterialFallback({ color }: { color: string }) {
return (
<meshStandardMaterial
@@ -78,40 +105,37 @@ function SteelMaterialFallback({ color }: { color: string }) {
);
}
/**
* Photorealistic Glass Material
* High transmission for realistic glass look
*/
const GlassMaterial = () => (
<meshPhysicalMaterial
transmission={0.98}
roughness={0.05}
thickness={0.007}
ior={1.5}
color="#eff6ff"
transparent
opacity={0.98}
envMapIntensity={1.0}
/>
);
function GlassMaterial({ glassColor }: { glassColor: GlassColor }) {
const props = GLASS_COLOR_MAP[glassColor];
return (
<meshPhysicalMaterial
transmission={props.transmission}
roughness={props.roughness}
thickness={0.007}
ior={1.5}
color={props.color}
transparent
opacity={0.98}
envMapIntensity={1.0}
/>
);
}
// ============================================
// PHYSICAL PART RENDERER
// ============================================
/**
* Renders a single physical part with correct geometry
*/
function PhysicalPartComponent({
part,
frameColor,
finish,
glassColor,
}: {
part: PhysicalPart;
frameColor: string;
finish: string;
finish: Finish;
glassColor: GlassColor;
}) {
// Convert mm to meters
const x = mmToMeters(part.x);
const y = mmToMeters(part.y);
const z = mmToMeters(part.z);
@@ -119,17 +143,15 @@ function PhysicalPartComponent({
const height = mmToMeters(part.height);
const depth = mmToMeters(part.depth);
// Glass uses different material
if (part.isGlass) {
return (
<mesh position={[x, y, z]} castShadow receiveShadow>
<boxGeometry args={[width, height, depth]} />
<GlassMaterial />
<GlassMaterial glassColor={glassColor} />
</mesh>
);
}
// Steel profiles use RoundedBox for realistic edges
const cornerRadius = mmToMeters(PROFILE_CORNER_RADIUS);
return (
@@ -153,32 +175,21 @@ function PhysicalPartComponent({
// ============================================
export function Door3DEnhanced() {
const { doorType, gridType, finish, handle, glassPattern, doorLeafWidth, height } =
const { doorType, gridType, finish, handle, glassPattern, glassColor, doorLeafWidth, height } =
useConfiguratorStore();
const doorRef = useRef<THREE.Group>(null);
// Frame color based on finish
const frameColor = {
zwart: "#1a1a1a",
brons: "#8B6F47",
grijs: "#525252",
}[finish] || "#1a1a1a";
const frameColor = FRAME_COLORS[finish] || "#1a1a1a";
// Generate door assembly from manufacturing specs
const doorAssembly = useMemo(
() => generateDoorAssembly(doorType, gridType, doorLeafWidth, height),
[doorType, gridType, doorLeafWidth, height]
);
// Convert dimensions to meters
const doorWidth = mmToMeters(doorLeafWidth);
const doorHeight = mmToMeters(height);
// Profile dimensions in meters (for handle positioning)
const stileWidth = mmToMeters(40);
const railDepth = mmToMeters(40);
// Get divider positions for glass patterns (backward compatibility)
const dividerPositions = getDividerPositions(gridType, height);
return (
@@ -190,6 +201,7 @@ export function Door3DEnhanced() {
part={part}
frameColor={frameColor}
finish={finish}
glassColor={glassColor}
/>
))}
@@ -208,13 +220,12 @@ export function Door3DEnhanced() {
{ depth: 0.01, bevelEnabled: false },
]}
/>
<GlassMaterial />
<GlassMaterial glassColor={glassColor} />
</mesh>
)}
{glassPattern === "dt10-ushape" && dividerPositions.length > 0 && (
<>
{/* Top section - Inverted U */}
<mesh
position={[0, (doorHeight / 4 + dividerPositions[0]) / 2, 0]}
castShadow
@@ -229,10 +240,9 @@ export function Door3DEnhanced() {
{ depth: 0.01, bevelEnabled: false },
]}
/>
<GlassMaterial />
<GlassMaterial glassColor={glassColor} />
</mesh>
{/* Bottom section - Normal U */}
<mesh
position={[
0,
@@ -255,7 +265,7 @@ export function Door3DEnhanced() {
{ depth: 0.01, bevelEnabled: false },
]}
/>
<GlassMaterial />
<GlassMaterial glassColor={glassColor} />
</mesh>
</>
)}
@@ -264,58 +274,22 @@ export function Door3DEnhanced() {
{/* PROFESSIONAL 3D HANDLES */}
{handle === "beugelgreep" && (
<Beugelgreep
finish={finish}
doorWidth={doorWidth}
doorHeight={doorHeight}
railDepth={railDepth}
stileWidth={stileWidth}
/>
<Beugelgreep finish={finish} doorWidth={doorWidth} doorHeight={doorHeight} railDepth={railDepth} stileWidth={stileWidth} />
)}
{handle === "hoekgreep" && (
<Hoekgreep
finish={finish}
doorWidth={doorWidth}
doorHeight={doorHeight}
railDepth={railDepth}
stileWidth={stileWidth}
/>
<Hoekgreep finish={finish} doorWidth={doorWidth} doorHeight={doorHeight} railDepth={railDepth} stileWidth={stileWidth} />
)}
{handle === "maangreep" && (
<Maangreep
finish={finish}
doorWidth={doorWidth}
doorHeight={doorHeight}
railDepth={railDepth}
stileWidth={stileWidth}
/>
<Maangreep finish={finish} doorWidth={doorWidth} doorHeight={doorHeight} railDepth={railDepth} stileWidth={stileWidth} />
)}
{handle === "ovaalgreep" && (
<Ovaalgreep
finish={finish}
doorWidth={doorWidth}
doorHeight={doorHeight}
railDepth={railDepth}
stileWidth={stileWidth}
/>
<Ovaalgreep finish={finish} doorWidth={doorWidth} doorHeight={doorHeight} railDepth={railDepth} stileWidth={stileWidth} />
)}
{handle === "klink" && (
<Klink
finish={finish}
doorWidth={doorWidth}
doorHeight={doorHeight}
railDepth={railDepth}
stileWidth={stileWidth}
/>
<Klink finish={finish} doorWidth={doorWidth} doorHeight={doorHeight} railDepth={railDepth} stileWidth={stileWidth} />
)}
{handle === "u-greep" && (
<UGreep
finish={finish}
doorWidth={doorWidth}
doorHeight={doorHeight}
railDepth={railDepth}
stileWidth={stileWidth}
/>
<UGreep finish={finish} doorWidth={doorWidth} doorHeight={doorHeight} railDepth={railDepth} stileWidth={stileWidth} />
)}
</group>
);

View File

@@ -36,11 +36,14 @@ export function Door3D() {
const doorRef = useRef<THREE.Group>(null);
// Frame color based on finish
const frameColor = {
const frameColor = ({
zwart: "#1a1a1a",
brons: "#8B6F47",
grijs: "#525252",
}[finish];
goud: "#b8960c",
beige: "#c8b88a",
ral: "#2a2a2a",
} as Record<string, string>)[finish] ?? "#1a1a1a";
// Convert mm to meters for 3D scene
const doorWidth = doorLeafWidth / 1000; // Convert mm to m

View File

@@ -1,8 +1,9 @@
"use client";
import { Suspense } from "react";
import { Suspense, useCallback } from "react";
import { useConfiguratorStore } from "@/lib/store";
import { Scene3D } from "./scene";
import { Camera } from "lucide-react";
function LoadingFallback() {
return (
@@ -15,8 +16,31 @@ function LoadingFallback() {
);
}
function formatPrice(amount: number): string {
return new Intl.NumberFormat("nl-NL", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
}
export function DoorVisualizer() {
const { doorType, gridType, finish, handle } = useConfiguratorStore();
const { doorType, gridType, finish, handle, priceBreakdown, setScreenshotDataUrl } =
useConfiguratorStore();
const handleScreenshot = useCallback(() => {
const canvas = document.querySelector("canvas");
if (!canvas) return;
const dataUrl = canvas.toDataURL("image/png");
setScreenshotDataUrl(dataUrl);
// Also trigger download
const link = document.createElement("a");
link.download = "proinn-deur-configuratie.png";
link.href = dataUrl;
link.click();
}, [setScreenshotDataUrl]);
return (
<div className="relative h-full w-full overflow-hidden rounded-[2.5rem]">
@@ -28,13 +52,36 @@ export function DoorVisualizer() {
</div>
</div>
{/* Screenshot Button */}
<div className="absolute right-8 top-8 z-10">
<button
onClick={handleScreenshot}
className="flex items-center gap-2 rounded-full bg-[#1A2E2E]/80 px-3 py-2 text-xs font-medium text-white shadow-lg backdrop-blur-sm transition-all hover:bg-[#1A2E2E]"
>
<Camera className="size-3.5" />
Screenshot
</button>
</div>
{/* 3D Scene */}
<Suspense fallback={<LoadingFallback />}>
<Scene3D />
</Suspense>
{/* Live Price Badge */}
<div className="absolute right-8 bottom-24 z-10 lg:bottom-8">
<div className="rounded-2xl bg-[#1A2E2E] px-5 py-3 text-right shadow-lg">
<div className="text-[10px] font-medium uppercase tracking-wider text-gray-400">
Indicatieprijs
</div>
<div className="text-xl font-bold text-[#C4D668]">
{formatPrice(priceBreakdown.totalPrice)}
</div>
</div>
</div>
{/* Configuration Info Card */}
<div className="absolute bottom-8 left-8 right-8 z-10">
<div className="absolute bottom-8 left-8 z-10">
<div className="rounded-2xl bg-white/90 p-4 shadow-lg backdrop-blur-sm">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
@@ -64,9 +111,9 @@ export function DoorVisualizer() {
</div>
{/* Controls Hint */}
<div className="absolute bottom-8 right-8 z-10 hidden lg:block">
<div className="absolute bottom-8 right-8 z-10 hidden lg:hidden">
<div className="rounded-xl bg-[#1A2E2E]/80 px-3 py-2 text-xs text-white backdrop-blur-sm">
<p className="font-medium">🖱 Drag to rotate Scroll to zoom</p>
<p className="font-medium">Drag to rotate - Scroll to zoom</p>
</div>
</div>
</div>

View File

@@ -37,11 +37,14 @@ export interface HandleProps {
*/
function HandleMaterialTextured({ color, finish }: { color: string; finish: string }) {
try {
const texturePath = {
zwart: "/textures/aluwdoors/aluwdoors-configurator-metaalkleur-zwart.jpg",
brons: "/textures/aluwdoors/aluwdoors-configurator-metaalkleur-brons.jpg",
grijs: "/textures/aluwdoors/aluwdoors-configurator-metaalkleur-antraciet.jpg",
}[finish] || "/textures/aluwdoors/aluwdoors-configurator-metaalkleur-zwart.jpg";
const texturePath = ({
zwart: "/textures/proinn/proinn-metaalkleur-zwart.jpg",
brons: "/textures/proinn/proinn-metaalkleur-brons.jpg",
grijs: "/textures/proinn/proinn-metaalkleur-antraciet.jpg",
goud: "/textures/proinn/proinn-metaalkleur-goud.jpg",
beige: "/textures/proinn/proinn-metaalkleur-beige.jpg",
ral: "/textures/proinn/proinn-metaalkleur-ral-keuze.jpg",
} as Record<string, string>)[finish] || "/textures/proinn/proinn-metaalkleur-zwart.jpg";
const texture = useTexture(texturePath);
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
@@ -83,7 +86,14 @@ function PowderCoatMaterial({ color, finish }: { color: string; finish: string }
}
function getColor(finish: string): string {
return { zwart: "#1a1a1a", brons: "#8B6F47", grijs: "#525252" }[finish] || "#1a1a1a";
return ({
zwart: "#1a1a1a",
brons: "#8B6F47",
grijs: "#525252",
goud: "#B8860B",
beige: "#C8B88A",
ral: "#4A6741",
} as Record<string, string>)[finish] || "#1a1a1a";
}
// ============================================
@@ -122,14 +132,16 @@ function MountStandoff({
/**
* U-Greep: Proper U-shaped bar handle with two standoff mounts.
* The grip sits 40mm off the door face, connected by two cylindrical pootjes.
* Mounted on the right stile (vertical frame profile).
*/
export function UGreep({ finish, doorHeight }: HandleProps) {
export function UGreep({ finish, doorWidth, doorHeight, stileWidth }: HandleProps) {
const color = getColor(finish);
const gripLength = Math.min(doorHeight * 0.25, 0.6); // Max 60cm, proportional
const mountSpacing = gripLength - GRIP_BAR_SIZE; // Distance between mount centers
const xPos = doorWidth / 2 - stileWidth / 2; // Center of right stile
return (
<group position={[0, 0, 0]}>
<group position={[xPos, 0, 0]}>
{/* Top mount standoff */}
<MountStandoff
position={[0, mountSpacing / 2, MOUNT_CENTER_Z]}
@@ -163,16 +175,18 @@ export function UGreep({ finish, doorHeight }: HandleProps) {
* Beugelgreep: Vertical bar handle (round) with mounting blocks.
* Two rectangular mounting blocks press against the door face,
* with a round bar connecting them.
* Mounted on the right stile (vertical frame profile).
*/
export function Beugelgreep({ finish, doorHeight }: HandleProps) {
export function Beugelgreep({ finish, doorWidth, doorHeight, stileWidth }: HandleProps) {
const color = getColor(finish);
const gripLength = Math.min(doorHeight * 0.35, 0.8); // Max 80cm
const barDiameter = 0.025; // 25mm
const mountBlockSize: [number, number, number] = [0.04, 0.05, MOUNT_LENGTH];
const mountSpacing = gripLength * 0.85;
const xPos = doorWidth / 2 - stileWidth / 2; // Center of right stile
return (
<group position={[0, 0, 0]}>
<group position={[xPos, 0, 0]}>
{/* Top mounting block (sits on door face, extends outward) */}
<RoundedBox
args={mountBlockSize}
@@ -229,8 +243,8 @@ export function Hoekgreep({ finish, doorWidth, stileWidth }: HandleProps) {
const barThickness = 0.02;
const barWidth = 0.03;
// Position near right stile
const xPos = doorWidth / 2 - stileWidth - 0.12;
// Position on right stile center
const xPos = doorWidth / 2 - stileWidth / 2;
return (
<group position={[xPos, 0, 0]}>
@@ -289,7 +303,8 @@ export function Hoekgreep({ finish, doorWidth, stileWidth }: HandleProps) {
export function Maangreep({ finish, doorWidth, stileWidth }: HandleProps) {
const color = getColor(finish);
const curveRadius = 0.08;
const xPos = doorWidth / 2 - stileWidth - 0.12;
// Position on right stile center
const xPos = doorWidth / 2 - stileWidth / 2;
return (
<group position={[xPos, 0, 0]}>
@@ -338,7 +353,8 @@ export function Maangreep({ finish, doorWidth, stileWidth }: HandleProps) {
*/
export function Ovaalgreep({ finish, doorWidth, stileWidth }: HandleProps) {
const color = getColor(finish);
const xPos = doorWidth / 2 - stileWidth - 0.12;
// Position on right stile center
const xPos = doorWidth / 2 - stileWidth / 2;
const shape = new THREE.Shape();
const rx = 0.06;
@@ -391,7 +407,8 @@ export function Ovaalgreep({ finish, doorWidth, stileWidth }: HandleProps) {
export function Klink({ finish, doorWidth, stileWidth }: HandleProps) {
const color = getColor(finish);
const leverLength = 0.12;
const xPos = doorWidth / 2 - stileWidth - 0.1;
// Position on right stile center
const xPos = doorWidth / 2 - stileWidth / 2;
return (
<group position={[xPos, 0, 0]}>

File diff suppressed because it is too large Load Diff

View File

@@ -13,9 +13,9 @@ export function useMetalTexture(finish: string) {
useEffect(() => {
const mapping: Record<string, string> = {
zwart: "/textures/aluwdoors/aluwdoors-configurator-metaalkleur-zwart.jpg",
brons: "/textures/aluwdoors/aluwdoors-configurator-metaalkleur-brons.jpg",
grijs: "/textures/aluwdoors/aluwdoors-configurator-metaalkleur-antraciet.jpg",
zwart: "/textures/proinn/proinn-metaalkleur-zwart.jpg",
brons: "/textures/proinn/proinn-metaalkleur-brons.jpg",
grijs: "/textures/proinn/proinn-metaalkleur-antraciet.jpg",
};
setTextureUrl(mapping[finish] || mapping.zwart);

View File

@@ -1,10 +1,10 @@
import Link from "next/link";
import Image from "next/image";
import { ArrowRight } from "lucide-react";
import { ArrowRight, Phone } from "lucide-react";
export function Hero() {
return (
<section className="relative flex min-h-screen items-end overflow-hidden">
<section className="relative flex h-[70vh] min-h-[480px] max-h-[720px] items-end overflow-hidden">
{/* Background image */}
<Image
src="/images/hero.jpg"
@@ -15,45 +15,48 @@ export function Hero() {
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-[#1A2E2E]/90 via-[#1A2E2E]/30 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-[#1A2E2E]/90 via-[#1A2E2E]/40 to-[#1A2E2E]/10" />
{/* Content pinned to bottom */}
<div className="relative w-full pb-20 pt-40">
<div className="relative w-full pb-12 pt-20">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{/* Label */}
<div className="mb-6 flex items-center gap-2">
<div className="h-5 w-1 rounded-full bg-[#C4D668]" />
<span className="text-sm font-medium tracking-wide text-[#C4D668]">
Staal &middot; Vakmanschap &middot; Maatwerk
</span>
</div>
<div className="max-w-2xl">
{/* Label */}
<div className="mb-4 flex items-center gap-2">
<div className="h-4 w-1 rounded-full bg-[#C4D668]" />
<span className="text-xs font-medium tracking-widest uppercase text-[#C4D668]">
Handgemaakt in Roosendaal
</span>
</div>
<h1 className="max-w-3xl text-5xl font-light leading-[1.1] tracking-tight text-white md:text-7xl">
Innovatieve
<br />
<span className="font-semibold">Stalen</span> Oplossingen
</h1>
<h1 className="text-4xl font-light leading-[1.1] tracking-tight text-white sm:text-5xl lg:text-6xl">
Stalen deuren
<br />
<span className="font-semibold">op maat</span> gemaakt
</h1>
<p className="mt-6 max-w-md text-base font-light leading-relaxed text-white/60">
Maatwerk voor bedrijven en particulieren. Van stalen deuren tot
industriële kozijnen wij realiseren uw visie in staal.
</p>
<p className="mt-4 max-w-md text-sm leading-relaxed text-white/60">
Van ontwerp tot montage. Wij maken stalen deuren, kozijnen en
wanden die perfect passen bij uw interieur.
</p>
<div className="mt-10 flex flex-col gap-4 sm:flex-row">
<Link
href="/offerte"
className="inline-flex items-center gap-2 rounded-md bg-[#C4D668] px-7 py-3 text-sm font-semibold text-black transition-colors hover:bg-[#b5c75a]"
>
Zelf ontwerpen
<ArrowRight className="size-4" />
</Link>
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center">
<Link
href="/offerte"
className="inline-flex items-center gap-2 rounded-md bg-[#C4D668] px-6 py-3 text-sm font-semibold text-black transition-colors hover:bg-[#b5c75a]"
>
Configureer uw deur
<ArrowRight className="size-4" />
</Link>
<Link
href="/producten"
className="inline-flex items-center gap-2 rounded-md border-2 border-white/30 px-7 py-3 text-sm font-semibold text-white transition-colors hover:bg-white/10"
>
Bekijk Producten
</Link>
<a
href="tel:0165311490"
className="inline-flex items-center gap-2 rounded-md border border-white/20 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-white/10"
>
<Phone className="size-4" />
0165 311 490
</a>
</div>
</div>
</div>
</div>

View File

@@ -3,37 +3,26 @@ import { Mail, Phone, Star, Facebook, Instagram, Linkedin, Youtube } from "lucid
const contactInfo = [
{ icon: Mail, text: "info@proinn.nl", href: "mailto:info@proinn.nl" },
{ icon: Phone, text: "085 - 1234 567", href: "tel:0851234567" },
{ icon: Phone, text: "0165 311 490", href: "tel:0165311490" },
];
const companyInfo = [
{ label: "KVK", value: "12345678" },
{ label: "BTW", value: "NL123456789B01" },
{ label: "IBAN", value: "NL00 INGB 0000 0000 00" },
];
const locations = [
"Nunspeet",
"Veghel",
"Amsterdam",
"Rotterdam",
"Utrecht",
{ label: "KVK", value: "85086991" },
{ label: "BTW", value: "NL863503330.B01" },
];
const proinnLinks = [
{ label: "Projecten", href: "/projecten" },
{ label: "Producten", href: "/producten" },
{ label: "Configurator", href: "/offerte" },
{ label: "Over ons", href: "/over-ons" },
{ label: "Vacatures", href: "/vacatures" },
{ label: "Showrooms", href: "/showrooms" },
{ label: "Contact", href: "/contact" },
];
const serviceLinks = [
{ label: "Contact", href: "/contact" },
{ label: "Kennisbank", href: "/kennisbank" },
{ label: "Veelgestelde vragen", href: "/faq" },
{ label: "Garantie", href: "/garantie" },
{ label: "Onderhoud", href: "/onderhoud" },
{ label: "Stalen binnendeuren", href: "/producten#binnendeuren" },
{ label: "Stalen buitendeuren", href: "/producten#buitendeuren" },
{ label: "Stalen kantoorwanden", href: "/producten#kantoorwanden" },
{ label: "Maatwerk", href: "/producten#maatwerk" },
];
const socialLinks = [
@@ -48,13 +37,16 @@ export function Footer() {
<footer className="bg-[#1A2E2E]">
{/* Main Footer */}
<div className="mx-auto max-w-7xl px-4 pt-16 pb-12 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 gap-10 md:grid-cols-2 lg:grid-cols-5 lg:gap-8">
<div className="grid grid-cols-1 gap-10 md:grid-cols-2 lg:grid-cols-4 lg:gap-8">
{/* Col 1 - Logo & Contact */}
<div className="lg:col-span-1">
<div>
<Link href="/" className="text-2xl font-extrabold tracking-tight text-white">
PROINN
</Link>
<div className="mt-6 space-y-3">
<p className="mt-4 text-sm leading-relaxed text-gray-400">
Handgemaakte stalen deuren, op maat geleverd en ge&iuml;nstalleerd vanuit Roosendaal.
</p>
<div className="mt-5 space-y-3">
{contactInfo.map((item) => (
<a
key={item.text}
@@ -66,7 +58,10 @@ export function Footer() {
</a>
))}
</div>
<div className="mt-5 space-y-2">
<p className="mt-3 text-xs text-gray-500">
Schotsbossenstraat 2, 4705AG Roosendaal
</p>
<div className="mt-4 space-y-1.5">
{companyInfo.map((item) => (
<p key={item.label} className="text-xs text-gray-500">
<span className="text-gray-400">{item.label}:</span> {item.value}
@@ -75,24 +70,7 @@ export function Footer() {
</div>
</div>
{/* Col 2 - Locaties */}
<div>
<h4 className="mb-4 text-sm font-semibold text-white">Locaties</h4>
<ul className="space-y-2.5">
{locations.map((city) => (
<li key={city}>
<Link
href={`/showrooms/${city.toLowerCase()}`}
className="text-sm text-gray-400 transition-colors hover:text-white"
>
{city}
</Link>
</li>
))}
</ul>
</div>
{/* Col 3 - Proinn */}
{/* Col 2 - Proinn */}
<div>
<h4 className="mb-4 text-sm font-semibold text-white">Proinn</h4>
<ul className="space-y-2.5">
@@ -109,9 +87,9 @@ export function Footer() {
</ul>
</div>
{/* Col 4 - Service */}
{/* Col 3 - Producten */}
<div>
<h4 className="mb-4 text-sm font-semibold text-white">Service</h4>
<h4 className="mb-4 text-sm font-semibold text-white">Producten</h4>
<ul className="space-y-2.5">
{serviceLinks.map((link) => (
<li key={link.label}>
@@ -126,7 +104,7 @@ export function Footer() {
</ul>
</div>
{/* Col 5 - Trustpilot */}
{/* Col 4 - Trustpilot */}
<div>
<div className="rounded-2xl bg-[#243636] p-6">
<p className="mb-3 text-xs font-medium uppercase tracking-wider text-gray-400">

View File

@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { ChevronDown, Phone, Mail } from "lucide-react";
import { Phone, Mail } from "lucide-react";
import {
Sheet,
SheetContent,
@@ -11,8 +11,7 @@ import {
const menuLinks = [
{ href: "/", label: "Home" },
{ href: "/producten", label: "Producten", hasSubmenu: true },
{ href: "/maatwerk", label: "Maatwerk", hasSubmenu: true },
{ href: "/producten", label: "Producten" },
{ href: "/over-ons", label: "Over Ons" },
{ href: "/contact", label: "Contact" },
];
@@ -43,9 +42,6 @@ export function MobileMenu({ open, onOpenChange }: MobileMenuProps) {
className="flex items-center justify-between rounded-md px-3 py-3 text-base font-medium text-gray-800 transition-colors hover:bg-gray-50"
>
{link.label}
{link.hasSubmenu && (
<ChevronDown className="size-4 text-gray-400" />
)}
</Link>
</li>
))}
@@ -70,11 +66,11 @@ export function MobileMenu({ open, onOpenChange }: MobileMenuProps) {
</p>
<div className="space-y-2">
<a
href="tel:0851234567"
href="tel:0165311490"
className="flex items-center gap-2 text-sm text-gray-300 transition-colors hover:text-white"
>
<Phone className="size-4" />
085 - 1234 567
0165 311 490
</a>
<a
href="mailto:info@proinn.nl"

View File

@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils";
const navLinks = [
{ href: "/", label: "Home" },
{ href: "/producten", label: "Producten" },
{ href: "/maatwerk", label: "Maatwerk" },
{ href: "/over-ons", label: "Over Ons" },
{ href: "/contact", label: "Contact" },
];

View File

@@ -28,11 +28,11 @@ export function TopBar() {
{/* Contact & Language */}
<div className="flex items-center gap-4 text-xs text-gray-600">
<a
href="tel:0851234567"
href="tel:0165311490"
className="flex items-center gap-1.5 font-medium transition-colors hover:text-gray-900"
>
<Phone className="size-3.5" />
<span>085 - 1234 567</span>
<span>0165 311 490</span>
</a>
<div className="h-3.5 w-px bg-gray-400" />
<div className="flex items-center gap-1.5 font-medium">

View File

@@ -7,20 +7,15 @@ import {
useCallback,
type ReactNode,
} from "react";
import type { QuoteData } from "@/lib/validators";
const TOTAL_STEPS = 5; // 4 form steps + 1 summary
type FormData = Partial<QuoteData>;
const TOTAL_STEPS = 6; // Product, Dimensions, Options, Extras, Contact, Summary
interface FormContextValue {
currentStep: number;
formData: FormData;
totalSteps: number;
nextStep: () => void;
prevStep: () => void;
goToStep: (step: number) => void;
updateData: (data: Partial<FormData>) => void;
reset: () => void;
}
@@ -28,7 +23,6 @@ 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));
@@ -42,25 +36,18 @@ export function FormProvider({ children }: { children: ReactNode }) {
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,
}}
>

View File

@@ -1,12 +1,13 @@
"use client";
import { useFormContext } from "@/components/offerte/form-context";
import { useConfiguratorStore } from "@/lib/store";
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();
const { name, email, phone, note, setName, setEmail, setPhone, setNote } =
useConfiguratorStore();
return (
<div>
@@ -24,8 +25,8 @@ export function StepContact() {
<Input
id="name"
placeholder="Uw volledige naam"
value={formData.name ?? ""}
onChange={(e) => updateData({ name: e.target.value })}
value={name}
onChange={(e) => setName(e.target.value)}
className="h-11 focus-visible:ring-brand-orange"
/>
</div>
@@ -40,8 +41,8 @@ export function StepContact() {
id="email"
type="email"
placeholder="naam@bedrijf.nl"
value={formData.email ?? ""}
onChange={(e) => updateData({ email: e.target.value })}
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-11 focus-visible:ring-brand-orange"
/>
</div>
@@ -55,8 +56,8 @@ export function StepContact() {
id="phone"
type="tel"
placeholder="06 1234 5678"
value={formData.phone ?? ""}
onChange={(e) => updateData({ phone: e.target.value })}
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="h-11 focus-visible:ring-brand-orange"
/>
</div>
@@ -72,8 +73,8 @@ export function StepContact() {
id="note"
rows={3}
placeholder="Bijv. specifieke wensen, RAL-kleur, plaatsing..."
value={formData.note ?? ""}
onChange={(e) => updateData({ note: e.target.value })}
value={note}
onChange={(e) => setNote(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>

View File

@@ -0,0 +1,89 @@
"use client";
import { useConfiguratorStore } from "@/lib/store";
import { Check, Ruler, Wrench, MessageCircle, Truck } from "lucide-react";
const extraOptionsList = [
{
id: "Meetservice",
label: "Meetservice",
description: "Wij komen bij u langs om de exacte maten op te nemen.",
icon: Ruler,
},
{
id: "Montage",
label: "Montage",
description: "Professionele plaatsing door onze vakmensen.",
icon: Wrench,
},
{
id: "Adviesgesprek",
label: "Adviesgesprek",
description: "Vrijblijvend advies over mogelijkheden en materialen.",
icon: MessageCircle,
},
{
id: "Bezorging",
label: "Bezorging",
description: "Bezorging aan huis, of afhalen op locatie.",
icon: Truck,
},
];
export function StepExtras() {
const { extraOptions, toggleExtraOption } = useConfiguratorStore();
return (
<div>
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Extra opties</h2>
<p className="mb-6 text-sm text-gray-600">
Selecteer eventuele extra services bij uw stalen deur.
</p>
<div className="grid gap-3">
{extraOptionsList.map((option) => {
const selected = extraOptions.includes(option.id);
const Icon = option.icon;
return (
<button
key={option.id}
type="button"
onClick={() => toggleExtraOption(option.id)}
className={`group relative rounded-xl border-2 p-4 text-left transition-all ${
selected
? "border-[#C4D668] bg-[#1A2E2E] text-white"
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
}`}
>
<div className="flex items-start gap-4">
<div
className={`flex size-10 shrink-0 items-center justify-center rounded-lg ${
selected ? "bg-[#C4D668]/20" : "bg-gray-100"
}`}
>
<Icon className={`size-5 ${selected ? "text-[#C4D668]" : "text-[#1A2E2E]"}`} />
</div>
<div className="flex-1">
<h3 className="font-bold">{option.label}</h3>
<p className={`mt-1 text-sm ${selected ? "text-white/80" : "text-gray-500"}`}>
{option.description}
</p>
</div>
<div
className={`flex size-6 shrink-0 items-center justify-center rounded-md border-2 transition-all ${
selected
? "border-[#C4D668] bg-[#C4D668]"
: "border-gray-300 bg-white"
}`}
>
{selected && <Check className="size-4 text-[#1A2E2E]" />}
</div>
</div>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -1,21 +1,39 @@
"use client";
import { useConfiguratorStore, type Finish, type Handle } from "@/lib/store";
import { useConfiguratorStore, type Finish, type GlassColor, type Handle, type FrameSize } from "@/lib/store";
import { glassPatternOptions, type GlassPattern } from "@/lib/glass-patterns";
import { Check } from "lucide-react";
// ============================================
// OPTIONS DATA
// ============================================
const finishOptions: Array<{
value: Finish;
label: string;
description: string;
swatch: string;
}> = [
{ value: "zwart", label: "Mat Zwart", description: "Klassiek en tijdloos" },
{
value: "brons",
label: "Brons",
description: "Warm en industrieel",
},
{ value: "grijs", label: "Antraciet", description: "Modern en neutraal" },
{ value: "zwart", label: "Mat Zwart", description: "Klassiek en tijdloos", swatch: "#1A1A1A" },
{ value: "brons", label: "Brons", description: "Warm en industrieel", swatch: "#8B6F47" },
{ value: "grijs", label: "Antraciet", description: "Modern en neutraal", swatch: "#525252" },
{ value: "goud", label: "Goud", description: "Luxe en opvallend", swatch: "#B8860B" },
{ value: "beige", label: "Beige", description: "Zacht en natuurlijk", swatch: "#C8B88A" },
{ value: "ral", label: "RAL Kleur", description: "Op maat, +EUR 200", swatch: "#4A6741" },
];
const glassColorOptions: Array<{
value: GlassColor;
label: string;
description: string;
swatch: string;
}> = [
{ value: "helder", label: "Helder", description: "Maximale transparantie", swatch: "#dbeafe" },
{ value: "grijs", label: "Rookglas", description: "Getint grijs glas", swatch: "#4B5563" },
{ value: "brons", label: "Bronsglas", description: "Warm getint glas", swatch: "#92764A" },
{ value: "mat-blank", label: "Mat Blank", description: "Zacht diffuus licht", swatch: "#e2e2e2" },
{ value: "mat-brons", label: "Mat Brons", description: "Warm en gedempd", swatch: "#A0845C" },
{ value: "mat-zwart", label: "Mat Zwart", description: "Privacy glas", swatch: "#2D2D2D" },
];
const handleOptions: Array<{
@@ -23,46 +41,69 @@ const handleOptions: Array<{
label: string;
description: string;
}> = [
{
value: "beugelgreep",
label: "Beugelgreep",
description: "Verticale staaf met montageblokken",
},
{
value: "hoekgreep",
label: "Hoekgreep",
description: "L-vormige minimalistisch design",
},
{
value: "maangreep",
label: "Maangreep",
description: "Gebogen half-maanvormige greep",
},
{
value: "ovaalgreep",
label: "Ovaalgreep",
description: "Moderne ovale trekgreep",
},
{
value: "klink",
label: "Deurklink",
description: "Klassieke deurklink met hendel",
},
{
value: "u-greep",
label: "U-Greep",
description: "Eenvoudige rechte staaf",
},
{
value: "geen",
label: "Geen greep",
description: "Voor vaste panelen",
},
{ value: "beugelgreep", label: "Beugelgreep", description: "Verticale staaf met montageblokken" },
{ value: "hoekgreep", label: "Hoekgreep", description: "L-vormige minimalistisch design" },
{ value: "maangreep", label: "Maangreep", description: "Gebogen half-maanvormige greep" },
{ value: "ovaalgreep", label: "Ovaalgreep", description: "Moderne ovale trekgreep" },
{ value: "klink", label: "Deurklink", description: "Klassieke deurklink met hendel" },
{ value: "u-greep", label: "U-Greep", description: "Eenvoudige rechte staaf" },
{ value: "geen", label: "Geen greep", description: "Voor vaste panelen" },
];
const frameSizeOptions: Array<{
value: FrameSize;
label: string;
description: string;
}> = [
{ value: 20, label: "Smal (20mm)", description: "Minimalistisch profiel" },
{ value: 30, label: "Standaard (30mm)", description: "Populairste keuze" },
{ value: 40, label: "Robuust (40mm)", description: "Industrieel karakter" },
];
// ============================================
// SHARED SELECTION COMPONENTS
// ============================================
function SelectionButton({
selected,
onClick,
children,
}: {
selected: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`group relative rounded-xl border-2 p-4 text-left transition-all ${
selected
? "border-[#C4D668] bg-[#1A2E2E] text-white"
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
}`}
>
<div className="flex items-start justify-between">
{children}
{selected && (
<div className="ml-2 flex size-6 shrink-0 items-center justify-center rounded-full bg-[#C4D668]">
<Check className="size-4 text-[#1A2E2E]" />
</div>
)}
</div>
</button>
);
}
// ============================================
// MAIN COMPONENT
// ============================================
export function StepOptions() {
const { finish, handle, glassPattern, setFinish, setHandle, setGlassPattern } =
useConfiguratorStore();
const {
finish, handle, glassPattern, glassColor, frameSize,
setFinish, setHandle, setGlassPattern, setGlassColor, setFrameSize,
} = useConfiguratorStore();
return (
<div className="space-y-8">
@@ -73,52 +114,113 @@ export function StepOptions() {
Kies de kleur en afwerking van het staal.
</p>
<div className="grid gap-3">
<div className="grid grid-cols-2 gap-3">
{finishOptions.map((option) => {
const selected = finish === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => setFinish(option.value)}
className={`group relative rounded-xl border-2 p-4 text-left transition-all ${
className={`group relative flex flex-col items-center rounded-xl border-2 p-3 transition-all ${
selected
? "border-[#C4D668] bg-[#1A2E2E] text-white"
? "border-[#C4D668] bg-[#1A2E2E] text-white ring-2 ring-[#C4D668]"
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
}`}
>
<div className="flex items-start justify-between">
<div className="flex flex-1 items-center gap-4">
{/* Color swatch */}
<div
className="size-10 rounded-lg border-2 border-white shadow-md"
style={{
backgroundColor:
option.value === "zwart"
? "#1A1A1A"
: option.value === "brons"
? "#8B6F47"
: "#4A5568",
}}
/>
<div className="flex-1">
<h3 className="font-bold">{option.label}</h3>
<p
className={`mt-1 text-sm ${
selected ? "text-white/80" : "text-gray-500"
}`}
>
{option.description}
</p>
</div>
<div
className="mb-2 size-10 rounded-lg border-2 border-white shadow-md"
style={{ backgroundColor: option.swatch }}
/>
<h3 className="text-sm font-bold">{option.label}</h3>
<p className={`mt-0.5 text-xs ${selected ? "text-white/70" : "text-gray-500"}`}>
{option.description}
</p>
{selected && (
<div className="absolute right-2 top-2 flex size-5 items-center justify-center rounded-full bg-[#C4D668]">
<Check className="size-3 text-[#1A2E2E]" />
</div>
{selected && (
<div className="flex size-6 items-center justify-center rounded-full bg-[#C4D668]">
<Check className="size-4 text-[#1A2E2E]" />
</div>
)}
)}
</button>
);
})}
</div>
</div>
{/* Glass Color Selection */}
<div>
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Glaskleur</h2>
<p className="mb-4 text-sm text-gray-600">
Kies het type en de kleur van het glas.
</p>
<div className="grid grid-cols-3 gap-3">
{glassColorOptions.map((option) => {
const selected = glassColor === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => setGlassColor(option.value)}
className={`group relative flex flex-col items-center rounded-xl border-2 p-3 transition-all ${
selected
? "border-[#C4D668] bg-[#1A2E2E] text-white ring-2 ring-[#C4D668]"
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
}`}
>
<div
className="mb-2 size-8 rounded-full border-2 border-white shadow-md"
style={{ backgroundColor: option.swatch }}
/>
<h3 className="text-xs font-bold">{option.label}</h3>
{selected && (
<div className="absolute right-1.5 top-1.5 flex size-4 items-center justify-center rounded-full bg-[#C4D668]">
<Check className="size-2.5 text-[#1A2E2E]" />
</div>
)}
</button>
);
})}
</div>
</div>
{/* Frame Size Selection */}
<div>
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Profielbreedte</h2>
<p className="mb-4 text-sm text-gray-600">
Kies de breedte van het stalen profiel.
</p>
<div className="grid grid-cols-3 gap-3">
{frameSizeOptions.map((option) => {
const selected = frameSize === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => setFrameSize(option.value)}
className={`group relative flex flex-col items-center rounded-xl border-2 px-2 py-3 transition-all ${
selected
? "border-[#C4D668] bg-[#1A2E2E] text-white ring-2 ring-[#C4D668]"
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
}`}
>
{/* Visual profile width indicator */}
<div className="mb-2 flex h-12 items-center justify-center">
<div
className={`rounded-sm ${selected ? "bg-[#C4D668]" : "bg-[#1A2E2E]"}`}
style={{ width: `${option.value * 0.4}px`, height: "40px" }}
/>
</div>
<h3 className="text-xs font-bold">{option.label}</h3>
<p className={`mt-0.5 text-[10px] ${selected ? "text-white/70" : "text-gray-500"}`}>
{option.description}
</p>
{selected && (
<div className="absolute right-1.5 top-1.5 flex size-4 items-center justify-center rounded-full bg-[#C4D668]">
<Check className="size-2.5 text-[#1A2E2E]" />
</div>
)}
</button>
);
})}
@@ -135,36 +237,19 @@ export function StepOptions() {
<div className="grid gap-3">
{glassPatternOptions.map((option) => {
const selected = glassPattern === option.value;
return (
<button
<SelectionButton
key={option.value}
type="button"
selected={selected}
onClick={() => setGlassPattern(option.value)}
className={`group relative rounded-xl border-2 p-4 text-left transition-all ${
selected
? "border-[#C4D668] bg-[#1A2E2E] text-white"
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-bold">{option.label}</h3>
<p
className={`mt-1 text-sm ${
selected ? "text-white/80" : "text-gray-500"
}`}
>
{option.description}
</p>
</div>
{selected && (
<div className="flex size-6 items-center justify-center rounded-full bg-[#C4D668]">
<Check className="size-4 text-[#1A2E2E]" />
</div>
)}
<div className="flex-1">
<h3 className="font-bold">{option.label}</h3>
<p className={`mt-1 text-sm ${selected ? "text-white/80" : "text-gray-500"}`}>
{option.description}
</p>
</div>
</button>
</SelectionButton>
);
})}
</div>
@@ -180,36 +265,19 @@ export function StepOptions() {
<div className="grid gap-3">
{handleOptions.map((option) => {
const selected = handle === option.value;
return (
<button
<SelectionButton
key={option.value}
type="button"
selected={selected}
onClick={() => setHandle(option.value)}
className={`group relative rounded-xl border-2 p-4 text-left transition-all ${
selected
? "border-[#C4D668] bg-[#1A2E2E] text-white"
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-bold">{option.label}</h3>
<p
className={`mt-1 text-sm ${
selected ? "text-white/80" : "text-gray-500"
}`}
>
{option.description}
</p>
</div>
{selected && (
<div className="flex size-6 items-center justify-center rounded-full bg-[#C4D668]">
<Check className="size-4 text-[#1A2E2E]" />
</div>
)}
<div className="flex-1">
<h3 className="font-bold">{option.label}</h3>
<p className={`mt-1 text-sm ${selected ? "text-white/80" : "text-gray-500"}`}>
{option.description}
</p>
</div>
</button>
</SelectionButton>
);
})}
</div>

View File

@@ -1,22 +1,21 @@
"use client";
import { useConfiguratorStore, type DoorType } from "@/lib/store";
import { useConfiguratorStore, type DoorType, type GridType } from "@/lib/store";
import { useFormContext } from "@/components/offerte/form-context";
import { Check } from "lucide-react";
// Door type visual icons (inline SVGs)
// ============================================
// DOOR TYPE ICONS (SVG)
// ============================================
function TaatsIcon({ selected }: { selected: boolean }) {
const stroke = selected ? "#C4D668" : "#1A2E2E";
const fill = selected ? "#C4D668" : "none";
return (
<svg viewBox="0 0 64 80" className="h-20 w-16">
{/* Door frame */}
<rect x="8" y="4" width="48" height="72" rx="2" fill="none" stroke={stroke} strokeWidth="2" />
{/* Glass */}
<rect x="14" y="10" width="36" height="60" rx="1" fill={selected ? "#C4D668" : "#e5e7eb"} opacity="0.2" />
{/* Pivot point (center) */}
<circle cx="32" cy="40" r="3" fill={fill} stroke={stroke} strokeWidth="1.5" />
{/* Rotation arrow */}
<path d="M 44 20 A 16 16 0 0 1 44 60" fill="none" stroke={stroke} strokeWidth="1.5" strokeDasharray="3 2" />
<polygon points="44,58 48,54 40,54" fill={stroke} />
</svg>
@@ -28,14 +27,10 @@ function ScharnierIcon({ selected }: { selected: boolean }) {
const fill = selected ? "#C4D668" : "none";
return (
<svg viewBox="0 0 64 80" className="h-20 w-16">
{/* Door frame */}
<rect x="8" y="4" width="48" height="72" rx="2" fill="none" stroke={stroke} strokeWidth="2" />
{/* Glass */}
<rect x="14" y="10" width="36" height="60" rx="1" fill={selected ? "#C4D668" : "#e5e7eb"} opacity="0.2" />
{/* Hinge dots on left side */}
<circle cx="10" cy="20" r="2.5" fill={fill} stroke={stroke} strokeWidth="1.5" />
<circle cx="10" cy="60" r="2.5" fill={fill} stroke={stroke} strokeWidth="1.5" />
{/* Rotation arrow from hinge side */}
<path d="M 56 18 A 24 24 0 0 1 56 62" fill="none" stroke={stroke} strokeWidth="1.5" strokeDasharray="3 2" />
<polygon points="56,60 60,56 52,56" fill={stroke} />
</svg>
@@ -46,11 +41,8 @@ function PaneelIcon({ selected }: { selected: boolean }) {
const stroke = selected ? "#C4D668" : "#1A2E2E";
return (
<svg viewBox="0 0 64 80" className="h-20 w-16">
{/* Door frame */}
<rect x="8" y="4" width="48" height="72" rx="2" fill="none" stroke={stroke} strokeWidth="2" />
{/* Glass */}
<rect x="14" y="10" width="36" height="60" rx="1" fill={selected ? "#C4D668" : "#e5e7eb"} opacity="0.2" />
{/* Fixed indicator - lock symbol */}
<rect x="26" y="34" width="12" height="12" rx="2" fill="none" stroke={stroke} strokeWidth="1.5" />
<circle cx="32" cy="34" r="5" fill="none" stroke={stroke} strokeWidth="1.5" />
</svg>
@@ -63,70 +55,149 @@ const doorTypeIcons: Record<DoorType, (props: { selected: boolean }) => React.Re
paneel: PaneelIcon,
};
// Grid type visual illustrations (CSS-based rectangles with dividers)
function GridIllustration({ dividers, selected }: { dividers: number; selected: boolean }) {
const borderColor = selected ? "border-[#C4D668]" : "border-[#1A2E2E]/40";
const dividerBg = selected ? "bg-[#C4D668]" : "bg-[#1A2E2E]/30";
const glassBg = selected ? "bg-[#C4D668]/10" : "bg-gray-100";
// ============================================
// GRID PATTERN SVG ILLUSTRATIONS
// ============================================
return (
<div className={`flex h-20 w-14 flex-col overflow-hidden rounded border-2 ${borderColor}`}>
{dividers === 0 && (
<div className={`flex-1 ${glassBg}`} />
)}
{dividers > 0 &&
Array.from({ length: dividers + 1 }).map((_, i) => (
<div key={i} className="flex flex-1 flex-col">
{i > 0 && <div className={`h-[2px] shrink-0 ${dividerBg}`} />}
<div className={`flex-1 ${glassBg}`} />
</div>
))
}
</div>
function GridSVG({ pattern, selected }: { pattern: GridType; selected: boolean }) {
const stroke = selected ? "#C4D668" : "#1A2E2E";
const glass = selected ? "#C4D668" : "#e5e7eb";
const opacity = selected ? 0.15 : 0.3;
const sw = 1.5;
// Frame: outer rect with inner glass area
const frame = (children: React.ReactNode) => (
<svg viewBox="0 0 40 60" className="h-14 w-10">
<rect x="2" y="2" width="36" height="56" rx="1" fill="none" stroke={stroke} strokeWidth={sw} />
<rect x="5" y="5" width="30" height="50" fill={glass} opacity={opacity} />
{children}
</svg>
);
switch (pattern) {
case "geen":
return frame(null);
case "2-vlak":
return frame(
<line x1="5" y1="30" x2="35" y2="30" stroke={stroke} strokeWidth={sw} />
);
case "3-vlak":
return frame(
<>
<line x1="5" y1="22" x2="35" y2="22" stroke={stroke} strokeWidth={sw} />
<line x1="5" y1="38" x2="35" y2="38" stroke={stroke} strokeWidth={sw} />
</>
);
case "4-vlak":
return frame(
<>
<line x1="5" y1="18" x2="35" y2="18" stroke={stroke} strokeWidth={sw} />
<line x1="5" y1="30" x2="35" y2="30" stroke={stroke} strokeWidth={sw} />
<line x1="5" y1="42" x2="35" y2="42" stroke={stroke} strokeWidth={sw} />
</>
);
case "6-vlak":
return frame(
<>
<line x1="5" y1="22" x2="35" y2="22" stroke={stroke} strokeWidth={sw} />
<line x1="5" y1="38" x2="35" y2="38" stroke={stroke} strokeWidth={sw} />
<line x1="20" y1="5" x2="20" y2="55" stroke={stroke} strokeWidth={sw} />
</>
);
case "8-vlak":
return frame(
<>
<line x1="5" y1="18" x2="35" y2="18" stroke={stroke} strokeWidth={sw} />
<line x1="5" y1="30" x2="35" y2="30" stroke={stroke} strokeWidth={sw} />
<line x1="5" y1="42" x2="35" y2="42" stroke={stroke} strokeWidth={sw} />
<line x1="20" y1="5" x2="20" y2="55" stroke={stroke} strokeWidth={sw} />
</>
);
case "kruis":
return frame(
<>
<line x1="5" y1="30" x2="35" y2="30" stroke={stroke} strokeWidth={sw} />
<line x1="20" y1="5" x2="20" y2="55" stroke={stroke} strokeWidth={sw} />
</>
);
case "ongelijk-3":
return frame(
<>
<line x1="5" y1="24" x2="35" y2="24" stroke={stroke} strokeWidth={sw} />
<line x1="5" y1="40" x2="35" y2="40" stroke={stroke} strokeWidth={sw} />
</>
);
case "boerderij":
return frame(
<>
<line x1="5" y1="20" x2="35" y2="20" stroke={stroke} strokeWidth={sw} />
<line x1="20" y1="20" x2="20" y2="55" stroke={stroke} strokeWidth={sw} />
</>
);
case "herenhuis":
return frame(
<>
<line x1="5" y1="18" x2="35" y2="18" stroke={stroke} strokeWidth={sw} />
<line x1="5" y1="40" x2="35" y2="40" stroke={stroke} strokeWidth={sw} />
</>
);
default:
return frame(null);
}
}
// ============================================
// DATA
// ============================================
const doorTypes: Array<{
value: DoorType;
label: string;
description: string;
}> = [
{
value: "taats",
label: "Taatsdeur",
description: "Pivoterende deur",
},
{
value: "scharnier",
label: "Scharnierdeur",
description: "Zijscharnieren",
},
{
value: "paneel",
label: "Vast Paneel",
description: "Geen beweging",
},
{ value: "taats", label: "Taatsdeur", description: "Pivoterende deur" },
{ value: "scharnier", label: "Scharnierdeur", description: "Zijscharnieren" },
{ value: "paneel", label: "Vast Paneel", description: "Geen beweging" },
];
const gridTypes: Array<{
value: "3-vlak" | "4-vlak" | "geen";
value: GridType;
label: string;
description: string;
dividers: number;
}> = [
{ value: "geen", label: "Geen", description: "Volledig vlak", dividers: 0 },
{ value: "3-vlak", label: "3-vlaks", description: "2 balken", dividers: 2 },
{ value: "4-vlak", label: "4-vlaks", description: "3 balken", dividers: 3 },
{ value: "geen", label: "Geen", description: "Volledig vlak" },
{ value: "2-vlak", label: "2-vlaks", description: "1 balk" },
{ value: "3-vlak", label: "3-vlaks", description: "2 balken" },
{ value: "4-vlak", label: "4-vlaks", description: "3 balken" },
{ value: "kruis", label: "Kruis", description: "1H + 1V" },
{ value: "6-vlak", label: "6-vlaks", description: "2H + 1V" },
{ value: "8-vlak", label: "8-vlaks", description: "3H + 1V" },
{ value: "ongelijk-3", label: "Ongelijk", description: "3 ongelijk" },
{ value: "boerderij", label: "Boerderij", description: "2+2 onder" },
{ value: "herenhuis", label: "Herenhuis", description: "3 horizontaal" },
];
// ============================================
// MAIN COMPONENT
// ============================================
export function StepProduct() {
const { nextStep } = useFormContext();
const { doorType, gridType, setDoorType, setGridType } =
useConfiguratorStore();
const { doorType, gridType, setDoorType, setGridType } = useConfiguratorStore();
return (
<div className="space-y-8">
{/* Door Type Selection - Visual Tiles */}
{/* Door Type Selection */}
<div>
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Kies uw deurtype</h2>
<p className="mb-4 text-sm text-gray-600">
@@ -153,11 +224,7 @@ export function StepProduct() {
<IconComponent selected={selected} />
</div>
<h3 className="text-sm font-bold">{type.label}</h3>
<p
className={`mt-1 text-xs ${
selected ? "text-white/70" : "text-gray-500"
}`}
>
<p className={`mt-1 text-xs ${selected ? "text-white/70" : "text-gray-500"}`}>
{type.description}
</p>
{selected && (
@@ -171,14 +238,14 @@ export function StepProduct() {
</div>
</div>
{/* Grid Type Selection - Visual Tiles */}
{/* Grid Type Selection - 10 patterns */}
<div>
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Verdeling</h2>
<p className="mb-4 text-sm text-gray-600">
Kies het aantal horizontale vlakken.
Kies het patroon van de glasverdeling.
</p>
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-5 gap-2">
{gridTypes.map((type) => {
const selected = gridType === type.value;
@@ -187,26 +254,22 @@ export function StepProduct() {
key={type.value}
type="button"
onClick={() => setGridType(type.value)}
className={`group relative flex flex-col items-center rounded-xl border-2 px-2 py-4 transition-all ${
className={`group relative flex flex-col items-center rounded-xl border-2 px-1 py-3 transition-all ${
selected
? "border-[#C4D668] bg-[#1A2E2E] text-white ring-2 ring-[#C4D668] shadow-lg shadow-[#C4D668]/20"
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/20 hover:shadow-md"
}`}
>
<div className="mb-3 flex items-center justify-center">
<GridIllustration dividers={type.dividers} selected={selected} />
<div className="mb-2 flex items-center justify-center">
<GridSVG pattern={type.value} selected={selected} />
</div>
<h3 className="text-sm font-bold">{type.label}</h3>
<p
className={`mt-1 text-xs ${
selected ? "text-white/70" : "text-gray-500"
}`}
>
<h3 className="text-[10px] font-bold leading-tight">{type.label}</h3>
<p className={`text-[9px] leading-tight ${selected ? "text-white/70" : "text-gray-500"}`}>
{type.description}
</p>
{selected && (
<div className="absolute right-2 top-2 flex size-5 items-center justify-center rounded-full bg-[#C4D668]">
<Check className="size-3 text-[#1A2E2E]" />
<div className="absolute right-1 top-1 flex size-4 items-center justify-center rounded-full bg-[#C4D668]">
<Check className="size-2.5 text-[#1A2E2E]" />
</div>
)}
</button>

View File

@@ -1,83 +1,288 @@
"use client";
import { useFormContext } from "@/components/offerte/form-context";
import { useState } from "react";
import { useConfiguratorStore } from "@/lib/store";
import { sendQuoteAction } from "@/actions/send-quote";
import { Button } from "@/components/ui/button";
import { Send, Check } from "lucide-react";
import { Send, Check, Loader2, CheckCircle2, AlertCircle } 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",
// ============================================
// LABEL MAPS
// ============================================
const DOOR_TYPE_LABELS: Record<string, string> = {
taats: "Taatsdeur",
scharnier: "Scharnierdeur",
paneel: "Vast Paneel",
};
const fieldOrder = [
"productType",
"height",
"width",
"glassType",
"finish",
"name",
"email",
"phone",
"note",
];
const CONFIG_LABELS: Record<string, string> = {
enkele: "Enkele deur",
dubbele: "Dubbele deur",
};
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);
const SIDE_PANEL_LABELS: Record<string, string> = {
geen: "Geen",
links: "Links",
rechts: "Rechts",
beide: "Beide zijden",
};
const FINISH_LABELS: Record<string, string> = {
zwart: "Mat Zwart",
brons: "Brons",
grijs: "Antraciet",
goud: "Goud",
beige: "Beige",
ral: "RAL Kleur",
};
const GLASS_COLOR_LABELS: Record<string, string> = {
helder: "Helder",
grijs: "Rookglas",
brons: "Bronsglas",
"mat-blank": "Mat Blank",
"mat-brons": "Mat Brons",
"mat-zwart": "Mat Zwart",
};
const HANDLE_LABELS: Record<string, string> = {
beugelgreep: "Beugelgreep",
hoekgreep: "Hoekgreep",
maangreep: "Maangreep",
ovaalgreep: "Ovaalgreep",
klink: "Deurklink",
"u-greep": "U-Greep",
geen: "Geen greep",
};
const PATTERN_LABELS: Record<string, string> = {
standard: "Standaard",
"dt9-rounded": "DT9 Afgerond",
"dt10-ushape": "DT10 U-vorm",
};
function formatPrice(amount: number): string {
return new Intl.NumberFormat("nl-NL", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
}
// ============================================
// COMPONENTS
// ============================================
function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
return (
<div className="flex items-center justify-between py-2">
<span className="text-sm text-gray-500">{label}</span>
<span className={`text-sm font-medium ${highlight ? "text-[#C4D668]" : "text-[#1A2E2E]"}`}>
{value}
</span>
</div>
);
}
function SummarySection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-4">
<h3 className="mb-3 text-sm font-bold uppercase tracking-wider text-gray-400">{title}</h3>
<div className="divide-y divide-gray-100">{children}</div>
</div>
);
}
function PriceRow({ label, amount, bold }: { label: string; amount: number; bold?: boolean }) {
if (amount === 0) return null;
return (
<div className={`flex items-center justify-between py-1.5 ${bold ? "text-base" : "text-sm"}`}>
<span className={bold ? "font-bold text-[#1A2E2E]" : "text-gray-500"}>{label}</span>
<span className={bold ? "font-bold text-[#1A2E2E]" : "text-gray-700"}>
{formatPrice(amount)}
</span>
</div>
);
}
// ============================================
// MAIN COMPONENT
// ============================================
export function StepSummary() {
const { formData } = useFormContext();
const store = useConfiguratorStore();
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [errorMsg, setErrorMsg] = useState("");
const {
doorType, gridType, doorConfig, sidePanel,
width, height, doorLeafWidth,
finish, glassColor, handle, frameSize, glassPattern,
extraOptions,
name, email, phone, note,
priceBreakdown, screenshotDataUrl,
} = store;
const canSubmit = name.length >= 2 && email.includes("@") && phone.length >= 10;
async function handleSubmit() {
if (!canSubmit) return;
setStatus("loading");
setErrorMsg("");
const result = await sendQuoteAction({
doorType, gridType, doorConfig, sidePanel,
width, height, doorLeafWidth,
finish, glassColor, handle, frameSize, glassPattern,
extraOptions,
name, email, phone, note,
totalPrice: priceBreakdown.totalPrice,
steelCost: priceBreakdown.steelCost,
glassCost: priceBreakdown.glassCost,
baseFee: priceBreakdown.baseFee,
mechanismSurcharge: priceBreakdown.mechanismSurcharge,
sidePanelSurcharge: priceBreakdown.sidePanelSurcharge,
handleCost: priceBreakdown.handleCost,
finishSurcharge: priceBreakdown.finishSurcharge,
screenshotDataUrl,
});
if (result.success) {
setStatus("success");
} else {
setStatus("error");
setErrorMsg(result.error || "Onbekende fout");
}
}
if (status === "success") {
return (
<div className="flex flex-col items-center py-8 text-center">
<div className="mb-4 flex size-16 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="size-8 text-green-600" />
</div>
<h2 className="mb-2 text-xl font-bold text-[#1A2E2E]">Aanvraag Verstuurd!</h2>
<p className="mb-1 text-sm text-gray-600">
Bedankt {name}, uw offerte aanvraag is ontvangen.
</p>
<p className="text-sm text-gray-500">
We sturen een bevestiging naar {email}.
</p>
</div>
);
}
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 className="space-y-4">
<div>
<h2 className="mb-1 text-xl font-bold text-[#1A2E2E]">Overzicht</h2>
<p className="text-sm text-gray-500">
Controleer uw configuratie en verstuur de aanvraag.
</p>
</div>
{/* Product Section */}
<SummarySection title="Product">
<SummaryRow label="Deurtype" value={DOOR_TYPE_LABELS[doorType] || doorType} />
<SummaryRow label="Verdeling" value={gridType} />
<SummaryRow label="Configuratie" value={CONFIG_LABELS[doorConfig] || doorConfig} />
<SummaryRow label="Zijpanelen" value={SIDE_PANEL_LABELS[sidePanel] || sidePanel} />
</SummarySection>
{/* Dimensions Section */}
<SummarySection title="Afmetingen">
<SummaryRow label="Wandopening (breedte)" value={`${width} mm`} />
<SummaryRow label="Hoogte" value={`${height} mm`} />
<SummaryRow label="Deurblad breedte" value={`${Math.round(doorLeafWidth)} mm`} />
</SummarySection>
{/* Style Section */}
<SummarySection title="Stijl">
<SummaryRow label="Afwerking" value={FINISH_LABELS[finish] || finish} />
<SummaryRow label="Glaskleur" value={GLASS_COLOR_LABELS[glassColor] || glassColor} />
<SummaryRow label="Greep" value={HANDLE_LABELS[handle] || handle} />
<SummaryRow label="Profielbreedte" value={`${frameSize} mm`} />
<SummaryRow label="Glaspatroon" value={PATTERN_LABELS[glassPattern] || glassPattern} />
</SummarySection>
{/* Extra Options */}
{extraOptions.length > 0 && (
<SummarySection title="Extra opties">
{extraOptions.map((opt) => (
<div key={opt} className="flex items-center gap-2 py-1.5">
<Check className="size-3.5 text-green-500" />
<span className="text-sm text-[#1A2E2E]">{opt}</span>
</div>
))}
</SummarySection>
)}
{/* Price Breakdown */}
<div className="rounded-xl border-2 border-[#1A2E2E] bg-gradient-to-b from-white to-gray-50 p-4">
<h3 className="mb-3 text-sm font-bold uppercase tracking-wider text-gray-400">
Indicatieprijs
</h3>
<div className="space-y-1">
<PriceRow label="Staal" amount={priceBreakdown.steelCost} />
<PriceRow label="Glas" amount={priceBreakdown.glassCost} />
<PriceRow label="Basiskost" amount={priceBreakdown.baseFee} />
<PriceRow label="Mechanisme toeslag" amount={priceBreakdown.mechanismSurcharge} />
<PriceRow label="Zijpaneel toeslag" amount={priceBreakdown.sidePanelSurcharge} />
<PriceRow label="Greep" amount={priceBreakdown.handleCost} />
<PriceRow label="Kleur toeslag" amount={priceBreakdown.finishSurcharge} />
</div>
<div className="mt-3 border-t-2 border-[#1A2E2E] pt-3">
<PriceRow label="Totaal (indicatie)" amount={priceBreakdown.totalPrice} bold />
</div>
<p className="mt-2 text-[10px] text-gray-400">
* Dit is een indicatieprijs. De definitieve prijs wordt bepaald na opmeting.
</p>
</div>
{/* Contact Summary */}
<SummarySection title="Contactgegevens">
<SummaryRow label="Naam" value={name || "—"} />
<SummaryRow label="E-mail" value={email || "—"} />
<SummaryRow label="Telefoon" value={phone || "—"} />
{note && <SummaryRow label="Opmerking" value={note} />}
</SummarySection>
{/* Validation Warning */}
{!canSubmit && (
<div className="flex items-start gap-2 rounded-lg bg-amber-50 p-3 text-sm text-amber-700">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>Vul eerst uw contactgegevens in (stap 5) om de aanvraag te versturen.</span>
</div>
)}
{/* Error Message */}
{status === "error" && (
<div className="flex items-start gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-700">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{errorMsg}</span>
</div>
)}
{/* Submit Button */}
<Button
size="lg"
className="mt-8 w-full bg-brand-orange text-white hover:bg-brand-orange/90"
onClick={handleSubmit}
disabled={!canSubmit || status === "loading"}
className="w-full bg-[#C4D668] text-[#1A2E2E] hover:bg-[#b5c75a] disabled:opacity-50"
>
<Send className="size-4" />
Verzend Aanvraag
{status === "loading" ? (
<>
<Loader2 className="size-4 animate-spin" />
Verzenden...
</>
) : (
<>
<Send className="size-4" />
Verzend Offerte Aanvraag
</>
)}
</Button>
</div>
);