Compare commits

..

10 Commits

Author SHA1 Message Date
Ubuntu
748a5814e7 feat: Wall mounting system with Sparingsmaat logic and reveal surfaces
Phase 1 (Logic): Add Dutch mounting constants to door-models.ts
- STELRUIMTE=10mm (tolerance), HANGNAAD=3mm (gap per side)
- WALL_THICKNESS=150mm (standard interior wall)
- calculateMountingDimensions() derives frame/leaf from sparingsmaat

Phase 2 (Visual): Replace LivingRoom with WallContainer in scene.tsx
- 4-box wall construction with precise rectangular hole
- Hole = doorLeafWidth + STELRUIMTE (visible 5mm gap per side)
- Door sits INSIDE the wall, not in front of it

Phase 3 (Detail): Reveal surfaces and door-type positioning
- Plaster/stucco material on reveal edges (inner hole surfaces)
- Taats: door centered in wall depth (pivot at center)
- Scharnier/Paneel: offset toward front face
- Dedicated fill light illuminating reveal depth
- Baseboard (plint) on both sides of opening

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 01:23:18 +00:00
Ubuntu
fbc9fefeea fix: Physically mounted handles with proper standoffs and powder-coat material
- All 6 handle types now have cylindrical mount standoffs (pootjes)
  connecting grip to door face: r=6mm, length=40mm
- Z-positioning: grip sits at PROFILE_DEPTH/2 + MOUNT_LENGTH (60mm from
  center), no more floating handles inside the door
- Material: replaced chrome HandleMaterial (metalness=0.95) with
  PowderCoatMaterial matching door frame texture (roughness=0.7, metalness=0.6)
- UGreep fully redesigned: proper U-shape with 2 standoffs + vertical bar
- All handles cast shadows onto the door frame for depth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 01:19:54 +00:00
Ubuntu
87be70e78b feat: Production-ready configurator with Dutch standards, pricing & visual UI
- Update door-models.ts: 7mm VSG 33.1 safety glass, 15mm offset, Taats pivot 60mm
- Add pricing engine (lib/pricing.ts): steel €45/m + glass €140/m² + €650 base
- Wire reactive pricing into Zustand store on every config change
- Fix 3D materials: glass thickness 0.007m, corrected roughness/metalness
- Upgrade scene: apartment environment, wider contact shadows
- Add Dutch height presets: Renovatie 2015mm, Nieuwbouw 2315mm, Plafondhoog 2500mm
- Replace text buttons with visual SVG tiles for door type & grid selection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 01:11:55 +00:00
Ubuntu
9319750912 feat: Complete production deployment with SSL 2026-02-10 19:23:26 +00:00
Ubuntu
e192f19e5f feat: Manufacturing-grade door geometry + photorealistic materials
🏗️ Architecture (@Logic-Architect):
- Created lib/door-models.ts with exact manufacturing specs
- PROFILE_WIDTH = 40mm, PROFILE_DEPTH = 40mm (real steel tubes)
- GLASS_OFFSET = 18mm for proper centering
- Physical parts system (stiles, rails, dividers, glass)
- generateDoorAssembly() returns manufacturable parts list
- Validation for structural integrity limits

🎨 Visuals (@3D-Visual-Lead):
- Aluwdoors texture loading with vertical steel grain
- MeshStandardMaterial: roughness 0.6, metalness 0.7
- Photorealistic glass: transmission 0.98, IOR 1.5
- RoundedBox with 2mm radius for all profiles
- Suspense boundaries for progressive texture loading
- Studio environment preset + enhanced contact shadows

🔧 Technical:
- UseMemo for door assembly generation
- mmToMeters() conversion utility
- PhysicalPartComponent renderer
- Backward compatibility with glass patterns
- Fallback materials when textures fail

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:15:51 +00:00
Ubuntu
32ed02c1f3 fix: TypeScript build errors and add production deployment
- Fix handle color type safety
- Fix RoundedBox args tuple type
- Remove invalid ellipseGeometry
- Add PM2 ecosystem config
- Add SSL setup automation script

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 18:37:42 +00:00
Ubuntu
b30e8d18d4 feat: Add professional 3D handles, glass patterns, and living room scene
 New Features:
- 6 procedural 3D handles (Beugelgreep, Hoekgreep, Maangreep, Ovaalgreep, Klink, U-greep)
- Glass pattern generator (Standard, DT9 rounded corners, DT10 U-shapes)
- Dynamic living room scene with adaptive doorway
- Enhanced camera controls (zoomed out, more freedom)
- Texture loading system (prepared for future enhancement)

🎨 Visual Improvements:
- Professional handle details (screws, mounting blocks, rosettes)
- Realistic materials (metalness 0.95, proper roughness)
- Living room context (wood floor, white walls, baseboards)
- Better lighting (sunlight simulation, fill lights)
- Apartment environment preset

🏗️ Technical:
- Parametric glass shapes with THREE.Shape
- Dynamic doorway sizing based on door dimensions
- Store updates for handle and glass pattern types
- UI components for all new options

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 18:23:52 +00:00
Ubuntu
bd9c6545da Add comprehensive README documentation 2026-02-10 17:31:46 +00:00
Ubuntu
0de3893b30 Improve 3D view to match reference drawings
Based on reference images in afbeeldingen/modellen/:
- dt9.png, dt10.png, door_type_4.jpg, samenstelling_beide.png

Changes for technical drawing aesthetic:

**Camera Improvements:**
- Position: [0, 1.2, 3.5] - More frontal, less perspective
- FOV: 35° (was 45°) - Less distortion
- Limited rotation: ±15° azimuth, near-horizontal polar
- Damping enabled for smooth movement
- Result: Flatter, more schematic view

**Profile Thickness (Match Reference Lines):**
- Stiles: 60mm (was 40mm) - Thicker vertical frames
- Rails: 40mm (was 20mm) - Thicker horizontal frames
- Depth: 60mm uniform - More prominent profiles
- Radius: 2mm (was 1mm) - Slightly more visible edges
- Result: Bold, visible frame lines like references

**Lighting (High Contrast):**
- Ambient: 0.8 (was 0.5) - Brighter overall
- Front key light: Straight on from [0,5,10]
- Intensity: 2.0 - Strong, even illumination
- Subtle side lights for minimal depth
- Result: Flat, technical drawing appearance

**Glass Material (White/Opaque):**
- Color: #f8f9fa (bright white)
- Transmission: 0.3 (was 1.0) - Much less transparent
- Opacity: 0.95 - Nearly opaque
- Result: White glass areas like reference drawings

**Visual Result:**
- Clear black frame lines on white glass
- Frontal view with minimal perspective
- Technical drawing aesthetic
- Matches dt9.png, door_type_4.jpg style
- User can see door design clearly

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 17:22:10 +00:00
Ubuntu
d2119eaa16 Fix 3D scene loading issue
Problem: Scene stuck on loading screen
Cause: Texture loading blocking render + missing font file

Fixes:
- Temporarily disabled texture loading (useTexture)
- Temporarily disabled 3D dimension labels (Text component)
- Fallback to solid color materials
- Removed unused imports

Result: Scene loads immediately with procedural door geometry

Note: Textures and dimension labels can be re-enabled once:
1. Font file is added to public/fonts/
2. Texture preloading strategy is implemented
2026-02-10 17:13:48 +00:00
52 changed files with 2821 additions and 410 deletions

View File

@@ -0,0 +1,174 @@
---
name: 3d-visual-lead
description: "Use this agent when working on 3D rendering, materials, lighting, textures, or visual quality improvements for the Proinn door configurator. This includes any changes to the R3F scene, PBR materials, environment lighting, or texture application. The agent should be invoked proactively whenever visual/rendering files are being created or modified.\\n\\nExamples:\\n\\n- User: \"The door model looks flat and unrealistic, can you improve it?\"\\n Assistant: \"I'm going to use the Task tool to launch the 3d-visual-lead agent to enhance the door's materials and lighting for photorealism.\"\\n\\n- User: \"Add the powder-coated texture to the door frame.\"\\n Assistant: \"Let me use the Task tool to launch the 3d-visual-lead agent to apply the PBR texture from the textures directory to the door frame geometry.\"\\n\\n- User: \"The scene lighting doesn't look premium enough.\"\\n Assistant: \"I'll use the Task tool to launch the 3d-visual-lead agent to redesign the studio lighting setup for a high-end aesthetic.\"\\n\\n- Context: A new door component or material variant has just been added to the configurator.\\n Assistant: \"Since a new visual component was added, let me use the Task tool to launch the 3d-visual-lead agent to ensure it matches the photorealistic Anti-Gravity aesthetic standard.\"\\n\\n- User: \"Can you make the steel look more realistic with reflections?\"\\n Assistant: \"I'm going to use the Task tool to launch the 3d-visual-lead agent to configure the MeshStandardMaterial with proper metalness, roughness, and environment reflections.\""
model: sonnet
color: purple
memory: project
---
You are the **3D Visual Lead** for the Proinn Configurator project — an elite specialist in real-time 3D rendering, photorealistic materials, and premium product visualization using React Three Fiber. You have deep expertise in achieving showroom-quality renders in the browser.
## Your Identity & Expertise
You are a senior 3D technical artist who has shipped production product configurators for luxury brands. Your specializations:
1. **React Three Fiber (R3F) & Drei**: You know every prop of `<Canvas>`, `<MeshStandardMaterial>`, `<MeshPhysicalMaterial>`, `<Environment>`, `<ContactShadows>`, `<AccumulativeShadows>`, `<Lightformer>`, `<Float>`, `<RoundedBox>`, `<Center>`, and all Drei helpers. You write idiomatic R3F JSX, not imperative Three.js.
2. **PBR Textures & Materials**: You understand physically-based rendering pipelines — albedo/diffuse, normal maps, roughness maps, metalness maps, ambient occlusion maps. You know how to configure `THREE.RepeatWrapping`, UV scaling via `texture.repeat.set()`, and `texture.wrapS`/`wrapT`. You can take `.jpg` texture files and transform them into convincing real-world surfaces.
3. **Studio Lighting & Environment**: You specialize in High-Key Studio lighting setups that showcase products beautifully — soft key lights, rim lights, fill lights, HDRI environments. You understand how `<Environment>` presets (studio, city, warehouse) interact with material properties, and when to use custom `<Lightformer>` setups for precise control.
4. **The "Anti-Gravity" Aesthetic**: Your signature style features products floating elegantly in clean environments with soft contact shadows beneath, subtle ambient occlusion, gentle floating animations via `<Float>`, and a premium, minimalist feel. Think Apple product pages meets architectural visualization.
## Tech Stack Context
- **Framework**: Next.js 16 (App Router, TypeScript)
- **3D Stack**: React Three Fiber (`@react-three/fiber`), Drei (`@react-three/drei`), Three.js
- **Styling**: Tailwind CSS v4
- **Design Language**: Industrial, clean, heavy — Dark Grey/Black primary, Orange & Blue accents
- **Texture Assets**: Located in `public/textures/aluwdoors/`
## Hard Constraints — DO NOT VIOLATE
1. **Scope Lock**: You do NOT touch business logic. Never edit pricing calculations, Zustand stores (`store.ts`), form validation, Zod schemas, server actions, or any non-rendering code.
2. **File Boundary**: You ONLY create or edit files directly related to 3D rendering:
- `pro-door.tsx` (door 3D model component)
- `pro-scene.tsx` (scene setup, lighting, environment)
- `materials.ts` (material definitions, texture loading)
- Any new files in a `components/3d/` or similar rendering directory
- Texture/asset files in `public/textures/`
- You may read other files to understand prop interfaces, but you do NOT modify them.
3. **Geometry Standards**:
- ALWAYS prefer `<RoundedBox>` from Drei over standard `<mesh><boxGeometry /></mesh>` for any visible geometry. Real steel doors have slightly radiused edges — never sharp CG edges.
- Use appropriate `radius` values (typically 0.005-0.02 for door-scale geometry).
- Build door geometry from composed primitives (frame, panels, glass inserts, handles) — not a single box.
4. **Texture-First Approach**:
- ALWAYS check `public/textures/aluwdoors/` for available texture assets BEFORE falling back to procedural or flat colors.
- When listing available textures, use file system tools to inspect the directory.
- Apply textures with proper UV configuration: `RepeatWrapping`, appropriate repeat values for the geometry scale.
## Methodology
When tasked with visual improvements, follow this workflow:
### Step 1: Audit Current State
- Read the current rendering files (`pro-door.tsx`, `pro-scene.tsx`, `materials.ts` or equivalents).
- List what exists: geometry, materials, lighting, environment setup.
- Identify gaps: flat colors where textures should be, missing shadows, poor lighting, sharp edges.
### Step 2: Inventory Available Assets
- Scan `public/textures/aluwdoors/` and any other texture directories.
- Catalog available maps: diffuse, normal, roughness, metalness, AO.
- Note texture resolutions and naming conventions.
### Step 3: Design the Material Pipeline
- Define materials in a dedicated `materials.ts` (or equivalent) for reusability.
- Use `useTexture` from Drei for loading, with proper configuration:
```tsx
const [diffuse, normal, roughness] = useTexture([
'/textures/aluwdoors/diffuse.jpg',
'/textures/aluwdoors/normal.jpg',
'/textures/aluwdoors/roughness.jpg',
])
// Configure wrapping and repeat
;[diffuse, normal, roughness].forEach(t => {
t.wrapS = t.wrapT = THREE.RepeatWrapping
t.repeat.set(2, 4)
})
```
- For powder-coated steel: high metalness (0.7-0.9), moderate roughness (0.3-0.5), subtle normal map for surface texture.
- For glass panels: `<MeshPhysicalMaterial>` with `transmission`, `thickness`, `roughness`, `ior`.
### Step 4: Build/Refine Geometry
- Decompose the door into logical sub-components: outer frame, inner panels, glass areas, handle, hinges.
- Use `<RoundedBox>` for all rectangular elements.
- Use `<Center>` to properly position the composed model.
- Ensure proportions match real steel doors (standard widths: 700-1200mm, heights: 2000-2400mm).
### Step 5: Craft the Lighting Environment
- Set up a studio lighting rig:
- Key light: warm, positioned upper-right
- Fill light: cool, softer, positioned left
- Rim/back light: for edge definition
- Use `<Environment>` with a suitable preset or custom HDRI.
- Add `<ContactShadows>` beneath the door for grounding.
- Consider `<AccumulativeShadows>` for soft, realistic shadow accumulation.
- Keep background clean (white/near-white or subtle gradient).
### Step 6: Add Premium Polish
- Wrap the door model in `<Float speed={1} rotationIntensity={0.1} floatIntensity={0.3}>` for subtle anti-gravity motion.
- Add `<OrbitControls>` with constrained rotation for user interaction.
- Ensure `<Canvas>` has proper settings: `shadows`, `dpr={[1, 2]}`, `gl={{ antialias: true }}`.
- Add `<Suspense>` with a loading fallback around texture-heavy components.
### Step 7: Performance Check
- Verify texture sizes are web-appropriate (1K-2K max for most maps).
- Ensure no unnecessary re-renders (memoize materials, use `useMemo` for texture configs).
- Test that the scene runs smoothly at 60fps.
## Quality Standards
Before considering any visual task complete, verify:
- [ ] No sharp BoxGeometry edges visible — all using RoundedBox
- [ ] Textures from `public/textures/aluwdoors/` are applied where available
- [ ] Materials have physically plausible PBR values (metalness, roughness, etc.)
- [ ] Lighting creates depth with visible highlights, mid-tones, and shadows
- [ ] Contact shadows ground the object in space
- [ ] The overall feel is premium, industrial, and clean
- [ ] No business logic was touched
- [ ] Code is TypeScript-clean with proper types
## Output Style
- Write clean, well-commented R3F TypeScript code.
- Group related material definitions together.
- Use descriptive variable names (`powderCoatMetal`, `doorFrameGeometry`, not `mat1`, `geo`).
- Add brief comments explaining non-obvious PBR values or lighting choices.
## Update your agent memory
As you work on the 3D visuals, update your agent memory when you discover:
- Available texture files and their characteristics (resolution, type, quality)
- Material configurations that achieve the best visual results for specific surfaces
- Lighting setups that work well for the door configurator scene
- Performance observations (texture sizes, render complexity)
- Geometry proportions and door component dimensions that look correct
- Any R3F/Drei version-specific behaviors or gotchas encountered
- Color values and material parameters that match the Proinn brand aesthetic
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/home/anisy/projects/stalendeuren/.claude/agent-memory/3d-visual-lead/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- Use the Write and Edit tools to update your memory files
What to save:
- Stable patterns and conventions confirmed across multiple interactions
- Key architectural decisions, important file paths, and project structure
- User preferences for workflow, tools, and communication style
- Solutions to recurring problems and debugging insights
What NOT to save:
- Session-specific context (current task details, in-progress work, temporary state)
- Information that might be incomplete — verify against project docs before writing
- Anything that duplicates or contradicts existing CLAUDE.md instructions
- Speculative or unverified conclusions from reading a single file
Explicit user requests:
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.

View File

@@ -0,0 +1,159 @@
---
name: configurator-logic-lead
description: "Use this agent when working on the Proinn Configurator's business logic, state management, pricing engine, validation rules, or data structures. This includes implementing pricing calculations, dimension constraints, standard size presets, Zustand store updates, form validation schemas, and any step component data flow. Do NOT use this agent for 3D rendering, shaders, lighting, or visual styling tasks.\\n\\nExamples:\\n\\n- User: \"Add a maximum width validation of 1200mm to the dimension step\"\\n Assistant: \"I'll use the configurator-logic-lead agent to implement the dimension validation constraint.\"\\n <launches configurator-logic-lead agent>\\n\\n- User: \"Implement the pricing formula for steel doors with glass panels\"\\n Assistant: \"Let me launch the configurator-logic-lead agent to build the pricing engine calculation.\"\\n <launches configurator-logic-lead agent>\\n\\n- User: \"Add standard size presets like 900x2100 and 1000x2400 to the configurator\"\\n Assistant: \"I'll use the configurator-logic-lead agent to implement the standard size presets with their associated pricing.\"\\n <launches configurator-logic-lead agent>\\n\\n- User: \"The configurator state isn't updating correctly when switching between steps\"\\n Assistant: \"This is a state management issue - let me launch the configurator-logic-lead agent to debug the Zustand store transitions.\"\\n <launches configurator-logic-lead agent>\\n\\n- Context: After writing a new configurator step component that handles user dimension input.\\n Assistant: \"A significant piece of configurator logic was written. Let me use the configurator-logic-lead agent to verify the data flow between the step component and the store, and ensure validation rules are properly applied.\"\\n <launches configurator-logic-lead agent>"
model: sonnet
color: green
memory: project
---
You are the **Logic & Architecture Lead** for the Proinn Configurator — an elite systems architect specializing in multi-step product configurators, pricing engines, and state management for e-commerce/lead-generation applications. You bring deep expertise in TypeScript type safety, Zustand state management, Zod validation schemas, and business rule implementation.
## Project Context
You are working on the Proinn project (proinn.youztech.nl), a lead-generation site rebuilt from a Lightspeed webshop. The core feature is a **Product Configurator** — a multi-step wizard that allows users to configure steel doors (stalendeuren) and request quotes.
**Tech Stack:**
- Next.js (App Router, TypeScript)
- Tailwind CSS v4, Shadcn/UI
- React Hook Form + Zod for form validation
- Zustand for state management
- Resend for email (Server Actions)
**Key Directories:**
- `components/offerte/` — Configurator wizard step components (`step-*.tsx`)
- `lib/` — Utilities, store, pricing, validation modules
- `actions/` — Server Actions
- `components/ui/` — Shadcn components (DO NOT modify these)
## Your Responsibilities
### 1. Business Logic Implementation
- Implement and enforce dimensional constraints (e.g., min/max width, height, panel counts)
- Build pricing formulas that account for materials, dimensions, glass types, hardware, and configurations
- Implement standard size presets derived from market analysis with pre-calculated pricing
- Ensure all business rules are centralized and testable, not scattered across UI components
### 2. State Management (Zustand Store)
- Design and maintain `store.ts` as the single source of truth for configurator state
- Ensure all state updates are **immutable** — never mutate state directly
- Use Zustand slices or logical groupings for: dimensions, configuration options, pricing, validation state, and wizard navigation
- Implement computed/derived state where appropriate (e.g., total price derived from selections)
- Add proper TypeScript typing for all state and actions
### 3. Data Structures & Interfaces
- Design `DoorModel`, `DoorConfiguration`, `PricingResult`, and related TypeScript interfaces
- Ensure interfaces serve as the contract between UI step components and any 3D visualization
- Keep interfaces in a shared types file so both logic and visual layers can consume them
- Version or extend interfaces carefully to avoid breaking changes
### 4. Validation & Error Handling
- Build Zod schemas for each configurator step that validate user input
- Implement cross-field validation (e.g., "if door type is X, then max panels = 3")
- Provide clear, user-friendly Dutch error messages
- Handle edge cases: invalid combinations, boundary values, empty states
- Validate the complete configuration before quote submission
### 5. Smart Pricing Engine
- Implement a modular pricing engine in `pricing.ts` with clear calculation breakdowns
- Support base price + additive pricing model: `Price = BaseMaterial + GlassType + Hardware + SizeModifier + Extras`
- Implement price tiers for standard vs. custom sizes
- Include margin calculations and round to appropriate precision
- Make pricing rules data-driven (configurable constants, not hardcoded magic numbers)
- Log pricing breakdowns for debugging and transparency
### 6. Standard Size Presets
- Define standard door sizes based on market analysis as a typed constant array
- Each preset should include: dimensions, recommended glass type, base price, and availability
- Implement a "closest standard size" suggestion when users input custom dimensions
- Apply discounts or preferred pricing for standard sizes vs. custom orders
## Strict Boundaries — DO NOT CROSS
- **DO NOT** modify 3D rendering code, shaders, materials, lighting, camera setup, or Three.js/R3F scene components
- **DO NOT** modify Shadcn UI base components in `components/ui/`
- **DO NOT** add visual styling beyond basic Tailwind utility classes needed for layout in step components
- **DO NOT** implement email sending logic (that belongs in Server Actions, handled separately)
- **ONLY** edit: `store.ts`, `pricing.ts`, `validation.ts`, type definition files, `step-*.tsx` (data/logic portions), and related utility modules
## Code Quality Standards
1. **Type Safety First:** No `any` types. Use discriminated unions, generics, and proper narrowing.
2. **Pure Functions for Logic:** Pricing calculations and validation must be pure functions — no side effects, fully testable.
3. **Constants Over Magic Numbers:** All thresholds, limits, and pricing factors must be named constants.
4. **Error Boundaries:** Wrap critical calculations in try-catch with meaningful error logging.
5. **Documentation:** Add JSDoc comments to all public functions, interfaces, and complex logic.
6. **Naming Conventions:** Use camelCase for variables/functions, PascalCase for types/interfaces, SCREAMING_SNAKE_CASE for constants.
## Decision-Making Framework
When facing architectural decisions:
1. **Correctness first** — A wrong price or invalid configuration is worse than a slow UI
2. **Type safety second** — If TypeScript can't catch the bug, add runtime validation
3. **Simplicity third** — Prefer straightforward imperative logic over clever abstractions
4. **Performance last** — Only optimize when you have evidence of a bottleneck
## Self-Verification Checklist
Before completing any task, verify:
- [ ] All new/modified functions have proper TypeScript types
- [ ] Zod schemas match the corresponding TypeScript interfaces
- [ ] State updates in the Zustand store are immutable (spread operators, no direct mutation)
- [ ] Pricing calculations handle edge cases (zero dimensions, negative values, overflow)
- [ ] Validation error messages are in Dutch and user-friendly
- [ ] No 3D rendering code was touched
- [ ] Constants are used instead of magic numbers
- [ ] The store's state shape is consistent with what step components expect
## Current Mission: Smart Pricing Engine & Standard Size Presets
Your immediate priorities are:
1. Design and implement the `PricingEngine` module with a clear, extensible calculation pipeline
2. Define standard door size presets with market-competitive pricing
3. Implement the pricing rules: material costs, glass costs, hardware, size modifiers, and standard-size discounts
4. Connect the pricing engine to the Zustand store so prices update reactively as users configure
5. Add validation rules that prevent impossible configurations before they reach pricing
**Update your agent memory** as you discover business rules, pricing formulas, validation constraints, state management patterns, and architectural decisions in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
Examples of what to record:
- Pricing formula components and their locations
- Dimension constraints and their business justification
- Zustand store shape and slice organization
- Standard size presets and their source data
- Validation rules and cross-field dependencies
- Interface contracts between configurator steps and the store
- Edge cases discovered and how they were handled
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/home/anisy/projects/stalendeuren/.claude/agent-memory/configurator-logic-lead/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- Use the Write and Edit tools to update your memory files
What to save:
- Stable patterns and conventions confirmed across multiple interactions
- Key architectural decisions, important file paths, and project structure
- User preferences for workflow, tools, and communication style
- Solutions to recurring problems and debugging insights
What NOT to save:
- Session-specific context (current task details, in-progress work, temporary state)
- Information that might be incomplete — verify against project docs before writing
- Anything that duplicates or contradicts existing CLAUDE.md instructions
- Speculative or unverified conclusions from reading a single file
Explicit user requests:
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.

View File

@@ -0,0 +1,149 @@
---
name: frontend-stylist
description: "Use this agent when you need to implement or refine visual styling, design system tokens, Tailwind CSS configurations, UI polish, responsive layouts, or translate reference CSS into Tailwind utility classes for the Proinn Configurator project. This agent is specifically for the 'Anti-Gravity' design system: floating cards, soft shadows, premium typography, and mobile-first responsive design.\\n\\nExamples:\\n\\n<example>\\nContext: The user wants to apply the competitor's color palette from the scraped CSS file to the Tailwind config.\\nuser: \"Apply the colors from the aluwdoors reference CSS to our Tailwind config\"\\nassistant: \"I'll use the frontend-stylist agent to read the reference CSS and translate those color values into our Tailwind configuration.\"\\n<commentary>\\nSince this is a styling task involving translating reference CSS into Tailwind config, use the Task tool to launch the frontend-stylist agent.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: The user has just built a new configurator step component and it needs styling.\\nuser: \"I just created step-dimensions.tsx, can you style it to match our design system?\"\\nassistant: \"I'll launch the frontend-stylist agent to apply the Anti-Gravity design system styles to the new step component.\"\\n<commentary>\\nSince a new UI component needs styling with floating cards, shadows, and responsive design, use the Task tool to launch the frontend-stylist agent.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: The user notices the configurator looks broken on mobile.\\nuser: \"The configurator buttons are overlapping on iPhone, fix the mobile layout\"\\nassistant: \"I'll use the frontend-stylist agent to fix the mobile-first responsive layout for the configurator buttons.\"\\n<commentary>\\nSince this is a mobile responsive styling issue in the configurator UI, use the Task tool to launch the frontend-stylist agent.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: The user wants to add smooth transitions and hover effects to the step cards.\\nuser: \"Make the option cards feel more premium with hover animations\"\\nassistant: \"I'll launch the frontend-stylist agent to implement smooth transitions and premium hover effects on the option cards.\"\\n<commentary>\\nSince this involves UI polish, transitions, and visual refinement, use the Task tool to launch the frontend-stylist agent.\\n</commentary>\\n</example>"
model: sonnet
color: cyan
memory: project
---
You are the **Frontend-Stylist**, an elite UI/CSS specialist for the Proinn Configurator project — a lead-generation site for steel doors and windows being built at proinn.youztech.nl. You are the sole guardian of the "Anti-Gravity" Design System: floating UI cards, soft shadows, and premium typography that convey an industrial yet refined aesthetic.
## Your Identity & Expertise
You are a world-class frontend stylist who thinks in Tailwind utility classes. You have deep mastery of:
1. **Tailwind CSS**: Utility classes, arbitrary values (e.g., `h-[calc(100vh-80px)]`), `@apply` directives, responsive prefixes (`sm:`, `md:`, `lg:`, `xl:`), dark mode, animation utilities, and custom theme configuration.
2. **UI/UX Craft**: You ensure every interactive element feels tactile and intentional — buttons have satisfying hover states, transitions are buttery smooth (200-300ms ease-out), and spacing creates visual breathing room.
3. **Mobile-First Design**: You ALWAYS write mobile styles first, then layer on tablet and desktop enhancements. Every component must be fully functional and beautiful on a 375px viewport before you consider larger screens.
4. **CSS Translation**: You excel at reading raw CSS files (especially `public/aluwdoors-ref/configurator.css`) and translating exact values — colors, border-radii, shadows, fonts, spacing — into precise Tailwind config entries or utility classes.
## Tech Stack Context
- **Framework**: Next.js 16 (App Router, TypeScript)
- **Styling**: Tailwind CSS v4, Shadcn/UI (Slate base, CSS variables, new-york style)
- **Icons**: Lucide-React
- **Design Language**: Industrial, clean, heavy. Dark Grey/Black primary. Orange & Blue accents (from Proinn logo). Sans-serif typography (Inter/Roboto), bold headers.
## The "Anti-Gravity" Design System
Your design system principles:
1. **Floating Cards**: UI panels appear to hover above the background using layered box-shadows. No hard borders — use shadow depth to create hierarchy.
- Level 1 (subtle): `shadow-sm` or custom `shadow-[0_1px_3px_rgba(0,0,0,0.08)]`
- Level 2 (default cards): `shadow-md` or custom with slight vertical offset
- Level 3 (elevated/active): `shadow-lg` to `shadow-xl` with increased blur
2. **Soft Shadows**: Never use harsh black shadows. Use semi-transparent dark grays (`rgba(0,0,0,0.05)` to `rgba(0,0,0,0.15)`). Consider colored shadows for accent elements (e.g., orange glow on primary CTA).
3. **Premium Typography**:
- Headers: Bold/Extrabold, tight letter-spacing (`tracking-tight`), generous size scaling
- Body: Regular weight, comfortable line-height (`leading-relaxed`)
- Labels: Medium weight, uppercase with wide tracking for small labels
- Use font-size responsive scaling (`text-sm md:text-base lg:text-lg`)
4. **Rounded Corners**: Generous but not cartoonish. Default `rounded-xl` for cards, `rounded-lg` for buttons, `rounded-md` for inputs.
5. **Transitions**: Every interactive state change uses `transition-all duration-200 ease-out` minimum. Hover states should include subtle `scale-[1.02]` or shadow elevation changes.
6. **Spacing**: Use consistent spacing rhythm. Prefer `p-4 md:p-6 lg:p-8` for card padding. Use `gap-3 md:gap-4` for grid/flex gaps.
## Strict Constraints — READ CAREFULLY
### You MUST NOT:
- Touch any 3D code (React Three Fiber, @react-three/*, Three.js, Canvas components)
- Modify business logic, Zustand stores, state management, or data flow
- Edit server actions (`actions/` directory)
- Change routing logic or page-level data fetching
- Install new npm packages without explicitly stating why
### You MUST ONLY edit:
- `components/ui/*` — Shadcn UI component overrides and custom UI primitives
- `components/configurator/step-*.tsx` — Configurator wizard step components (styling only)
- `components/offerte/*` — Quote wizard step components (styling only)
- `components/layout/*` — Navbar, Footer styling
- `app/globals.css` — Global styles, CSS custom properties, Tailwind layers
- `tailwind.config.ts` — Theme extensions, custom colors, shadows, fonts, animations
### When editing step components:
- Only modify className attributes, style props, and JSX structure for layout purposes
- Do NOT alter event handlers, state updates, or conditional logic
- If you need to wrap elements for layout, use purely presentational `<div>` wrappers
## Workflow
1. **Read First**: Before making changes, read the target file AND `public/aluwdoors-ref/configurator.css` (if relevant) to understand current state and reference values.
2. **Plan**: Briefly describe what styles you'll apply and why.
3. **Implement Mobile-First**: Write the mobile layout first. Test mentally at 375px.
4. **Layer Up**: Add `sm:`, `md:`, `lg:` responsive variants.
5. **Verify Consistency**: Ensure your changes use design tokens from the Tailwind config, not magic numbers. If a value doesn't exist in config, add it to `tailwind.config.ts` first.
6. **Self-Check**: After applying styles, re-read the component and verify:
- No business logic was altered
- All interactive elements have hover/focus/active states
- The component is accessible (sufficient contrast, focus rings, semantic HTML)
- Transitions are smooth and intentional
## Reference CSS Translation Protocol
When translating from `public/aluwdoors-ref/configurator.css`:
1. Read the CSS file carefully, extracting:
- Color values → Add to `tailwind.config.ts` under `theme.extend.colors`
- Border-radius values → Map to Tailwind's `rounded-*` scale or add custom
- Box-shadow values → Add to `theme.extend.boxShadow` with descriptive names
- Font sizes/weights → Map to Tailwind typography scale
- Spacing/padding → Map to Tailwind spacing scale
2. Name tokens descriptively:
- Colors: `configurator-bg`, `configurator-card`, `configurator-accent`
- Shadows: `configurator-float`, `configurator-hover`, `configurator-active`
- Not generic names like `custom-1` or `blue-special`
3. Document the mapping in a comment at the top of the config section
## Current Mission
Your immediate task is to translate the scraped competitor styles from `public/aluwdoors-ref/configurator.css` — specifically colors, border-radius values, and shadow definitions — into our Tailwind config (`tailwind.config.ts`) and then apply them systematically to the configurator interface components. Ensure the result feels premium, industrial, and distinctly "Proinn" while borrowing the best UX patterns from the reference.
**Update your agent memory** as you discover design tokens, component styling patterns, responsive breakpoint decisions, and any CSS quirks or workarounds specific to this project. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
Examples of what to record:
- Color values extracted from reference CSS and their Tailwind token names
- Shadow definitions and which elevation level they map to
- Components that required special responsive handling
- Tailwind config customizations and why they were added
- Any CSS conflicts between Shadcn defaults and the Anti-Gravity design system
- Mobile-specific layout decisions and breakpoint choices
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/home/anisy/projects/stalendeuren/.claude/agent-memory/frontend-stylist/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- Use the Write and Edit tools to update your memory files
What to save:
- Stable patterns and conventions confirmed across multiple interactions
- Key architectural decisions, important file paths, and project structure
- User preferences for workflow, tools, and communication style
- Solutions to recurring problems and debugging insights
What NOT to save:
- Session-specific context (current task details, in-progress work, temporary state)
- Information that might be incomplete — verify against project docs before writing
- Anything that duplicates or contradicts existing CLAUDE.md instructions
- Speculative or unverified conclusions from reading a single file
Explicit user requests:
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.

View File

@@ -1,36 +1,32 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Stalen Deuren Configurator - Proinn.nl
## Getting Started
Premium 3D configurator voor stalen deuren en kozijnen, gebouwd met React Three Fiber en Next.js.
First, run the development server:
🔗 **Live Demo:** [proinn.youztech.nl](https://proinn.youztech.nl)
```bash
## ✨ Features
- 🎨 **Premium 3D Visualizer** - React Three Fiber met realtime updates
- ⚙️ **Geavanceerde Configuratie** - Deur types, dimensies, afwerkingen
- 📐 **Slimme Berekeningen** - Automatische dimensie validatie
- 🎯 **Premium UI/UX** - Shadcn/UI met responsive design
## 🚀 Quick Start
\`\`\`bash
npm install
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
\`\`\`
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Open [http://localhost:3000](http://localhost:3000)
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## 📦 Tech Stack
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
- Next.js 16 + React 19 + TypeScript
- React Three Fiber + Three.js
- Zustand + Tailwind CSS v4
- Shadcn/UI + Vercel
## Learn More
## 📄 License
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
© 2026 Proinn B.V.

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,89 +1,159 @@
"use client";
import { useRef } from "react";
import { useRef, useMemo, Suspense } from "react";
import { useConfiguratorStore } from "@/lib/store";
import { RoundedBox, Text, useTexture } from "@react-three/drei";
import { getMetalTexture } from "@/lib/asset-map";
import { RoundedBox, useTexture } from "@react-three/drei";
import * as THREE from "three";
import {
Beugelgreep,
Hoekgreep,
Maangreep,
Ovaalgreep,
Klink,
UGreep,
} from "./handles-3d";
import {
createStandardGlass,
createRoundedCornerGlass,
createInvertedUGlass,
createNormalUGlass,
} from "@/lib/glass-patterns";
import {
generateDoorAssembly,
mmToMeters,
getDividerPositions,
PROFILE_CORNER_RADIUS,
type PhysicalPart,
} from "@/lib/door-models";
// Steel material with photorealistic texture mapping
const SteelMaterial = ({ color, finish }: { color: string; finish: string }) => {
// ============================================
// PHOTOREALISTIC MATERIALS
// ============================================
/**
* Steel Material with Aluwdoors Texture
* Vertical steel grain for industrial look
*/
function SteelMaterialTextured({ color, finish }: { color: string; finish: string }) {
try {
const metalTexture = useTexture(getMetalTexture(finish));
// 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";
// Configure texture repeat for realistic grain (4x horizontal, 8x vertical)
metalTexture.wrapS = metalTexture.wrapT = THREE.RepeatWrapping;
metalTexture.repeat.set(4, 8);
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.colorSpace = THREE.SRGBColorSpace;
return (
<meshStandardMaterial
map={metalTexture}
map={texture}
color={color}
roughness={0.7} // Matte powdercoat finish
metalness={0.8}
envMapIntensity={1.2}
roughness={0.7}
metalness={0.6}
envMapIntensity={1.5}
/>
);
} catch (error) {
// Fallback to solid color if texture fails
return (
<meshStandardMaterial
color={color}
roughness={0.7}
metalness={0.8}
envMapIntensity={1}
/>
);
return <SteelMaterialFallback color={color} />;
}
};
}
// Glass material
const GlassMaterial = () => (
<meshPhysicalMaterial
color="#eff6ff"
transparent
transmission={1}
roughness={0.05}
thickness={2.5}
ior={1.5}
envMapIntensity={1}
clearcoat={1}
clearcoatRoughness={0}
/>
);
// 3D Dimension Label Component
function DimensionLabel({
value,
position,
label,
}: {
value: number;
position: [number, number, number];
label: string;
}) {
/**
* Fallback Steel Material (Solid Color)
*/
function SteelMaterialFallback({ color }: { color: string }) {
return (
<group position={position}>
<Text
fontSize={0.08}
color="#1a1a1a"
anchorX="center"
anchorY="middle"
font="/fonts/inter-bold.woff"
>
{`${Math.round(value)} ${label}`}
</Text>
{/* Background for better readability */}
<mesh position={[0, 0, -0.01]}>
<planeGeometry args={[0.4, 0.12]} />
<meshBasicMaterial color="#ffffff" opacity={0.9} transparent />
</mesh>
</group>
<meshStandardMaterial
color={color}
roughness={0.7}
metalness={0.6}
envMapIntensity={1.5}
/>
);
}
/**
* 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}
/>
);
// ============================================
// PHYSICAL PART RENDERER
// ============================================
/**
* Renders a single physical part with correct geometry
*/
function PhysicalPartComponent({
part,
frameColor,
finish,
}: {
part: PhysicalPart;
frameColor: string;
finish: string;
}) {
// Convert mm to meters
const x = mmToMeters(part.x);
const y = mmToMeters(part.y);
const z = mmToMeters(part.z);
const width = mmToMeters(part.width);
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 />
</mesh>
);
}
// Steel profiles use RoundedBox for realistic edges
const cornerRadius = mmToMeters(PROFILE_CORNER_RADIUS);
return (
<RoundedBox
args={[width, height, depth]}
radius={cornerRadius}
smoothness={4}
position={[x, y, z]}
castShadow
receiveShadow
>
<Suspense fallback={<SteelMaterialFallback color={frameColor} />}>
<SteelMaterialTextured color={frameColor} finish={finish} />
</Suspense>
</RoundedBox>
);
}
// ============================================
// MAIN DOOR COMPONENT
// ============================================
export function Door3DEnhanced() {
const { doorType, gridType, finish, handle, doorLeafWidth, height } =
const { doorType, gridType, finish, handle, glassPattern, doorLeafWidth, height } =
useConfiguratorStore();
const doorRef = useRef<THREE.Group>(null);
@@ -92,175 +162,161 @@ export function Door3DEnhanced() {
zwart: "#1a1a1a",
brons: "#8B6F47",
grijs: "#525252",
}[finish];
}[finish] || "#1a1a1a";
// Convert mm to meters for 3D scene
const doorWidth = doorLeafWidth / 1000; // Convert mm to m
const doorHeight = height / 1000; // Convert mm to m
// Generate door assembly from manufacturing specs
const doorAssembly = useMemo(
() => generateDoorAssembly(doorType, gridType, doorLeafWidth, height),
[doorType, gridType, doorLeafWidth, height]
);
// Profile dimensions (in meters)
const stileWidth = 0.04; // 40mm vertical profiles
const stileDepth = 0.04; // 40mm depth
const railHeight = 0.02; // 20mm horizontal profiles
const railDepth = 0.04; // 40mm depth
const glassThickness = 0.008; // 8mm glass
const profileRadius = 0.001; // 1mm rounded corners
// Convert dimensions to meters
const doorWidth = mmToMeters(doorLeafWidth);
const doorHeight = mmToMeters(height);
// Calculate positions for grid dividers
const getDividerPositions = () => {
if (gridType === "3-vlak") {
return [-doorHeight / 3, doorHeight / 3];
} else if (gridType === "4-vlak") {
return [-doorHeight / 2, 0, doorHeight / 2];
}
return [];
};
// Profile dimensions in meters (for handle positioning)
const stileWidth = mmToMeters(40);
const railDepth = mmToMeters(40);
const dividerPositions = getDividerPositions();
// Get divider positions for glass patterns (backward compatibility)
const dividerPositions = getDividerPositions(gridType, height);
return (
<group ref={doorRef} position={[0, doorHeight / 2, 0]}>
{/* LEFT STILE - Vertical profile */}
<RoundedBox
args={[stileWidth, doorHeight, stileDepth]}
radius={profileRadius}
smoothness={4}
position={[-doorWidth / 2 + stileWidth / 2, 0, 0]}
castShadow
>
<SteelMaterial color={frameColor} finish={finish} />
</RoundedBox>
{/* RIGHT STILE - Vertical profile */}
<RoundedBox
args={[stileWidth, doorHeight, stileDepth]}
radius={profileRadius}
smoothness={4}
position={[doorWidth / 2 - stileWidth / 2, 0, 0]}
castShadow
>
<SteelMaterial color={frameColor} finish={finish} />
</RoundedBox>
{/* TOP RAIL - Horizontal profile */}
<RoundedBox
args={[doorWidth - stileWidth * 2, railHeight, railDepth]}
radius={profileRadius}
smoothness={4}
position={[0, doorHeight / 2 - railHeight / 2, 0]}
castShadow
>
<SteelMaterial color={frameColor} finish={finish} />
</RoundedBox>
{/* BOTTOM RAIL - Horizontal profile */}
<RoundedBox
args={[doorWidth - stileWidth * 2, railHeight, railDepth]}
radius={profileRadius}
smoothness={4}
position={[0, -doorHeight / 2 + railHeight / 2, 0]}
castShadow
>
<SteelMaterial color={frameColor} finish={finish} />
</RoundedBox>
{/* INTERMEDIATE RAILS (Grid dividers) */}
{dividerPositions.map((yPos, index) => (
<RoundedBox
key={`rail-${index}`}
args={[doorWidth - stileWidth * 2, railHeight, railDepth]}
radius={profileRadius}
smoothness={4}
position={[0, yPos, 0]}
castShadow
>
<SteelMaterial color={frameColor} finish={finish} />
</RoundedBox>
{/* RENDER ALL PHYSICAL PARTS */}
{doorAssembly.parts.map((part, index) => (
<PhysicalPartComponent
key={`${part.type}-${index}`}
part={part}
frameColor={frameColor}
finish={finish}
/>
))}
{/* VERTICAL DIVIDER for Paneel */}
{doorType === "paneel" && (
<RoundedBox
args={[stileWidth, doorHeight - railHeight * 2, stileDepth]}
radius={profileRadius}
smoothness={4}
position={[0, 0, 0]}
castShadow
>
<SteelMaterial color={frameColor} finish={finish} />
</RoundedBox>
)}
{/* GLASS PANELS WITH PATTERNS */}
{glassPattern !== "standard" && (
<group position={[0, 0, 0.005]}>
{glassPattern === "dt9-rounded" && (
<mesh castShadow receiveShadow>
<extrudeGeometry
args={[
createRoundedCornerGlass(
doorWidth - stileWidth * 2,
doorHeight - stileWidth * 2,
0.12
),
{ depth: 0.01, bevelEnabled: false },
]}
/>
<GlassMaterial />
</mesh>
)}
{/* GLASS PANEL - Sits inside the frame */}
<mesh position={[0, 0, 0]} castShadow receiveShadow>
<boxGeometry
args={[
doorWidth - stileWidth * 2,
doorHeight - railHeight * 2,
glassThickness,
]}
/>
<GlassMaterial />
</mesh>
{glassPattern === "dt10-ushape" && dividerPositions.length > 0 && (
<>
{/* Top section - Inverted U */}
<mesh
position={[0, (doorHeight / 4 + dividerPositions[0]) / 2, 0]}
castShadow
receiveShadow
>
<extrudeGeometry
args={[
createInvertedUGlass(
doorWidth - stileWidth * 2,
Math.abs(doorHeight / 2 - stileWidth - dividerPositions[0])
),
{ depth: 0.01, bevelEnabled: false },
]}
/>
<GlassMaterial />
</mesh>
{/* HANDLE - U-Greep for Taats */}
{doorType === "taats" && handle === "u-greep" && (
<RoundedBox
args={[0.02, 0.6, 0.02]}
radius={0.003}
smoothness={4}
position={[0, 0, railDepth / 2 + 0.01]}
castShadow
>
<SteelMaterial color={frameColor} finish={finish} />
</RoundedBox>
)}
{/* HANDLE - Klink for Scharnier */}
{doorType === "scharnier" && handle === "klink" && (
<group position={[doorWidth / 2 - stileWidth - 0.1, 0, railDepth / 2 + 0.01]}>
<RoundedBox args={[0.08, 0.02, 0.02]} radius={0.003} smoothness={4} castShadow>
<SteelMaterial color={frameColor} finish={finish} />
</RoundedBox>
<mesh position={[0.04, 0, 0]} castShadow>
<sphereGeometry args={[0.015, 32, 32]} />
<meshStandardMaterial
color={finish === "brons" ? "#6B5434" : frameColor}
metalness={0.95}
roughness={0.05}
envMapIntensity={1.2}
/>
</mesh>
{/* Bottom section - Normal U */}
<mesh
position={[
0,
(-doorHeight / 4 + dividerPositions[dividerPositions.length - 1]) / 2,
0,
]}
castShadow
receiveShadow
>
<extrudeGeometry
args={[
createNormalUGlass(
doorWidth - stileWidth * 2,
Math.abs(
-doorHeight / 2 +
stileWidth -
dividerPositions[dividerPositions.length - 1]
)
),
{ depth: 0.01, bevelEnabled: false },
]}
/>
<GlassMaterial />
</mesh>
</>
)}
</group>
)}
{/* 3D DIMENSION LABELS */}
{/* Width dimension */}
<DimensionLabel
value={doorLeafWidth}
position={[0, -doorHeight / 2 - 0.15, 0.1]}
label="mm"
/>
{/* Height dimension */}
<DimensionLabel
value={height}
position={[doorWidth / 2 + 0.15, 0, 0.1]}
label="mm"
/>
{/* Dimension lines */}
{/* Horizontal line for width */}
<mesh position={[0, -doorHeight / 2 - 0.1, 0.05]}>
<boxGeometry args={[doorWidth, 0.002, 0.002]} />
<meshBasicMaterial color="#1a1a1a" />
</mesh>
{/* Vertical line for height */}
<mesh position={[doorWidth / 2 + 0.1, 0, 0.05]}>
<boxGeometry args={[0.002, doorHeight, 0.002]} />
<meshBasicMaterial color="#1a1a1a" />
</mesh>
{/* PROFESSIONAL 3D HANDLES */}
{handle === "beugelgreep" && (
<Beugelgreep
finish={finish}
doorWidth={doorWidth}
doorHeight={doorHeight}
railDepth={railDepth}
stileWidth={stileWidth}
/>
)}
{handle === "hoekgreep" && (
<Hoekgreep
finish={finish}
doorWidth={doorWidth}
doorHeight={doorHeight}
railDepth={railDepth}
stileWidth={stileWidth}
/>
)}
{handle === "maangreep" && (
<Maangreep
finish={finish}
doorWidth={doorWidth}
doorHeight={doorHeight}
railDepth={railDepth}
stileWidth={stileWidth}
/>
)}
{handle === "ovaalgreep" && (
<Ovaalgreep
finish={finish}
doorWidth={doorWidth}
doorHeight={doorHeight}
railDepth={railDepth}
stileWidth={stileWidth}
/>
)}
{handle === "klink" && (
<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}
/>
)}
</group>
);
}

View File

@@ -0,0 +1,443 @@
"use client";
import { Suspense } from "react";
import { RoundedBox, useTexture } from "@react-three/drei";
import * as THREE from "three";
// ============================================
// PHYSICAL CONSTANTS (mm converted to meters)
// ============================================
const PROFILE_DEPTH_M = 0.04; // 40mm profile depth
const DOOR_FACE_Z = PROFILE_DEPTH_M / 2; // 20mm - front face of door
const MOUNT_RADIUS = 0.006; // 6mm radius standoff cylinders
const MOUNT_LENGTH = 0.04; // 40mm standoff length
const MOUNT_CENTER_Z = DOOR_FACE_Z + MOUNT_LENGTH / 2; // Center of mount
const GRIP_CENTER_Z = DOOR_FACE_Z + MOUNT_LENGTH; // Front face of mount = grip center
const GRIP_RADIUS = 0.01; // 10mm radius for round grips
const GRIP_BAR_SIZE = 0.02; // 20mm for square grip cross-section
export interface HandleProps {
finish: string;
doorWidth: number;
doorHeight: number;
railDepth: number;
stileWidth: number;
}
// ============================================
// MATERIALS
// ============================================
/**
* Powder-coated steel material matching door frame finish.
* Loaded with texture for visual continuity with the frame.
*/
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 texture = useTexture(texturePath);
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(0.2, 1);
texture.colorSpace = THREE.SRGBColorSpace;
return (
<meshStandardMaterial
map={texture}
color={color}
roughness={0.7}
metalness={0.6}
envMapIntensity={1.5}
/>
);
} catch {
return <HandleMaterialFallback color={color} />;
}
}
function HandleMaterialFallback({ color }: { color: string }) {
return (
<meshStandardMaterial
color={color}
roughness={0.7}
metalness={0.6}
envMapIntensity={1.5}
/>
);
}
/** Wrap textured material in Suspense */
function PowderCoatMaterial({ color, finish }: { color: string; finish: string }) {
return (
<Suspense fallback={<HandleMaterialFallback color={color} />}>
<HandleMaterialTextured color={color} finish={finish} />
</Suspense>
);
}
function getColor(finish: string): string {
return { zwart: "#1a1a1a", brons: "#8B6F47", grijs: "#525252" }[finish] || "#1a1a1a";
}
// ============================================
// SHARED MOUNT COMPONENT
// ============================================
/**
* A single cylindrical standoff (pootje) connecting handle to door face.
* Rotated 90° on X to point outward from the door surface.
*/
function MountStandoff({
position,
color,
finish,
}: {
position: [number, number, number];
color: string;
finish: string;
}) {
return (
<mesh
position={position}
rotation={[Math.PI / 2, 0, 0]}
castShadow
>
<cylinderGeometry args={[MOUNT_RADIUS, MOUNT_RADIUS, MOUNT_LENGTH, 16]} />
<PowderCoatMaterial color={color} finish={finish} />
</mesh>
);
}
// ============================================
// HANDLE COMPONENTS
// ============================================
/**
* U-Greep: Proper U-shaped bar handle with two standoff mounts.
* The grip sits 40mm off the door face, connected by two cylindrical pootjes.
*/
export function UGreep({ finish, doorHeight }: 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
return (
<group position={[0, 0, 0]}>
{/* Top mount standoff */}
<MountStandoff
position={[0, mountSpacing / 2, MOUNT_CENTER_Z]}
color={color}
finish={finish}
/>
{/* Bottom mount standoff */}
<MountStandoff
position={[0, -mountSpacing / 2, MOUNT_CENTER_Z]}
color={color}
finish={finish}
/>
{/* Vertical grip bar */}
<RoundedBox
args={[GRIP_BAR_SIZE, gripLength, GRIP_BAR_SIZE]}
radius={0.003}
smoothness={4}
position={[0, 0, GRIP_CENTER_Z]}
castShadow
receiveShadow
>
<PowderCoatMaterial color={color} finish={finish} />
</RoundedBox>
</group>
);
}
/**
* Beugelgreep: Vertical bar handle (round) with mounting blocks.
* Two rectangular mounting blocks press against the door face,
* with a round bar connecting them.
*/
export function Beugelgreep({ finish, doorHeight }: 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;
return (
<group position={[0, 0, 0]}>
{/* Top mounting block (sits on door face, extends outward) */}
<RoundedBox
args={mountBlockSize}
radius={0.003}
smoothness={4}
position={[0, mountSpacing / 2, MOUNT_CENTER_Z]}
castShadow
receiveShadow
>
<PowderCoatMaterial color={color} finish={finish} />
</RoundedBox>
{/* Bottom mounting block */}
<RoundedBox
args={mountBlockSize}
radius={0.003}
smoothness={4}
position={[0, -mountSpacing / 2, MOUNT_CENTER_Z]}
castShadow
receiveShadow
>
<PowderCoatMaterial color={color} finish={finish} />
</RoundedBox>
{/* Main vertical round bar */}
<mesh position={[0, 0, GRIP_CENTER_Z]} castShadow>
<cylinderGeometry args={[barDiameter / 2, barDiameter / 2, gripLength, 32]} />
<PowderCoatMaterial color={color} finish={finish} />
</mesh>
{/* Mounting screw details */}
{[mountSpacing / 2, -mountSpacing / 2].map((y, i) => (
<mesh
key={i}
position={[0, y, DOOR_FACE_Z + MOUNT_LENGTH + 0.002]}
castShadow
>
<cylinderGeometry args={[0.003, 0.003, 0.005, 12]} />
<meshStandardMaterial color="#2a2a2a" metalness={0.9} roughness={0.1} />
</mesh>
))}
</group>
);
}
/**
* Hoekgreep: L-shaped corner handle with standoff mounts.
* Horizontal bar + vertical bar meeting at a rounded corner.
*/
export function Hoekgreep({ finish, doorWidth, stileWidth }: HandleProps) {
const color = getColor(finish);
const horizontalLength = 0.15;
const verticalLength = 0.12;
const barThickness = 0.02;
const barWidth = 0.03;
// Position near right stile
const xPos = doorWidth / 2 - stileWidth - 0.12;
return (
<group position={[xPos, 0, 0]}>
{/* Top mount standoff */}
<MountStandoff
position={[horizontalLength * 0.8, verticalLength / 2, MOUNT_CENTER_Z]}
color={color}
finish={finish}
/>
{/* Bottom mount standoff */}
<MountStandoff
position={[0, -verticalLength * 0.3, MOUNT_CENTER_Z]}
color={color}
finish={finish}
/>
{/* Horizontal bar */}
<RoundedBox
args={[horizontalLength, barWidth, barThickness]}
radius={0.003}
smoothness={4}
position={[horizontalLength / 2, verticalLength / 2, GRIP_CENTER_Z]}
castShadow
>
<PowderCoatMaterial color={color} finish={finish} />
</RoundedBox>
{/* Vertical bar */}
<RoundedBox
args={[barWidth, verticalLength, barThickness]}
radius={0.003}
smoothness={4}
position={[0, 0, GRIP_CENTER_Z]}
castShadow
>
<PowderCoatMaterial color={color} finish={finish} />
</RoundedBox>
{/* Corner radius */}
<mesh
position={[0.015, verticalLength / 2 - 0.015, GRIP_CENTER_Z]}
castShadow
>
<torusGeometry args={[0.015, barThickness / 2, 16, 32, Math.PI / 2]} />
<PowderCoatMaterial color={color} finish={finish} />
</mesh>
</group>
);
}
/**
* Maangreep: Crescent/moon shaped handle with standoff mounts.
* Curved torus section mounted on two pootjes.
*/
export function Maangreep({ finish, doorWidth, stileWidth }: HandleProps) {
const color = getColor(finish);
const curveRadius = 0.08;
const xPos = doorWidth / 2 - stileWidth - 0.12;
return (
<group position={[xPos, 0, 0]}>
{/* Left mount standoff */}
<MountStandoff
position={[-curveRadius * 0.8, 0, MOUNT_CENTER_Z]}
color={color}
finish={finish}
/>
{/* Right mount standoff */}
<MountStandoff
position={[curveRadius * 0.8, 0, MOUNT_CENTER_Z]}
color={color}
finish={finish}
/>
{/* Main curved handle body */}
<mesh
rotation={[Math.PI / 2, 0, 0]}
position={[0, 0, GRIP_CENTER_Z]}
castShadow
>
<torusGeometry args={[curveRadius, 0.015, 16, 32, Math.PI]} />
<PowderCoatMaterial color={color} finish={finish} />
</mesh>
{/* Left end cap */}
<mesh position={[-curveRadius, 0, GRIP_CENTER_Z]} castShadow>
<sphereGeometry args={[0.015, 32, 32]} />
<PowderCoatMaterial color={color} finish={finish} />
</mesh>
{/* Right end cap */}
<mesh position={[curveRadius, 0, GRIP_CENTER_Z]} castShadow>
<sphereGeometry args={[0.015, 32, 32]} />
<PowderCoatMaterial color={color} finish={finish} />
</mesh>
</group>
);
}
/**
* Ovaalgreep: Oval/elliptical pull handle with standoff mounts.
* Extruded ellipse shape mounted on two pootjes.
*/
export function Ovaalgreep({ finish, doorWidth, stileWidth }: HandleProps) {
const color = getColor(finish);
const xPos = doorWidth / 2 - stileWidth - 0.12;
const shape = new THREE.Shape();
const rx = 0.06;
const ry = 0.03;
for (let i = 0; i <= 64; i++) {
const angle = (i / 64) * Math.PI * 2;
const x = Math.cos(angle) * rx;
const y = Math.sin(angle) * ry;
if (i === 0) shape.moveTo(x, y);
else shape.lineTo(x, y);
}
return (
<group position={[xPos, 0, 0]}>
{/* Top mount standoff */}
<MountStandoff
position={[0, ry * 0.6, MOUNT_CENTER_Z]}
color={color}
finish={finish}
/>
{/* Bottom mount standoff */}
<MountStandoff
position={[0, -ry * 0.6, MOUNT_CENTER_Z]}
color={color}
finish={finish}
/>
{/* Oval handle */}
<mesh position={[0, 0, GRIP_CENTER_Z - 0.01]} castShadow>
<extrudeGeometry
args={[shape, {
depth: 0.02,
bevelEnabled: true,
bevelThickness: 0.003,
bevelSize: 0.003,
bevelSegments: 8,
}]}
/>
<PowderCoatMaterial color={color} finish={finish} />
</mesh>
</group>
);
}
/**
* Klink: Traditional lever handle with rosette, standoff-mounted.
* Rosette plate against door, lever extending horizontally.
*/
export function Klink({ finish, doorWidth, stileWidth }: HandleProps) {
const color = getColor(finish);
const leverLength = 0.12;
const xPos = doorWidth / 2 - stileWidth - 0.1;
return (
<group position={[xPos, 0, 0]}>
{/* Mounting rosette (flat against door face) */}
<mesh position={[0, 0, DOOR_FACE_Z + 0.004]} castShadow>
<cylinderGeometry args={[0.03, 0.03, 0.008, 32]} />
<PowderCoatMaterial color={color} finish={finish} />
</mesh>
{/* Square spindle standoff (connects rosette to lever) */}
<mesh
position={[0, 0, MOUNT_CENTER_Z]}
rotation={[Math.PI / 2, 0, 0]}
castShadow
>
<cylinderGeometry args={[0.008, 0.008, MOUNT_LENGTH * 0.6, 8]} />
<PowderCoatMaterial color={color} finish={finish} />
</mesh>
{/* Lever handle */}
<RoundedBox
args={[leverLength, 0.02, 0.015]}
radius={0.005}
smoothness={4}
position={[leverLength / 2, 0, GRIP_CENTER_Z]}
rotation={[0, 0, -0.15]}
castShadow
>
<PowderCoatMaterial color={color} finish={finish} />
</RoundedBox>
{/* Lever end grip */}
<mesh position={[leverLength, -0.015, GRIP_CENTER_Z]} castShadow>
<sphereGeometry args={[0.012, 32, 32]} />
<PowderCoatMaterial color={color} finish={finish} />
</mesh>
{/* Lock cylinder below */}
<mesh position={[0, -0.045, DOOR_FACE_Z + 0.005]} castShadow>
<cylinderGeometry args={[0.008, 0.008, 0.01, 16]} />
<meshStandardMaterial
color={finish === "brons" ? "#6B5434" : "#2a2a2a"}
metalness={0.9}
roughness={0.2}
/>
</mesh>
</group>
);
}

View File

@@ -1,90 +1,274 @@
"use client";
import { Canvas } from "@react-three/fiber";
import { OrbitControls, PerspectiveCamera, Environment, ContactShadows } from "@react-three/drei";
import {
OrbitControls,
PerspectiveCamera,
Environment,
ContactShadows,
} from "@react-three/drei";
import { Door3DEnhanced } from "./door-3d-enhanced";
import { useConfiguratorStore } from "@/lib/store";
import {
STELRUIMTE,
WALL_THICKNESS,
TAATS_PIVOT_OFFSET,
mmToMeters,
} from "@/lib/door-models";
import * as THREE from "three";
function Room() {
const wallThickness = 0.15;
const doorWidth = 1.3;
const doorHeight = 2.5;
// ============================================
// WALL MATERIALS
// ============================================
/** Smooth painted wall surface */
const WallMaterial = () => (
<meshStandardMaterial color="#f5f2ed" roughness={0.95} metalness={0} />
);
/** Stucco/plaster reveal surface (inside the door opening) */
const RevealMaterial = () => (
<meshStandardMaterial color="#e8e4dd" roughness={1.0} metalness={0} />
);
/** Floor material - light wood */
const FloorMaterial = () => (
<meshStandardMaterial color="#e8dcc4" roughness={0.8} metalness={0} />
);
// ============================================
// WALL CONTAINER WITH PRECISE HOLE
// ============================================
/**
* Creates a wall with a precise rectangular opening (sparing).
* Uses 4 boxes to form the wall around the hole instead of CSG.
* The reveal (inner edge of the hole) has a plaster texture.
*/
function WallContainer({
holeWidth,
holeHeight,
wallThickness,
}: {
holeWidth: number; // meters - sparingsmaat width
holeHeight: number; // meters - sparingsmaat height
wallThickness: number; // meters
}) {
const wallWidth = 4.0; // Total wall width in meters
const wallHeight = 3.0; // Total wall height (floor to ceiling)
// Half dimensions for positioning
const halfHoleW = holeWidth / 2;
const halfWallT = wallThickness / 2;
// Left wall section: from left edge to hole left edge
const leftSectionWidth = (wallWidth - holeWidth) / 2;
const leftSectionX = -(halfHoleW + leftSectionWidth / 2);
// Right wall section: from hole right edge to right edge
const rightSectionWidth = leftSectionWidth;
const rightSectionX = halfHoleW + rightSectionWidth / 2;
// Top section: above hole, full width
const topSectionHeight = wallHeight - holeHeight;
const topSectionY = holeHeight + topSectionHeight / 2;
// Stelruimte gap (visual indicator)
const gapPerSide = mmToMeters(STELRUIMTE / 2);
return (
<group>
{/* Floor - Clean shadow catcher */}
<group position={[0, 0, 0]}>
{/* === MAIN WALL SECTIONS === */}
{/* Left wall section */}
<mesh
rotation={[-Math.PI / 2, 0, 0]}
position={[0, 0, 0]}
position={[leftSectionX, wallHeight / 2, 0]}
castShadow
receiveShadow
>
<planeGeometry args={[15, 15]} />
<meshStandardMaterial color="#f5f5f5" roughness={0.9} metalness={0} />
<boxGeometry args={[leftSectionWidth, wallHeight, wallThickness]} />
<WallMaterial />
</mesh>
{/* Proper Doorway with Reveal */}
<group position={[0, 0, -wallThickness / 2]}>
{/* Left Pillar */}
<mesh position={[-(doorWidth / 2 + wallThickness / 2), doorHeight / 2, 0]} receiveShadow castShadow>
<boxGeometry args={[wallThickness, doorHeight + wallThickness, wallThickness]} />
<meshStandardMaterial color="#fafafa" roughness={1} />
</mesh>
{/* Right Pillar */}
<mesh position={[doorWidth / 2 + wallThickness / 2, doorHeight / 2, 0]} receiveShadow castShadow>
<boxGeometry args={[wallThickness, doorHeight + wallThickness, wallThickness]} />
<meshStandardMaterial color="#fafafa" roughness={1} />
</mesh>
{/* Top Lintel */}
<mesh position={[0, doorHeight + wallThickness / 2, 0]} receiveShadow castShadow>
<boxGeometry args={[doorWidth + wallThickness * 2, wallThickness, wallThickness]} />
<meshStandardMaterial color="#fafafa" roughness={1} />
</mesh>
{/* Main Wall - Left Section */}
<mesh position={[-doorWidth - wallThickness * 2, 2.5, 0]} receiveShadow castShadow>
<boxGeometry args={[6, 5, wallThickness]} />
<meshStandardMaterial color="#fafafa" roughness={1} />
</mesh>
{/* Main Wall - Right Section */}
<mesh position={[doorWidth + wallThickness * 2, 2.5, 0]} receiveShadow castShadow>
<boxGeometry args={[6, 5, wallThickness]} />
<meshStandardMaterial color="#fafafa" roughness={1} />
</mesh>
{/* Main Wall - Top Section */}
<mesh position={[0, doorHeight + wallThickness + 1.25, 0]} receiveShadow castShadow>
<boxGeometry args={[doorWidth + wallThickness * 2, 2.5, wallThickness]} />
<meshStandardMaterial color="#fafafa" roughness={1} />
</mesh>
</group>
{/* Side Walls for depth */}
<mesh position={[-7, 2.5, 2]} receiveShadow castShadow>
<boxGeometry args={[0.15, 5, 10]} />
<meshStandardMaterial color="#fcfcfc" roughness={1} />
{/* Right wall section */}
<mesh
position={[rightSectionX, wallHeight / 2, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[rightSectionWidth, wallHeight, wallThickness]} />
<WallMaterial />
</mesh>
<mesh position={[7, 2.5, 2]} receiveShadow castShadow>
<boxGeometry args={[0.15, 5, 10]} />
<meshStandardMaterial color="#fcfcfc" roughness={1} />
{/* Top section (above hole, full wall width) */}
{topSectionHeight > 0.01 && (
<mesh
position={[0, topSectionY, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[holeWidth, topSectionHeight, wallThickness]} />
<WallMaterial />
</mesh>
)}
{/* === REVEAL SURFACES (inside the hole) === */}
{/* These are the plaster/stucco edges visible inside the opening */}
{/* Left reveal */}
<mesh
position={[-halfHoleW + 0.001, holeHeight / 2, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[0.002, holeHeight, wallThickness]} />
<RevealMaterial />
</mesh>
{/* Right reveal */}
<mesh
position={[halfHoleW - 0.001, holeHeight / 2, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[0.002, holeHeight, wallThickness]} />
<RevealMaterial />
</mesh>
{/* Top reveal */}
<mesh
position={[0, holeHeight - 0.001, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[holeWidth, 0.002, wallThickness]} />
<RevealMaterial />
</mesh>
{/* === REVEAL DEPTH SURFACES (visible sides inside the opening) === */}
{/* Left inner wall (visible when looking at the opening from the side) */}
<mesh
position={[-halfHoleW + gapPerSide / 2, holeHeight / 2, 0]}
receiveShadow
>
<boxGeometry args={[gapPerSide, holeHeight, wallThickness - 0.01]} />
<RevealMaterial />
</mesh>
{/* Right inner wall */}
<mesh
position={[halfHoleW - gapPerSide / 2, holeHeight / 2, 0]}
receiveShadow
>
<boxGeometry args={[gapPerSide, holeHeight, wallThickness - 0.01]} />
<RevealMaterial />
</mesh>
{/* Top inner wall (lintel reveal) */}
<mesh
position={[0, holeHeight - gapPerSide / 2, 0]}
receiveShadow
>
<boxGeometry args={[holeWidth - gapPerSide * 2, gapPerSide, wallThickness - 0.01]} />
<RevealMaterial />
</mesh>
{/* === FLOOR === */}
<mesh
rotation={[-Math.PI / 2, 0, 0]}
position={[0, 0, wallThickness]}
receiveShadow
>
<planeGeometry args={[wallWidth * 2, wallThickness * 8]} />
<FloorMaterial />
</mesh>
{/* Floor behind wall */}
<mesh
rotation={[-Math.PI / 2, 0, 0]}
position={[0, 0, -wallThickness]}
receiveShadow
>
<planeGeometry args={[wallWidth * 2, wallThickness * 8]} />
<FloorMaterial />
</mesh>
{/* === BASEBOARD (Plint) === */}
{/* Left side baseboard */}
<mesh
position={[leftSectionX, 0.04, halfWallT + 0.005]}
castShadow
>
<boxGeometry args={[leftSectionWidth, 0.08, 0.01]} />
<meshStandardMaterial color="#d5d0c8" roughness={0.8} />
</mesh>
{/* Right side baseboard */}
<mesh
position={[rightSectionX, 0.04, halfWallT + 0.005]}
castShadow
>
<boxGeometry args={[rightSectionWidth, 0.08, 0.01]} />
<meshStandardMaterial color="#d5d0c8" roughness={0.8} />
</mesh>
</group>
);
}
// ============================================
// DOOR + WALL COMPOSITION
// ============================================
function DoorInWall() {
const { doorType, doorLeafWidth, height, holeWidth } = useConfiguratorStore();
// Convert mm to meters
const doorWidthM = mmToMeters(doorLeafWidth);
const doorHeightM = mmToMeters(height);
const wallThicknessM = mmToMeters(WALL_THICKNESS);
// Sparingsmaat = the hole in the wall
// Use doorLeafWidth + stelruimte as the opening size
const stelruimteM = mmToMeters(STELRUIMTE);
const holeWidthM = doorWidthM + stelruimteM;
const holeHeightM = doorHeightM + stelruimteM / 2; // 5mm top tolerance
// Door Z position depends on type
// Taats: centered in wall thickness (pivot at center)
// Scharnier/Paneel: flush with front wall face
const doorZOffset = doorType === 'taats' ? 0 : wallThicknessM * 0.15;
return (
<>
{/* The wall with precise opening */}
<WallContainer
holeWidth={holeWidthM}
holeHeight={holeHeightM}
wallThickness={wallThicknessM}
/>
{/* The door, positioned inside the wall opening */}
<group position={[0, 0, doorZOffset]}>
<Door3DEnhanced />
</group>
</>
);
}
// ============================================
// LIGHTING
// ============================================
function Lighting() {
return (
<>
{/* Soft ambient light */}
{/* Ambient for overall illumination */}
<ambientLight intensity={0.5} />
{/* Key light - main illumination */}
{/* Main directional light (sunlight angle) */}
<directionalLight
position={[5, 10, 8]}
intensity={1.5}
position={[4, 6, 8]}
intensity={1.4}
castShadow
shadow-mapSize-width={4096}
shadow-mapSize-height={4096}
@@ -96,15 +280,28 @@ function Lighting() {
shadow-bias={-0.0001}
/>
{/* Rim light for separation */}
<directionalLight position={[-3, 3, -5]} intensity={0.6} />
{/* Fill light from behind/left to illuminate reveal */}
<directionalLight position={[-3, 4, -4]} intensity={0.3} />
{/* Fill light */}
<directionalLight position={[0, 2, 5]} intensity={0.4} />
{/* Subtle light from viewer side to show depth in reveal */}
<directionalLight position={[2, 3, 6]} intensity={0.4} />
{/* Top down light for reveal shadows */}
<directionalLight
position={[0, 8, 0]}
intensity={0.2}
castShadow
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
/>
</>
);
}
// ============================================
// MAIN SCENE EXPORT
// ============================================
export function Scene3D() {
return (
<Canvas
@@ -112,48 +309,47 @@ export function Scene3D() {
gl={{
antialias: true,
toneMapping: THREE.ACESFilmicToneMapping,
toneMappingExposure: 1.3,
toneMappingExposure: 1.2,
outputColorSpace: THREE.SRGBColorSpace,
}}
style={{ background: "#fafafa" }}
style={{ background: "#f0ede8" }}
>
{/* Camera */}
<PerspectiveCamera makeDefault position={[0, 1.6, 4.5]} fov={45} />
{/* Camera - positioned for wall view */}
<PerspectiveCamera makeDefault position={[0, 1.4, 4.5]} fov={45} />
{/* Camera Controls - Limited rotation */}
{/* Camera Controls */}
<OrbitControls
enablePan={false}
enableZoom={true}
minDistance={3.5}
minDistance={2.5}
maxDistance={7}
minPolarAngle={Math.PI / 3.5}
maxPolarAngle={Math.PI / 2.2}
maxAzimuthAngle={Math.PI / 6}
minAzimuthAngle={-Math.PI / 6}
target={[0, 1.2, 0]}
minPolarAngle={Math.PI / 3}
maxPolarAngle={Math.PI / 2.1}
maxAzimuthAngle={Math.PI / 3}
minAzimuthAngle={-Math.PI / 3}
target={[0, 1.1, 0]}
enableDamping
dampingFactor={0.05}
/>
{/* Premium Studio Lighting */}
{/* Lighting */}
<Lighting />
{/* City/Apartment Environment for realistic steel reflections */}
<Environment preset="city" environmentIntensity={0.8} />
{/* Apartment Environment for warm reflections */}
<Environment preset="apartment" blur={0.6} environmentIntensity={1.2} />
{/* High-Resolution Contact Shadows for grounding */}
{/* Contact shadows for floor grounding */}
<ContactShadows
position={[0, 0.01, 0]}
position={[0, 0.005, 0.15]}
opacity={0.5}
scale={10}
blur={2}
far={1}
resolution={1024}
scale={20}
blur={2.5}
far={4}
resolution={2048}
/>
{/* The Room */}
<Room />
{/* The Door - Enhanced with textures and dimensions */}
<Door3DEnhanced />
{/* Door mounted inside wall */}
<DoorInWall />
</Canvas>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import { useTexture } from "@react-three/drei";
import { useEffect, useState } from "react";
import * as THREE from "three";
/**
* Preload textures to prevent loading freezes
* Uses Suspense boundary for progressive loading
*/
export function useMetalTexture(finish: string) {
const [textureUrl, setTextureUrl] = useState<string | null>(null);
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",
};
setTextureUrl(mapping[finish] || mapping.zwart);
}, [finish]);
try {
if (!textureUrl) return null;
// Load texture with useTexture
const texture = useTexture(textureUrl);
// Configure texture for optimal rendering
if (texture) {
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(2, 2);
texture.colorSpace = THREE.SRGBColorSpace;
}
return texture;
} catch (error) {
// Fallback: return null if texture fails to load
console.warn("Texture loading failed, using solid color fallback", error);
return null;
}
}
/**
* Enhanced Steel Material with texture support
*/
export function SteelMaterialWithTexture({
color,
finish,
}: {
color: string;
finish: string;
}) {
const texture = useMetalTexture(finish);
return (
<meshStandardMaterial
map={texture}
color={color}
roughness={0.7}
metalness={0.8}
envMapIntensity={1.2}
/>
);
}
/**
* Fallback Steel Material (solid color)
*/
export function SteelMaterialSolid({ color }: { color: string }) {
return (
<meshStandardMaterial
color={color}
roughness={0.7}
metalness={0.8}
envMapIntensity={1}
/>
);
}

View File

@@ -150,6 +150,44 @@ export function StepDimensions() {
/>
</div>
{/* Height Presets - Dutch Market Standards */}
<div>
<Label className="text-base font-bold text-[#1A2E2E]">
Standaard Hoogtes
</Label>
<p className="mb-3 text-sm text-gray-600">
Kies een standaard hoogte of stel handmatig in.
</p>
<div className="grid grid-cols-3 gap-2">
{[
{ label: 'Renovatie', value: 2015, desc: '201.5 cm' },
{ label: 'Nieuwbouw', value: 2315, desc: '231.5 cm' },
{ label: 'Plafondhoog', value: 2500, desc: '250+ cm' },
].map((preset) => {
const isActive = height === preset.value;
return (
<button
key={preset.value}
type="button"
onClick={() => setHeight(preset.value)}
className={`rounded-lg border-2 px-3 py-2.5 text-center transition-all ${
isActive
? 'border-[#C4D668] bg-[#1A2E2E] text-white shadow-md'
: 'border-gray-200 bg-white text-gray-700 hover:border-[#1A2E2E]/30 hover:shadow-sm'
}`}
>
<span className="block text-xs font-bold uppercase tracking-wide">
{preset.label}
</span>
<span className={`block font-mono text-sm ${isActive ? 'text-[#C4D668]' : 'text-gray-500'}`}>
{preset.desc}
</span>
</button>
);
})}
</div>
</div>
{/* Height Control */}
<div>
<div className="mb-4 flex items-end justify-between">

View File

@@ -1,6 +1,7 @@
"use client";
import { useConfiguratorStore, type Finish, type Handle } from "@/lib/store";
import { glassPatternOptions, type GlassPattern } from "@/lib/glass-patterns";
import { Check } from "lucide-react";
const finishOptions: Array<{
@@ -22,17 +23,46 @@ 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: "Verticale greep voor taatsdeur",
description: "Eenvoudige rechte staaf",
},
{
value: "geen",
label: "Geen greep",
description: "Voor vaste panelen",
},
{ value: "klink", label: "Klink", description: "Klassieke deurklink" },
{ value: "geen", label: "Geen greep", description: "Voor vaste panelen" },
];
export function StepOptions() {
const { finish, handle, setFinish, setHandle } = useConfiguratorStore();
const { finish, handle, glassPattern, setFinish, setHandle, setGlassPattern } =
useConfiguratorStore();
return (
<div className="space-y-8">
@@ -95,6 +125,51 @@ export function StepOptions() {
</div>
</div>
{/* Glass Pattern Selection */}
<div>
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Glaspatroon</h2>
<p className="mb-4 text-sm text-gray-600">
Kies het decoratieve patroon voor de glaspanelen.
</p>
<div className="grid gap-3">
{glassPatternOptions.map((option) => {
const selected = glassPattern === option.value;
return (
<button
key={option.value}
type="button"
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>
</button>
);
})}
</div>
</div>
{/* Handle Selection */}
<div>
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Greep</h2>

View File

@@ -4,6 +4,88 @@ import { useConfiguratorStore, type DoorType } from "@/lib/store";
import { useFormContext } from "@/components/offerte/form-context";
import { Check } from "lucide-react";
// Door type visual icons (inline SVGs)
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>
);
}
function ScharnierIcon({ 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" />
{/* 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>
);
}
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>
);
}
const doorTypeIcons: Record<DoorType, (props: { selected: boolean }) => React.ReactElement> = {
taats: TaatsIcon,
scharnier: ScharnierIcon,
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";
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>
);
}
const doorTypes: Array<{
value: DoorType;
label: string;
@@ -12,17 +94,17 @@ const doorTypes: Array<{
{
value: "taats",
label: "Taatsdeur",
description: "Pivoterende deur met verticaal draaimechanisme",
description: "Pivoterende deur",
},
{
value: "scharnier",
label: "Scharnierdeur",
description: "Klassieke deur met zijscharnieren",
description: "Zijscharnieren",
},
{
value: "paneel",
label: "Vast Paneel",
description: "Vast glaspaneel zonder bewegend mechanisme",
description: "Geen beweging",
},
];
@@ -30,10 +112,11 @@ const gridTypes: Array<{
value: "3-vlak" | "4-vlak" | "geen";
label: string;
description: string;
dividers: number;
}> = [
{ value: "geen", label: "Geen verdeling", description: "Volledig vlak" },
{ value: "3-vlak", label: "3-vlaks", description: "2 horizontale balken" },
{ value: "4-vlak", label: "4-vlaks", description: "3 horizontale balken" },
{ 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 },
];
export function StepProduct() {
@@ -41,73 +124,61 @@ export function StepProduct() {
const { doorType, gridType, setDoorType, setGridType } =
useConfiguratorStore();
function handleDoorTypeSelect(type: DoorType) {
setDoorType(type);
}
function handleGridTypeSelect(type: "3-vlak" | "4-vlak" | "geen") {
setGridType(type);
}
function handleContinue() {
nextStep();
}
return (
<div className="space-y-8">
{/* Door Type Selection */}
{/* Door Type Selection - Visual Tiles */}
<div>
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Kies uw deurtype</h2>
<p className="mb-4 text-sm text-gray-600">
Selecteer het type stalen deur dat u wilt configureren.
</p>
<div className="grid gap-3">
<div className="grid grid-cols-3 gap-3">
{doorTypes.map((type) => {
const selected = doorType === type.value;
const IconComponent = doorTypeIcons[type.value];
return (
<button
key={type.value}
type="button"
onClick={() => handleDoorTypeSelect(type.value)}
className={`group relative rounded-xl border-2 p-4 text-left transition-all ${
onClick={() => setDoorType(type.value)}
className={`group relative flex flex-col items-center rounded-xl border-2 px-2 py-4 transition-all ${
selected
? "border-[#C4D668] bg-[#1A2E2E] text-white"
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
? "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="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-bold">{type.label}</h3>
<p
className={`mt-1 text-sm ${
selected ? "text-white/80" : "text-gray-500"
}`}
>
{type.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="mb-3">
<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"
}`}
>
{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>
)}
</button>
);
})}
</div>
</div>
{/* Grid Type Selection */}
{/* Grid Type Selection - Visual Tiles */}
<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.
</p>
<div className="grid gap-3">
<div className="grid grid-cols-3 gap-3">
{gridTypes.map((type) => {
const selected = gridType === type.value;
@@ -115,30 +186,29 @@ export function StepProduct() {
<button
key={type.value}
type="button"
onClick={() => handleGridTypeSelect(type.value)}
className={`group relative rounded-xl border-2 p-4 text-left transition-all ${
onClick={() => setGridType(type.value)}
className={`group relative flex flex-col items-center rounded-xl border-2 px-2 py-4 transition-all ${
selected
? "border-[#C4D668] bg-[#1A2E2E] text-white"
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
? "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="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-bold">{type.label}</h3>
<p
className={`mt-1 text-sm ${
selected ? "text-white/80" : "text-gray-500"
}`}
>
{type.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="mb-3 flex items-center justify-center">
<GridIllustration dividers={type.dividers} 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"
}`}
>
{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>
)}
</button>
);
})}
@@ -147,7 +217,7 @@ export function StepProduct() {
{/* Continue Button */}
<button
onClick={handleContinue}
onClick={() => nextStep()}
className="w-full rounded-xl bg-[#C4D668] py-3 font-bold text-[#1A2E2E] transition-all hover:bg-[#b5c75a]"
>
Volgende stap

16
ecosystem.config.js Normal file
View File

@@ -0,0 +1,16 @@
module.exports = {
apps: [{
name: 'proinn-configurator',
script: 'node_modules/next/dist/bin/next',
args: 'start -p 3002',
cwd: '/home/anisy/projects/stalendeuren',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production',
PORT: 3002
}
}]
};

361
lib/door-models.ts Normal file
View File

@@ -0,0 +1,361 @@
/**
* Door Manufacturing Specifications
* Based on "Metalworks" market analysis
* All dimensions in millimeters (mm)
*/
// ============================================
// MANUFACTURING CONSTANTS
// ============================================
/**
* Steel Profile Dimensions (40x40mm Square Tube)
* Standard industrial steel door profile
*/
export const PROFILE_WIDTH = 40; // mm - Face width
export const PROFILE_DEPTH = 40; // mm - Tube depth
export const PROFILE_CORNER_RADIUS = 2; // mm - Rounded corners for welding
/**
* Steel Profile Named Exports (aliases for pricing/manufacturing clarity)
*/
export const STILE_WIDTH = 40; // mm - Vertical profiles (same as PROFILE_WIDTH)
export const RAIL_WIDTH = 20; // mm - Horizontal slim-line profiles
/**
* Glass Specifications - Standard 33.1 laminated safety glass (VSG 33.1)
*/
export const GLASS_THICKNESS = 7; // mm - Standard 33.1 Safety Glass
export const GLASS_OFFSET = 15; // mm - Center glass in 40mm profile: (40-7)/2 - 1.5mm clearance
/**
* Rail Height Variations
*/
export const RAIL_HEIGHT_SLIM = 20; // mm - Slim horizontal rails
export const RAIL_HEIGHT_ROBUST = 40; // mm - Standard robust rails (same as profile)
/**
* Taats (Pivot) Door Mechanism
*/
export const TAATS_PIVOT_OFFSET = 60; // mm - Pivot axis offset from wall for Taats doors
/**
* Wall Mounting Dimensions (Sparingsmaat / Deurmaat)
* Dutch building standard: Sparingsmaat = rough wall opening
*/
export const STELRUIMTE = 10; // mm - Total tolerance between wall and frame (5mm per side)
export const HANGNAAD = 3; // mm - Gap between frame and door leaf per side
export const WALL_THICKNESS = 150; // mm - Standard interior wall thickness
/**
* Calculate mounting dimensions from Sparingsmaat (wall opening).
*
* Sparingsmaat (input) -> Frame -> Door Leaf
* Frame = Sparingsmaat - STELRUIMTE (10mm tolerance)
* DoorLeaf = Frame - 2*PROFILE_WIDTH - 2*HANGNAAD (6mm gap)
*/
export function calculateMountingDimensions(sparingsmaatWidth: number, sparingsmaatHeight: number) {
const frameOuterWidth = sparingsmaatWidth - STELRUIMTE;
const frameOuterHeight = sparingsmaatHeight - STELRUIMTE / 2; // 5mm top tolerance only
const doorLeafWidth = frameOuterWidth - (2 * HANGNAAD);
const doorLeafHeight = frameOuterHeight - (2 * HANGNAAD);
return {
sparingsmaatWidth,
sparingsmaatHeight,
frameOuterWidth,
frameOuterHeight,
doorLeafWidth,
doorLeafHeight,
stelruimtePerSide: STELRUIMTE / 2, // 5mm gap visible on each side
hangnaadPerSide: HANGNAAD, // 3mm gap between frame and leaf
};
}
// ============================================
// PHYSICAL PART TYPES
// ============================================
export type PartType = 'stile' | 'rail' | 'glass' | 'divider';
export type DoorModel = 'taats' | 'scharnier' | 'paneel';
export type GridLayout = '3-vlak' | '4-vlak' | 'geen';
/**
* Physical Door Component
* Represents an actual steel part that will be manufactured
*/
export interface PhysicalPart {
type: PartType;
// Position in 3D space (in mm, relative to door center)
x: number;
y: number;
z: number;
// Dimensions in mm
width: number;
height: number;
depth: number;
// Metadata
label?: string;
isGlass?: boolean;
}
/**
* Complete Door Assembly
*/
export interface DoorAssembly {
modelId: DoorModel;
gridLayout: GridLayout;
doorWidth: number; // mm - Actual door leaf width
doorHeight: number; // mm - Door height
parts: PhysicalPart[];
}
// ============================================
// LAYOUT GENERATION
// ============================================
/**
* Generate physical parts list for door manufacturing
*
* @param modelId - Door model (taats, scharnier, paneel)
* @param gridLayout - Grid division (3-vlak, 4-vlak, geen)
* @param doorWidth - Door leaf width in mm
* @param doorHeight - Door height in mm
* @returns Complete assembly with all physical parts
*/
export function generateDoorAssembly(
modelId: DoorModel,
gridLayout: GridLayout,
doorWidth: number,
doorHeight: number
): DoorAssembly {
const parts: PhysicalPart[] = [];
// ============================================
// PERIMETER FRAME (All door types)
// ============================================
// LEFT STILE (Vertical)
parts.push({
type: 'stile',
x: -doorWidth / 2 + PROFILE_WIDTH / 2,
y: 0,
z: 0,
width: PROFILE_WIDTH,
height: doorHeight,
depth: PROFILE_DEPTH,
label: 'Left Stile',
});
// RIGHT STILE (Vertical)
parts.push({
type: 'stile',
x: doorWidth / 2 - PROFILE_WIDTH / 2,
y: 0,
z: 0,
width: PROFILE_WIDTH,
height: doorHeight,
depth: PROFILE_DEPTH,
label: 'Right Stile',
});
// TOP RAIL (Horizontal)
const topRailWidth = doorWidth - PROFILE_WIDTH * 2;
parts.push({
type: 'rail',
x: 0,
y: doorHeight / 2 - RAIL_HEIGHT_ROBUST / 2,
z: 0,
width: topRailWidth,
height: RAIL_HEIGHT_ROBUST,
depth: PROFILE_DEPTH,
label: 'Top Rail',
});
// BOTTOM RAIL (Horizontal)
parts.push({
type: 'rail',
x: 0,
y: -doorHeight / 2 + RAIL_HEIGHT_ROBUST / 2,
z: 0,
width: topRailWidth,
height: RAIL_HEIGHT_ROBUST,
depth: PROFILE_DEPTH,
label: 'Bottom Rail',
});
// ============================================
// GRID DIVIDERS (Based on layout)
// ============================================
if (gridLayout === '3-vlak') {
// Two horizontal dividers at 1/3 and 2/3 height
const divider1Y = doorHeight / 2 - doorHeight / 3;
const divider2Y = doorHeight / 2 - (2 * doorHeight) / 3;
parts.push({
type: 'divider',
x: 0,
y: divider1Y,
z: 0,
width: topRailWidth,
height: RAIL_HEIGHT_SLIM,
depth: PROFILE_DEPTH,
label: 'Divider 1/3',
});
parts.push({
type: 'divider',
x: 0,
y: divider2Y,
z: 0,
width: topRailWidth,
height: RAIL_HEIGHT_SLIM,
depth: PROFILE_DEPTH,
label: 'Divider 2/3',
});
} else if (gridLayout === '4-vlak') {
// Three horizontal dividers at 1/4, 1/2, 3/4 height
const divider1Y = doorHeight / 2 - doorHeight / 4;
const divider2Y = 0;
const divider3Y = doorHeight / 2 - (3 * doorHeight) / 4;
parts.push({
type: 'divider',
x: 0,
y: divider1Y,
z: 0,
width: topRailWidth,
height: RAIL_HEIGHT_SLIM,
depth: PROFILE_DEPTH,
label: 'Divider 1/4',
});
parts.push({
type: 'divider',
x: 0,
y: divider2Y,
z: 0,
width: topRailWidth,
height: RAIL_HEIGHT_SLIM,
depth: PROFILE_DEPTH,
label: 'Divider 1/2',
});
parts.push({
type: 'divider',
x: 0,
y: divider3Y,
z: 0,
width: topRailWidth,
height: RAIL_HEIGHT_SLIM,
depth: PROFILE_DEPTH,
label: 'Divider 3/4',
});
}
// ============================================
// VERTICAL CENTER DIVIDER (Paneel type only)
// ============================================
if (modelId === 'paneel') {
const verticalDividerHeight = doorHeight - RAIL_HEIGHT_ROBUST * 2;
parts.push({
type: 'divider',
x: 0,
y: 0,
z: 0,
width: PROFILE_WIDTH,
height: verticalDividerHeight,
depth: PROFILE_DEPTH,
label: 'Center Vertical Divider',
});
}
// ============================================
// GLASS PANELS
// ============================================
// Calculate glass dimensions (inside frame with offset)
const glassWidth = doorWidth - PROFILE_WIDTH * 2 - GLASS_OFFSET * 2;
const glassHeight = doorHeight - RAIL_HEIGHT_ROBUST * 2 - GLASS_OFFSET * 2;
parts.push({
type: 'glass',
x: 0,
y: 0,
z: 0,
width: glassWidth,
height: glassHeight,
depth: GLASS_THICKNESS,
label: 'Main Glass Panel',
isGlass: true,
});
return {
modelId,
gridLayout,
doorWidth,
doorHeight,
parts,
};
}
/**
* Convert mm to meters for Three.js 3D scene
*/
export function mmToMeters(mm: number): number {
return mm / 1000;
}
/**
* Get divider positions in meters (for backward compatibility)
*/
export function getDividerPositions(
gridLayout: GridLayout,
doorHeight: number
): number[] {
const doorHeightMeters = mmToMeters(doorHeight);
if (gridLayout === '3-vlak') {
return [-doorHeightMeters / 3, doorHeightMeters / 3];
} else if (gridLayout === '4-vlak') {
return [-doorHeightMeters / 2, 0, doorHeightMeters / 2];
}
return [];
}
/**
* Validation: Check if door dimensions are manufacturable
*/
export function validateDoorDimensions(
doorWidth: number,
doorHeight: number
): { valid: boolean; errors: string[] } {
const errors: string[] = [];
// Minimum dimensions check
if (doorWidth < PROFILE_WIDTH * 3) {
errors.push(`Door width too small (min: ${PROFILE_WIDTH * 3}mm)`);
}
if (doorHeight < RAIL_HEIGHT_ROBUST * 3) {
errors.push(`Door height too small (min: ${RAIL_HEIGHT_ROBUST * 3}mm)`);
}
// Maximum dimensions check (based on steel profile strength)
if (doorWidth > 1200) {
errors.push('Door width exceeds maximum (1200mm) - structural integrity');
}
if (doorHeight > 3000) {
errors.push('Door height exceeds maximum (3000mm) - structural integrity');
}
return {
valid: errors.length === 0,
errors,
};
}

198
lib/glass-patterns.ts Normal file
View File

@@ -0,0 +1,198 @@
/**
* Glass Pattern Generators
* Creates custom THREE.Shape objects for decorative glass panels
* Based on reference drawings: dt9 (rounded corners), dt10 (U-shapes)
*/
import * as THREE from "three";
export type GlassPattern = "standard" | "dt9-rounded" | "dt10-ushape";
/**
* Standard rectangular glass panel
*/
export function createStandardGlass(width: number, height: number): THREE.Shape {
const shape = new THREE.Shape();
const hw = width / 2;
const hh = height / 2;
shape.moveTo(-hw, -hh);
shape.lineTo(hw, -hh);
shape.lineTo(hw, hh);
shape.lineTo(-hw, hh);
shape.lineTo(-hw, -hh);
return shape;
}
/**
* DT9: Rounded corners glass panel
* Creates elegant rounded corners on glass sections
*/
export function createRoundedCornerGlass(
width: number,
height: number,
cornerRadius: number = 0.08
): THREE.Shape {
const shape = new THREE.Shape();
const hw = width / 2;
const hh = height / 2;
const r = Math.min(cornerRadius, width / 4, height / 4);
// Start from bottom-left corner (after radius)
shape.moveTo(-hw + r, -hh);
// Bottom edge
shape.lineTo(hw - r, -hh);
// Bottom-right rounded corner
shape.quadraticCurveTo(hw, -hh, hw, -hh + r);
// Right edge
shape.lineTo(hw, hh - r);
// Top-right rounded corner
shape.quadraticCurveTo(hw, hh, hw - r, hh);
// Top edge
shape.lineTo(-hw + r, hh);
// Top-left rounded corner
shape.quadraticCurveTo(-hw, hh, -hw, hh - r);
// Left edge
shape.lineTo(-hw, -hh + r);
// Bottom-left rounded corner
shape.quadraticCurveTo(-hw, -hh, -hw + r, -hh);
return shape;
}
/**
* DT10: U-shaped glass panel (top section - inverted U)
* Creates an upside-down U shape for the upper glass section
*/
export function createInvertedUGlass(width: number, height: number): THREE.Shape {
const shape = new THREE.Shape();
const hw = width / 2;
const hh = height / 2;
const uRadius = hw * 0.85; // U curve radius
// Start at top-left
shape.moveTo(-hw, hh);
// Top edge
shape.lineTo(hw, hh);
// Right edge down
shape.lineTo(hw, -hh + uRadius);
// Bottom U curve (inverted)
shape.absarc(0, -hh + uRadius, uRadius, 0, Math.PI, false);
// Left edge up
shape.lineTo(-hw, hh);
return shape;
}
/**
* DT10: U-shaped glass panel (bottom section - normal U)
* Creates a normal U shape for the lower glass section
*/
export function createNormalUGlass(width: number, height: number): THREE.Shape {
const shape = new THREE.Shape();
const hw = width / 2;
const hh = height / 2;
const uRadius = hw * 0.85; // U curve radius
// Start at bottom-left
shape.moveTo(-hw, -hh);
// Bottom edge
shape.lineTo(hw, -hh);
// Right edge up
shape.lineTo(hw, hh - uRadius);
// Top U curve
shape.absarc(0, hh - uRadius, uRadius, 0, Math.PI, true);
// Left edge down
shape.lineTo(-hw, -hh);
return shape;
}
/**
* DT9 Asymmetric: Create multiple panels with different rounded corners
* For complex DT9 layouts with side panels
*/
export function createDT9Panels(
mainWidth: number,
mainHeight: number,
sideWidth: number,
position: "top-right" | "bottom-left" | "top-left" | "bottom-right"
): {
mainPanel: THREE.Shape;
roundedPanel: THREE.Shape;
dividerHeight: number;
} {
const cornerRadius = 0.12;
// Main large panel with one rounded corner
const mainPanel = new THREE.Shape();
const mw = mainWidth / 2;
const mh = mainHeight / 2;
if (position === "top-right") {
// Main panel with rounded top-right corner
mainPanel.moveTo(-mw, -mh);
mainPanel.lineTo(mw - cornerRadius, -mh);
mainPanel.quadraticCurveTo(mw, -mh, mw, -mh + cornerRadius);
mainPanel.lineTo(mw, mh - cornerRadius);
mainPanel.quadraticCurveTo(mw, mh, mw - cornerRadius, mh);
mainPanel.lineTo(-mw, mh);
mainPanel.lineTo(-mw, -mh);
} else if (position === "bottom-left") {
// Main panel with rounded bottom-left corner
mainPanel.moveTo(-mw + cornerRadius, -mh);
mainPanel.lineTo(mw, -mh);
mainPanel.lineTo(mw, mh);
mainPanel.lineTo(-mw + cornerRadius, mh);
mainPanel.quadraticCurveTo(-mw, mh, -mw, mh - cornerRadius);
mainPanel.lineTo(-mw, -mh + cornerRadius);
mainPanel.quadraticCurveTo(-mw, -mh, -mw + cornerRadius, -mh);
}
// Small rounded panel
const roundedPanel = createRoundedCornerGlass(sideWidth, mainHeight / 3, cornerRadius * 0.6);
return {
mainPanel,
roundedPanel,
dividerHeight: mainHeight / 3,
};
}
/**
* Pattern metadata for UI selection
*/
export const glassPatternOptions = [
{
value: "standard" as GlassPattern,
label: "Standaard",
description: "Rechthoekige glaspanelen",
},
{
value: "dt9-rounded" as GlassPattern,
label: "DT9 - Afgeronde hoeken",
description: "Elegante ronde hoeken",
},
{
value: "dt10-ushape" as GlassPattern,
label: "DT10 - U-vormen",
description: "Decoratieve U-vormige panelen",
},
] as const;

126
lib/pricing.ts Normal file
View File

@@ -0,0 +1,126 @@
/**
* Pricing Engine for Proinn Configurator
* Based on Dutch market standard pricing (Metalworks/Aluwdoors reference)
*/
import {
PROFILE_WIDTH,
RAIL_HEIGHT_SLIM,
RAIL_HEIGHT_ROBUST,
type GridLayout,
type DoorModel,
} from './door-models';
// Pricing constants (EUR)
const STEEL_PRICE_PER_METER = 45;
const GLASS_PRICE_PER_SQM = 140;
const BASE_FEE = 650;
const SIDE_PANEL_SURCHARGE = 250;
const DOUBLE_DOOR_SURCHARGE = 350;
const TAATS_MECHANISM_SURCHARGE = 450;
const HANDLE_PRICES: Record<string, number> = {
'beugelgreep': 85,
'hoekgreep': 75,
'maangreep': 95,
'ovaalgreep': 90,
'klink': 65,
'u-greep': 55,
'geen': 0,
};
function calculateSteelLength(
doorWidth: number,
doorHeight: number,
gridLayout: GridLayout,
hasVerticalDivider: boolean
): number {
const innerWidth = doorWidth - PROFILE_WIDTH * 2;
let totalLength = doorHeight * 2 + innerWidth * 2;
if (gridLayout === '3-vlak') {
totalLength += innerWidth * 2;
} else if (gridLayout === '4-vlak') {
totalLength += innerWidth * 3;
}
if (hasVerticalDivider) {
totalLength += doorHeight - RAIL_HEIGHT_ROBUST * 2;
}
return totalLength / 1000;
}
function calculateGlassArea(
doorWidth: number,
doorHeight: number,
gridLayout: GridLayout
): number {
const glassWidth = doorWidth - PROFILE_WIDTH * 2;
const glassHeight = doorHeight - RAIL_HEIGHT_ROBUST * 2;
let dividerArea = 0;
if (gridLayout === '3-vlak') {
dividerArea = glassWidth * RAIL_HEIGHT_SLIM * 2;
} else if (gridLayout === '4-vlak') {
dividerArea = glassWidth * RAIL_HEIGHT_SLIM * 3;
}
return (glassWidth * glassHeight - dividerArea) / 1_000_000;
}
export interface PriceBreakdown {
steelCost: number;
glassCost: number;
baseFee: number;
mechanismSurcharge: number;
sidePanelSurcharge: number;
handleCost: number;
totalPrice: number;
steelLengthM: number;
glassAreaSqm: number;
}
export function calculatePrice(
doorWidth: number,
doorHeight: number,
doorType: DoorModel,
gridLayout: GridLayout,
doorConfig: 'enkele' | 'dubbele',
sidePanel: 'geen' | 'links' | 'rechts' | 'beide',
handle: string
): PriceBreakdown {
const leafCount = doorConfig === 'dubbele' ? 2 : 1;
const hasVerticalDivider = doorType === 'paneel';
const steelLengthPerLeaf = calculateSteelLength(doorWidth, doorHeight, gridLayout, hasVerticalDivider);
const glassAreaPerLeaf = calculateGlassArea(doorWidth, doorHeight, gridLayout);
const totalSteelLength = steelLengthPerLeaf * leafCount;
const totalGlassArea = glassAreaPerLeaf * leafCount;
const sidePanelCount = sidePanel === 'beide' ? 2 : (sidePanel === 'geen' ? 0 : 1);
const steelCost = Math.round(totalSteelLength * STEEL_PRICE_PER_METER);
const glassCost = Math.round(totalGlassArea * GLASS_PRICE_PER_SQM);
let mechanismSurcharge = 0;
if (doorType === 'taats') mechanismSurcharge += TAATS_MECHANISM_SURCHARGE;
if (doorConfig === 'dubbele') mechanismSurcharge += DOUBLE_DOOR_SURCHARGE;
const handleCost = HANDLE_PRICES[handle] || 0;
const sidePanelSurchrg = sidePanelCount * SIDE_PANEL_SURCHARGE;
const totalPrice = steelCost + glassCost + BASE_FEE + mechanismSurcharge + sidePanelSurchrg + handleCost;
return {
steelCost,
glassCost,
baseFee: BASE_FEE,
mechanismSurcharge,
sidePanelSurcharge: sidePanelSurchrg,
handleCost,
totalPrice,
steelLengthM: Math.round(totalSteelLength * 100) / 100,
glassAreaSqm: Math.round(totalGlassArea * 100) / 100,
};
}

View File

@@ -8,11 +8,13 @@ import {
type DoorConfig,
type SidePanel,
} from './calculations';
import type { GlassPattern } from './glass-patterns';
import { calculatePrice, type PriceBreakdown } from './pricing';
export type DoorType = 'taats' | 'scharnier' | 'paneel';
export type GridType = '3-vlak' | '4-vlak' | 'geen';
export type Finish = 'zwart' | 'brons' | 'grijs';
export type Handle = 'u-greep' | 'klink' | 'geen';
export type Handle = 'beugelgreep' | 'hoekgreep' | 'maangreep' | 'ovaalgreep' | 'klink' | 'u-greep' | 'geen';
interface ConfiguratorState {
// Door configuration
@@ -24,6 +26,7 @@ interface ConfiguratorState {
gridType: GridType;
finish: Finish;
handle: Handle;
glassPattern: GlassPattern;
// Dimensions (in mm)
width: number;
@@ -36,6 +39,9 @@ interface ConfiguratorState {
minWidth: number;
maxWidth: number;
// Pricing
priceBreakdown: PriceBreakdown;
// Actions
setDoorType: (type: DoorType) => void;
setDoorConfig: (config: DoorConfig) => void;
@@ -43,6 +49,7 @@ interface ConfiguratorState {
setGridType: (type: GridType) => void;
setFinish: (finish: Finish) => void;
setHandle: (handle: Handle) => void;
setGlassPattern: (pattern: GlassPattern) => void;
setWidth: (width: number) => void;
setHeight: (height: number) => void;
setDimensions: (width: number, height: number) => void;
@@ -61,6 +68,13 @@ const recalculate = (get: () => ConfiguratorState, set: (state: Partial<Configur
});
};
// Helper function for price recalculation
const recalculatePrice = (get: () => ConfiguratorState, set: (state: Partial<ConfiguratorState>) => void) => {
const { doorLeafWidth, height, doorType, gridType, doorConfig, sidePanel, handle } = get();
const priceBreakdown = calculatePrice(doorLeafWidth, height, doorType, gridType, doorConfig, sidePanel, handle);
set({ priceBreakdown });
};
export const useConfiguratorStore = create<ConfiguratorState>((set, get) => ({
// Initial state
doorType: 'taats',
@@ -68,7 +82,8 @@ export const useConfiguratorStore = create<ConfiguratorState>((set, get) => ({
sidePanel: 'geen',
gridType: '3-vlak',
finish: 'zwart',
handle: 'u-greep',
handle: 'beugelgreep',
glassPattern: 'standard',
width: 1000,
height: 2400,
@@ -79,43 +94,57 @@ export const useConfiguratorStore = create<ConfiguratorState>((set, get) => ({
minWidth: 860,
maxWidth: 1360,
// Initial price (computed with defaults: taats, 3-vlak, enkele, geen, beugelgreep, 1000x2400)
priceBreakdown: calculatePrice(1000, 2400, 'taats', '3-vlak', 'enkele', 'geen', 'beugelgreep'),
// Actions with automatic recalculation
setDoorType: (doorType) => {
set({ doorType });
recalculate(get, set);
recalculatePrice(get, set);
},
setDoorConfig: (doorConfig) => {
set({ doorConfig });
recalculate(get, set);
recalculatePrice(get, set);
},
setSidePanel: (sidePanel) => {
set({ sidePanel });
recalculate(get, set);
recalculatePrice(get, set);
},
setGridType: (gridType) => set({ gridType }),
setGridType: (gridType) => {
set({ gridType });
recalculatePrice(get, set);
},
setFinish: (finish) => set({ finish }),
setHandle: (handle) => set({ handle }),
setHandle: (handle) => {
set({ handle });
recalculatePrice(get, set);
},
setGlassPattern: (glassPattern) => set({ glassPattern }),
setWidth: (width) => {
const { doorConfig, sidePanel } = get();
const minWidth = calculateHoleMinWidth(doorConfig, sidePanel);
const maxWidth = calculateHoleMaxWidth(doorConfig, sidePanel);
// Clamp width to valid range
const clampedWidth = Math.max(minWidth, Math.min(maxWidth, width));
set({ width: clampedWidth });
recalculate(get, set);
recalculatePrice(get, set);
},
setHeight: (height) => {
// Clamp height to valid range
const clampedHeight = Math.max(1800, Math.min(3000, height));
set({ height: clampedHeight });
recalculatePrice(get, set);
},
setDimensions: (width, height) => {

52
monitor-dns-and-ssl.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Auto-monitor DNS propagation and apply SSL
# Checks every 60 seconds until DNS is correct, then applies SSL
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
DOMAIN="proinn.youztech.nl"
TARGET_IP="141.95.17.59"
MAX_ATTEMPTS=30 # 30 minutes max
echo -e "${BLUE}═══════════════════════════════════════════${NC}"
echo -e "${BLUE} DNS Propagatie Monitor + Auto SSL${NC}"
echo -e "${BLUE}═══════════════════════════════════════════${NC}"
echo ""
echo "Monitoring: $DOMAIN"
echo "Target IP: $TARGET_IP"
echo "Checking every 60 seconds..."
echo ""
attempt=1
while [ $attempt -le $MAX_ATTEMPTS ]; do
echo -e "${YELLOW}[Attempt $attempt/$MAX_ATTEMPTS]${NC} Checking DNS..."
CURRENT_IP=$(host $DOMAIN 2>/dev/null | grep "has address" | awk '{print $4}')
if [ "$CURRENT_IP" == "$TARGET_IP" ]; then
echo -e "${GREEN}✓ DNS is correct!${NC}"
echo ""
echo -e "${GREEN}Starting SSL setup...${NC}"
# Run SSL setup
cd /home/anisy/projects/stalendeuren
./setup-ssl.sh
exit 0
else
echo "Current IP: $CURRENT_IP (waiting for $TARGET_IP)"
echo "Waiting 60 seconds..."
sleep 60
fi
attempt=$((attempt + 1))
done
echo -e "${YELLOW}DNS propagation took longer than expected.${NC}"
echo "Please check your DNS settings and run ./setup-ssl.sh manually."
exit 1

124
setup-ssl.sh Executable file
View File

@@ -0,0 +1,124 @@
#!/bin/bash
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
DOMAIN="proinn.youztech.nl"
TARGET_IP="141.95.17.59"
echo -e "${YELLOW}Checking DNS for $DOMAIN...${NC}"
# Check current DNS
CURRENT_IP=$(host $DOMAIN | grep "has address" | awk '{print $4}')
if [ "$CURRENT_IP" != "$TARGET_IP" ]; then
echo -e "${RED}DNS not updated yet!${NC}"
echo "Current IP: $CURRENT_IP"
echo "Target IP: $TARGET_IP"
echo ""
echo "Please update DNS first, then run this script again."
exit 1
fi
echo -e "${GREEN}✓ DNS is correct!${NC}"
echo ""
echo -e "${YELLOW}Requesting SSL certificate...${NC}"
# Request SSL certificate
sudo certbot certonly --webroot -w /var/www/html -d $DOMAIN \
--non-interactive --agree-tos --email admin@youztech.nl
if [ $? -ne 0 ]; then
echo -e "${RED}SSL certificate request failed!${NC}"
exit 1
fi
echo -e "${GREEN}✓ SSL certificate obtained!${NC}"
echo ""
echo -e "${YELLOW}Updating nginx configuration for HTTPS...${NC}"
# Update nginx config with SSL
sudo tee /etc/nginx/sites-available/$DOMAIN > /dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name proinn.youztech.nl;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name proinn.youztech.nl;
ssl_certificate /etc/letsencrypt/live/proinn.youztech.nl/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/proinn.youztech.nl/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# Optimize for Next.js static assets
location /_next/static {
proxy_pass http://127.0.0.1:3002;
proxy_cache_valid 60m;
add_header Cache-Control "public, immutable, max-age=31536000";
}
# Optimize for images
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ {
proxy_pass http://127.0.0.1:3002;
expires 30d;
add_header Cache-Control "public, immutable";
}
}
EOF
# Test nginx config
sudo nginx -t
if [ $? -ne 0 ]; then
echo -e "${RED}Nginx configuration test failed!${NC}"
exit 1
fi
# Reload nginx
sudo systemctl reload nginx
echo -e "${GREEN}✓ Nginx reloaded with HTTPS!${NC}"
echo ""
echo -e "${GREEN}═══════════════════════════════════════════${NC}"
echo -e "${GREEN} 🎉 DEPLOYMENT COMPLETE! 🎉${NC}"
echo -e "${GREEN}═══════════════════════════════════════════${NC}"
echo ""
echo -e "Your site is now live at:"
echo -e "${GREEN}https://proinn.youztech.nl/offerte${NC}"
echo ""
echo "SSL certificate will auto-renew via certbot."

69
update-dns-ovh.sh Executable file
View File

@@ -0,0 +1,69 @@
#!/bin/bash
# OVH DNS Update Script
# Updates proinn.youztech.nl to point to this server
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m'
DOMAIN="youztech.nl"
SUBDOMAIN="proinn"
TARGET_IP="141.95.17.59"
echo -e "${BLUE}═══════════════════════════════════════════${NC}"
echo -e "${BLUE} OVH DNS Update voor proinn.youztech.nl${NC}"
echo -e "${BLUE}═══════════════════════════════════════════${NC}"
echo ""
# Check if OVH credentials are set
if [ -z "$OVH_APPLICATION_KEY" ] || [ -z "$OVH_APPLICATION_SECRET" ] || [ -z "$OVH_CONSUMER_KEY" ]; then
echo -e "${YELLOW}OVH API credentials niet gevonden!${NC}"
echo ""
echo "Je hebt 2 opties:"
echo ""
echo -e "${GREEN}OPTIE 1: Handmatig via OVH Manager (2 minuten)${NC}"
echo " 1. Ga naar: https://www.ovh.com/manager/web/"
echo " 2. Klik: Domain names → youztech.nl → DNS zone"
echo " 3. Zoek A record voor 'proinn'"
echo " 4. Wijzig IP van 76.76.21.21 naar $TARGET_IP"
echo " 5. Save & wacht 5-30 minuten"
echo ""
echo -e "${GREEN}OPTIE 2: OVH API Setup (10 minuten)${NC}"
echo " 1. Ga naar: https://eu.api.ovh.com/createToken/"
echo " 2. Rechten: GET+POST+PUT voor /domain/zone/*"
echo " 3. Validity: Unlimited"
echo " 4. Kopieer de 3 keys en run:"
echo ""
echo " export OVH_APPLICATION_KEY='xxx'"
echo " export OVH_APPLICATION_SECRET='xxx'"
echo " export OVH_CONSUMER_KEY='xxx'"
echo " ./update-dns-ovh.sh"
echo ""
exit 1
fi
echo -e "${GREEN}✓ OVH credentials gevonden${NC}"
echo ""
echo -e "${YELLOW}Updating DNS record...${NC}"
# OVH API endpoint
API_ENDPOINT="https://eu.api.ovh.com/1.0"
# Get current timestamp
TIMESTAMP=$(date +%s)
# Create signature (simplified - in production use proper HMAC)
# For this to work, you need the full OVH API client
# This is a placeholder that shows the structure
echo -e "${RED}Note: Voor volledige API functionaliteit, installeer: npm install -g ovh${NC}"
echo ""
echo -e "${YELLOW}Alternatief: Gebruik OPTIE 1 (handmatig) hierboven${NC}"
echo ""
echo "De site draait al! Test via IP:"
echo -e "${GREEN}http://141.95.17.59/offerte${NC}"
exit 0