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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 · Vakmanschap · 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>
|
||||
|
||||
@@ -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ï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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
89
components/offerte/step-extras.tsx
Normal file
89
components/offerte/step-extras.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user