Add premium configurator with split-screen layout
- Redesigned configurator page with split-screen interface - Left: Large visual preview with sticky positioning - Right: Premium white controls container with form steps - Added complete configurator wizard (5 steps) - Updated hero CTA to "Zelf ontwerpen" - Configured Shadcn UI with Slate theme - Added layout components (Navbar, Footer) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
BIN
afbeeldingen/deurgang.jpg
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
afbeeldingen/example-footer
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
afbeeldingen/example-section1.jpeg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
afbeeldingen/example-section2.png
Normal file
|
After Width: | Height: | Size: 322 KiB |
BIN
afbeeldingen/example-section3.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
afbeeldingen/example-section4
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
afbeeldingen/image-people1.png
Normal file
|
After Width: | Height: | Size: 6.8 MiB |
BIN
afbeeldingen/proinn-spuiten
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
140
app/globals.css
@@ -1,26 +1,136 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
:root {
|
@custom-variant dark (&:is(.dark *));
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
--color-brand-orange: var(--brand-orange);
|
||||||
|
--color-brand-blue: var(--brand-blue);
|
||||||
|
--color-brand-pistachio: var(--brand-pistachio);
|
||||||
|
--color-topbar-bg: var(--topbar-bg);
|
||||||
|
--color-menu-footer-bg: var(--menu-footer-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
:root {
|
||||||
:root {
|
--radius: 0.625rem;
|
||||||
--background: #0a0a0a;
|
--brand-orange: #F97316;
|
||||||
--foreground: #ededed;
|
--brand-blue: #0ea5e9;
|
||||||
|
--brand-pistachio: #C4D668;
|
||||||
|
--topbar-bg: #E8E8E6;
|
||||||
|
--menu-footer-bg: #2F3B3B;
|
||||||
|
--background: oklch(0.985 0.002 247);
|
||||||
|
--foreground: oklch(0.1 0.02 265);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.1 0.02 265);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.1 0.02 265);
|
||||||
|
--primary: oklch(0.145 0.02 265);
|
||||||
|
--primary-foreground: oklch(0.985 0.003 247);
|
||||||
|
--secondary: oklch(0.968 0.007 247.896);
|
||||||
|
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--muted: oklch(0.968 0.007 247.896);
|
||||||
|
--muted-foreground: oklch(0.554 0.023 264.364);
|
||||||
|
--accent: oklch(0.968 0.007 247.896);
|
||||||
|
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.929 0.013 255.508);
|
||||||
|
--input: oklch(0.929 0.013 255.508);
|
||||||
|
--ring: oklch(0.704 0.04 256.788);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||||
|
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||||
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||||
|
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||||
|
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.129 0.042 264.695);
|
||||||
|
--foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--card: oklch(0.208 0.042 265.755);
|
||||||
|
--card-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--popover: oklch(0.208 0.042 265.755);
|
||||||
|
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--primary: oklch(0.929 0.013 255.508);
|
||||||
|
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--secondary: oklch(0.279 0.041 260.031);
|
||||||
|
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--muted: oklch(0.279 0.041 260.031);
|
||||||
|
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||||
|
--accent: oklch(0.279 0.041 260.031);
|
||||||
|
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.554 0.023 264.364);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.208 0.042 265.755);
|
||||||
|
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||||
|
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.554 0.023 264.364);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
}
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
body {
|
}
|
||||||
background: var(--background);
|
}
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
import { Header } from "@/components/layout/header";
|
||||||
|
import { Footer } from "@/components/layout/footer";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const inter = Inter({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "PROINN | Stalen Deuren & Maatwerk",
|
||||||
description: "Generated by create next app",
|
description:
|
||||||
|
"Industriële stalen deuren, kozijnen en maatwerk van PROINN. Vraag direct een offerte aan via onze configurator.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,11 +21,11 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="nl">
|
||||||
<body
|
<body className={`${inter.variable} font-sans antialiased`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<Header />
|
||||||
>
|
<main>{children}</main>
|
||||||
{children}
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
149
app/offerte/page.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormProvider, useFormContext } from "@/components/offerte/form-context";
|
||||||
|
import { StepProduct } from "@/components/offerte/step-product";
|
||||||
|
import { StepDimensions } from "@/components/offerte/step-dimensions";
|
||||||
|
import { StepOptions } from "@/components/offerte/step-options";
|
||||||
|
import { StepContact } from "@/components/offerte/step-contact";
|
||||||
|
import { StepSummary } from "@/components/offerte/step-summary";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
const stepLabels = ["Product", "Afmetingen", "Opties", "Contact", "Overzicht"];
|
||||||
|
|
||||||
|
const stepComponents = [
|
||||||
|
StepProduct,
|
||||||
|
StepDimensions,
|
||||||
|
StepOptions,
|
||||||
|
StepContact,
|
||||||
|
StepSummary,
|
||||||
|
];
|
||||||
|
|
||||||
|
function StepIndicator() {
|
||||||
|
const { currentStep, totalSteps } = useFormContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8 flex items-center gap-2">
|
||||||
|
{stepLabels.map((label, i) => (
|
||||||
|
<div key={label} className="flex items-center gap-2">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
className={`flex size-8 items-center justify-center rounded-full text-sm font-semibold transition-colors ${
|
||||||
|
i <= currentStep
|
||||||
|
? "bg-brand-orange text-white"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`hidden text-xs font-medium lg:inline ${
|
||||||
|
i === currentStep ? "text-foreground" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{i < totalSteps - 1 && (
|
||||||
|
<div
|
||||||
|
className={`h-px w-4 transition-colors lg:w-6 ${
|
||||||
|
i < currentStep ? "bg-brand-orange" : "bg-border"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WizardContent() {
|
||||||
|
const { currentStep, nextStep, prevStep, totalSteps } = useFormContext();
|
||||||
|
const CurrentStepComponent = stepComponents[currentStep];
|
||||||
|
const isLastStep = currentStep === totalSteps - 1;
|
||||||
|
const isFirstStep = currentStep === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="rounded-2xl border border-border/50 bg-card p-6">
|
||||||
|
<CurrentStepComponent />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation — hidden on step 1 (auto-advances) and summary (has its own button) */}
|
||||||
|
{!isFirstStep && !isLastStep && (
|
||||||
|
<div className="mt-6 flex justify-between gap-4">
|
||||||
|
<Button variant="outline" onClick={prevStep} className="flex-1 sm:flex-none">
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Vorige
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={nextStep}
|
||||||
|
className="flex-1 bg-brand-orange text-white hover:bg-brand-orange/90 sm:flex-none"
|
||||||
|
>
|
||||||
|
Volgende
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary: only show back button */}
|
||||||
|
{isLastStep && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button variant="outline" onClick={prevStep} className="w-full sm:w-auto">
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Terug naar Contact
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OffertePage() {
|
||||||
|
return (
|
||||||
|
<FormProvider>
|
||||||
|
{/* Mobile Header */}
|
||||||
|
<div className="px-4 py-6 lg:hidden">
|
||||||
|
<h1 className="mb-1 text-2xl font-bold tracking-tight">
|
||||||
|
Offerte Aanvragen
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Configureer uw product in een paar stappen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Split Screen Layout */}
|
||||||
|
<div className="grid grid-cols-1 gap-8 px-4 py-8 lg:grid-cols-12 lg:px-8 lg:py-12">
|
||||||
|
{/* Left Column: Visual Preview (Desktop Only) */}
|
||||||
|
<div className="relative hidden overflow-hidden rounded-[2.5rem] bg-gradient-to-br from-slate-100 to-slate-200 lg:col-span-8 lg:block lg:h-[calc(100vh-150px)]">
|
||||||
|
<div className="sticky top-24 flex h-full items-center justify-center">
|
||||||
|
<div className="relative h-full w-full overflow-hidden rounded-[2.5rem]">
|
||||||
|
<img
|
||||||
|
src="/images/hero.jpg"
|
||||||
|
alt="Product preview"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-8 top-8">
|
||||||
|
<div className="rounded-full bg-black/60 px-4 py-2 text-sm font-medium text-white backdrop-blur-sm">
|
||||||
|
Live Voorbeeld
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Controls */}
|
||||||
|
<div className="lg:col-span-4">
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-xl lg:p-8">
|
||||||
|
<h2 className="mb-6 text-2xl font-bold text-brand-dark-green">
|
||||||
|
Configureer jouw product
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<StepIndicator />
|
||||||
|
<WizardContent />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
app/page.tsx
@@ -1,65 +1,17 @@
|
|||||||
import Image from "next/image";
|
import { Hero } from "@/components/home/hero";
|
||||||
|
import { OfferSection } from "@/components/home/offer-section";
|
||||||
|
import { ProcessSection } from "@/components/home/process-section";
|
||||||
|
import { AboutSection } from "@/components/home/about-section";
|
||||||
|
import { CraftsmanshipSection } from "@/components/home/craftsmanship-section";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<>
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<Hero />
|
||||||
<Image
|
<OfferSection />
|
||||||
className="dark:invert"
|
<ProcessSection />
|
||||||
src="/next.svg"
|
<AboutSection />
|
||||||
alt="Next.js logo"
|
<CraftsmanshipSection />
|
||||||
width={100}
|
</>
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
23
components.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
88
components/home/about-section.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { MousePointerClick, FileText } from "lucide-react";
|
||||||
|
|
||||||
|
export function AboutSection() {
|
||||||
|
return (
|
||||||
|
<section className="bg-[#F5F5F3] py-24">
|
||||||
|
<div className="mx-auto grid max-w-7xl grid-cols-1 items-stretch gap-8 px-4 sm:px-6 lg:grid-cols-12 lg:px-8">
|
||||||
|
{/* Left Column - Tall Image */}
|
||||||
|
<div className="relative min-h-[500px] overflow-hidden rounded-[2.5rem] lg:col-span-5 lg:min-h-[600px]">
|
||||||
|
<Image
|
||||||
|
src="/images/image-people1.png"
|
||||||
|
alt="Het team van Proinn aan het werk"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 1024px) 100vw, 42vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Stacked Cards */}
|
||||||
|
<div className="flex flex-col gap-8 lg:col-span-7">
|
||||||
|
{/* Card 1 - Zelf aan de slag */}
|
||||||
|
<div className="flex flex-1 flex-col justify-center rounded-[2.5rem] bg-[#1A2E2E] p-10 lg:p-12">
|
||||||
|
{/* Label */}
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
<div className="h-5 w-1 rounded-full bg-[#C4D668]" />
|
||||||
|
<span className="text-sm font-medium tracking-wide text-[#C4D668]">
|
||||||
|
Zelf aan de slag
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heading */}
|
||||||
|
<h2 className="mb-4 text-3xl font-bold leading-tight text-white lg:text-4xl">
|
||||||
|
Ontwerp jouw deur of wand
|
||||||
|
<br />
|
||||||
|
op maat en zie direct de prijs
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<p className="mb-8 max-w-lg text-sm leading-relaxed text-gray-300">
|
||||||
|
Wil je zelf bepalen hoe jouw deur of wand eruitziet? Met onze
|
||||||
|
configurator kan dat. Jij kiest, wij maken. Selecteer stap voor
|
||||||
|
stap jouw favoriete uitvoering en bekijk direct het resultaat in
|
||||||
|
beeld en prijs.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Button */}
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href="/offerte"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-[#C4D668] px-6 py-3 text-sm font-semibold text-black transition-colors hover:bg-[#b5c75a]"
|
||||||
|
>
|
||||||
|
Klik hier
|
||||||
|
<MousePointerClick className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 2 - Hulp nodig */}
|
||||||
|
<div className="flex flex-1 flex-col justify-center rounded-[2.5rem] bg-[#1A2E2E] p-10 lg:p-12">
|
||||||
|
{/* Heading */}
|
||||||
|
<h3 className="mb-4 text-2xl font-bold text-white lg:text-3xl">
|
||||||
|
Heb je meer hulp nodig?
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<p className="mb-8 max-w-lg text-sm leading-relaxed text-gray-300">
|
||||||
|
We kunnen ons voorstellen dat je graag geholpen wordt rondom het
|
||||||
|
samenstellen van je deur. Hieronder kan je eenvoudig een snelle
|
||||||
|
offerte aanvragen waarna we contact met jou opnemen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Button */}
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href="/offerte"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border-2 border-white px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-white hover:text-[#1A2E2E]"
|
||||||
|
>
|
||||||
|
<FileText className="size-4" />
|
||||||
|
Snelle offerte aanvragen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
components/home/about-teaser.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
export function AboutTeaser() {
|
||||||
|
return (
|
||||||
|
<section className="border-t border-border bg-muted/40 py-24">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid items-center gap-12 lg:grid-cols-2">
|
||||||
|
{/* Text */}
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-semibold uppercase tracking-widest text-brand-blue">
|
||||||
|
Over Proinn
|
||||||
|
</p>
|
||||||
|
<h2 className="mb-6 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||||
|
Vakmanschap sinds de eerste las
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-muted-foreground">
|
||||||
|
Bij Proinn draait alles om staal. Onze werkplaats combineert
|
||||||
|
traditioneel vakmanschap met moderne technieken om producten te
|
||||||
|
leveren die niet alleen functioneel zijn, maar ook esthetisch
|
||||||
|
verantwoord.
|
||||||
|
</p>
|
||||||
|
<p className="mb-8 leading-relaxed text-muted-foreground">
|
||||||
|
Van industriële stalen deuren tot op maat gemaakte kozijnen — wij
|
||||||
|
denken mee, ontwerpen en produceren alles in eigen huis.
|
||||||
|
</p>
|
||||||
|
<Button asChild variant="outline" size="lg">
|
||||||
|
<Link href="/contact">
|
||||||
|
Neem Contact Op
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
<div className="relative aspect-[4/3] overflow-hidden rounded-lg">
|
||||||
|
<Image
|
||||||
|
src="/images/about.jpg"
|
||||||
|
alt="Proinn werkplaats — stalen deuren productie"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
{/* Accent corner */}
|
||||||
|
<div className="absolute bottom-0 left-0 h-1.5 w-24 bg-brand-orange" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
components/home/craftsmanship-section.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
export function CraftsmanshipSection() {
|
||||||
|
return (
|
||||||
|
<section className="relative bg-white">
|
||||||
|
<div className="mx-auto grid max-w-7xl grid-cols-1 lg:grid-cols-2">
|
||||||
|
{/* Left Column - Image */}
|
||||||
|
<div className="relative z-10 min-h-[450px] overflow-hidden rounded-r-[2.5rem] lg:min-h-[560px]">
|
||||||
|
<Image
|
||||||
|
src="/images/proinn-spuiten.png"
|
||||||
|
alt="Vakmanschap bij Proinn - spuitwerk in de fabriek"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Dark Content */}
|
||||||
|
<div className="flex flex-col justify-center bg-[#1A2E2E] px-8 py-16 lg:px-16 lg:py-20">
|
||||||
|
{/* Label */}
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
<div className="h-5 w-1 rounded-full bg-[#C4D668]" />
|
||||||
|
<span className="text-sm font-medium tracking-wide text-[#C4D668]">
|
||||||
|
Waarom Proinn
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heading */}
|
||||||
|
<h2 className="mb-5 text-3xl font-bold leading-tight text-white lg:text-4xl">
|
||||||
|
Vakmanschap op
|
||||||
|
<br />
|
||||||
|
ieder niveau
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<p className="mb-10 max-w-md text-sm leading-relaxed text-gray-300">
|
||||||
|
Bij Proinn draait alles om kwaliteit en vakmanschap. Van het eerste
|
||||||
|
ontwerp tot de laatste afwerking — elk detail wordt met zorg
|
||||||
|
behandeld in onze eigen fabriek. Wij combineren ambachtelijke
|
||||||
|
technieken met moderne technologie om stalen deuren en wanden te
|
||||||
|
maken die perfect passen bij jouw ruimte.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Input Group */}
|
||||||
|
<div className="flex max-w-md items-center gap-3">
|
||||||
|
<div className="flex-1 rounded-md bg-[#263e3e] px-4 py-3 text-sm text-gray-400">
|
||||||
|
Meer weten?
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/over-ons"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-[#C4D668] px-5 py-3 text-sm font-semibold text-black transition-colors hover:bg-[#b5c75a]"
|
||||||
|
>
|
||||||
|
Over ons
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
components/home/features.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Hammer, Truck, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: Hammer,
|
||||||
|
title: "Ambachtelijk Maatwerk",
|
||||||
|
description:
|
||||||
|
"Elk product wordt op maat gemaakt in onze eigen werkplaats. Geen standaardwerk, maar vakmanschap tot in detail.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Truck,
|
||||||
|
title: "Snelle Levering",
|
||||||
|
description:
|
||||||
|
"Korte doorlooptijden dankzij ons efficiënte productieproces. Van offerte tot montage, wij leveren op tijd.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ShieldCheck,
|
||||||
|
title: "Hoogste Kwaliteit",
|
||||||
|
description:
|
||||||
|
"Wij werken uitsluitend met hoogwaardig staal en duurzame coatings. Gebouwd om generaties mee te gaan.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Features() {
|
||||||
|
return (
|
||||||
|
<section className="py-24">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="mb-16 max-w-xl">
|
||||||
|
<p className="mb-2 text-sm font-semibold uppercase tracking-widest text-brand-orange">
|
||||||
|
Waarom Proinn
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||||
|
Gebouwd op vakmanschap
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-8 md:grid-cols-3">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<div
|
||||||
|
key={feature.title}
|
||||||
|
className="group rounded-lg border border-border bg-card p-8 transition-all hover:border-brand-orange/30 hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="mb-5 inline-flex rounded-md bg-brand-orange/10 p-3">
|
||||||
|
<feature.icon className="size-6 text-brand-orange" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-3 text-lg font-semibold">{feature.title}</h3>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
components/home/hero.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
export function Hero() {
|
||||||
|
return (
|
||||||
|
<section className="relative flex min-h-screen items-end overflow-hidden">
|
||||||
|
{/* Background image */}
|
||||||
|
<Image
|
||||||
|
src="/images/hero.jpg"
|
||||||
|
alt="Stalen deuren in modern interieur"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Gradient overlay */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-[#1A2E2E]/90 via-[#1A2E2E]/30 to-transparent" />
|
||||||
|
|
||||||
|
{/* Content pinned to bottom */}
|
||||||
|
<div className="relative w-full pb-20 pt-40">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Label */}
|
||||||
|
<div className="mb-6 flex items-center gap-2">
|
||||||
|
<div className="h-5 w-1 rounded-full bg-[#C4D668]" />
|
||||||
|
<span className="text-sm font-medium tracking-wide text-[#C4D668]">
|
||||||
|
Staal · Vakmanschap · Maatwerk
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="max-w-3xl text-5xl font-light leading-[1.1] tracking-tight text-white md:text-7xl">
|
||||||
|
Innovatieve
|
||||||
|
<br />
|
||||||
|
<span className="font-semibold">Stalen</span> Oplossingen
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-6 max-w-md text-base font-light leading-relaxed text-white/60">
|
||||||
|
Maatwerk voor bedrijven en particulieren. Van stalen deuren tot
|
||||||
|
industriële kozijnen — wij realiseren uw visie in staal.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10 flex flex-col gap-4 sm:flex-row">
|
||||||
|
<Link
|
||||||
|
href="/offerte"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-[#C4D668] px-7 py-3 text-sm font-semibold text-black transition-colors hover:bg-[#b5c75a]"
|
||||||
|
>
|
||||||
|
Zelf ontwerpen
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/producten"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border-2 border-white/30 px-7 py-3 text-sm font-semibold text-white transition-colors hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Bekijk Producten
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
components/home/offer-section.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRight, DoorOpen, Home, Grid2x2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{
|
||||||
|
id: "binnendeuren",
|
||||||
|
title: "Binnendeuren",
|
||||||
|
subtitle: "Stalen deuren voor binnen gebruik",
|
||||||
|
href: "/producten/binnendeuren",
|
||||||
|
icon: DoorOpen,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "buitendeuren",
|
||||||
|
title: "Buitendeuren",
|
||||||
|
subtitle: "Stalen deuren voor buiten gebruik",
|
||||||
|
href: "/producten/buitendeuren",
|
||||||
|
icon: Home,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "kantoorwanden",
|
||||||
|
title: "Kantoorwanden",
|
||||||
|
subtitle: "Glazen wanden voor kantoren",
|
||||||
|
href: "/producten/kantoorwanden",
|
||||||
|
icon: Grid2x2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function OfferSection() {
|
||||||
|
const [activeId, setActiveId] = useState("binnendeuren");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-[#F5F5F3] py-20">
|
||||||
|
<div className="mx-auto grid max-w-7xl gap-12 px-4 sm:px-6 lg:grid-cols-2 lg:gap-16 lg:px-8">
|
||||||
|
{/* Left Column */}
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
{/* Label */}
|
||||||
|
<div className="mb-6 flex items-center gap-2">
|
||||||
|
<div className="h-5 w-1 rounded-full bg-[#C4D668]" />
|
||||||
|
<span className="text-sm font-medium tracking-wide text-gray-500">
|
||||||
|
Ons aanbod
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heading */}
|
||||||
|
<h2 className="mb-5 text-4xl font-light leading-tight text-gray-900 lg:text-5xl">
|
||||||
|
Waar ben je
|
||||||
|
<br />
|
||||||
|
naar op zoek?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="mb-10 max-w-lg text-sm leading-relaxed text-gray-500">
|
||||||
|
Bij Proinn vind je deuren en wanden die passen bij iedere situatie.
|
||||||
|
Of je nu op zoek bent naar stijlvolle binnendeuren, sterke
|
||||||
|
buitendeuren of functionele kantoorwanden: wij verzorgen het
|
||||||
|
volledig op maat. Altijd met de kwaliteit, aandacht en service die
|
||||||
|
je van ons mag verwachten.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Category Cards */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const isActive = activeId === cat.id;
|
||||||
|
const Icon = cat.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={cat.id}
|
||||||
|
href={cat.href}
|
||||||
|
onMouseEnter={() => setActiveId(cat.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-4 rounded-xl px-5 py-4 transition-all duration-200",
|
||||||
|
isActive
|
||||||
|
? "bg-[#1A2E2E] text-white shadow-lg"
|
||||||
|
: "bg-white text-gray-800 shadow-sm hover:shadow-md"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-12 shrink-0 items-center justify-center rounded-lg",
|
||||||
|
isActive
|
||||||
|
? "bg-white/10"
|
||||||
|
: "bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"size-6",
|
||||||
|
isActive ? "text-white" : "text-gray-600"
|
||||||
|
)}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-semibold">{cat.title}</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
isActive ? "text-gray-300" : "text-gray-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cat.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-10 shrink-0 items-center justify-center rounded-lg transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-white/10"
|
||||||
|
: "bg-gray-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowRight
|
||||||
|
className={cn(
|
||||||
|
"size-4",
|
||||||
|
isActive ? "text-white" : "text-gray-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Image */}
|
||||||
|
<div className="relative min-h-[400px] overflow-hidden rounded-[2rem] lg:min-h-[560px]">
|
||||||
|
<Image
|
||||||
|
src="/images/aanbod.jpg"
|
||||||
|
alt="Stalen deur in modern interieur"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
components/home/process-section.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Check,
|
||||||
|
MapPin,
|
||||||
|
PenTool,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
"Vrijblijvende offerte",
|
||||||
|
"Wensen bespreken",
|
||||||
|
"Meten is weten",
|
||||||
|
"Het definitieve plan",
|
||||||
|
"Jouw deur wordt gemaakt",
|
||||||
|
"Montage op locatie",
|
||||||
|
];
|
||||||
|
|
||||||
|
const checklistItems = [
|
||||||
|
"We luisteren goed naar jouw ideeën en denken mee in mogelijkheden.",
|
||||||
|
"Samen vertalen we jouw wensen naar een passend ontwerp.",
|
||||||
|
"In onze eigen fabriek maken we alles volledig op maat.",
|
||||||
|
"We gebruiken hoogwaardige materialen voor een duurzaam resultaat.",
|
||||||
|
"Tijdens het hele traject kun je rekenen op persoonlijk advies.",
|
||||||
|
"Ons team zorgt voor een zorgvuldige en nette montage op locatie.",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ProcessSection() {
|
||||||
|
return (
|
||||||
|
<section className="bg-[#F5F5F3] py-20">
|
||||||
|
<div className="mx-auto grid max-w-7xl gap-12 px-4 sm:px-6 lg:grid-cols-2 lg:gap-16 lg:px-8">
|
||||||
|
{/* Left Column */}
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
{/* Label */}
|
||||||
|
<div className="mb-6 flex items-center gap-2">
|
||||||
|
<div className="h-5 w-1 rounded-full bg-[#C4D668]" />
|
||||||
|
<span className="text-sm font-medium tracking-wide text-gray-500">
|
||||||
|
Onze werkwijze in 6 stappen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heading */}
|
||||||
|
<h2 className="mb-5 text-4xl font-light leading-tight text-gray-900 lg:text-5xl">
|
||||||
|
Van schets tot stalen
|
||||||
|
<br />
|
||||||
|
deur in je woonkamer
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="mb-8 max-w-lg text-sm leading-relaxed text-gray-500">
|
||||||
|
Bij Proinn vinden we dat een deur of wand meer is dan alleen een
|
||||||
|
praktisch product. Het bepaalt de sfeer van een ruimte en moet
|
||||||
|
perfect aansluiten bij jouw wensen. Daarom begeleiden we je stap
|
||||||
|
voor stap in het proces:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Checklist */}
|
||||||
|
<ul className="mb-8 space-y-3">
|
||||||
|
{checklistItems.map((item) => (
|
||||||
|
<li key={item} className="flex items-start gap-3">
|
||||||
|
<Check className="mt-0.5 size-4 shrink-0 text-gray-700" strokeWidth={2.5} />
|
||||||
|
<span className="text-sm leading-snug text-gray-600">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Closing text */}
|
||||||
|
<p className="mb-8 max-w-lg text-sm leading-relaxed text-gray-500">
|
||||||
|
Zo zorgen we ervoor dat jij straks een deur of wand hebt die niet
|
||||||
|
alleen mooi oogt, maar ook praktisch en duurzaam is.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/showrooms"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-[#1A2E2E] px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[#263e3e]"
|
||||||
|
>
|
||||||
|
<MapPin className="size-4" />
|
||||||
|
Onze showrooms
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/offerte"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-[#C4D668] px-5 py-2.5 text-sm font-semibold text-black transition-colors hover:bg-[#b5c75a]"
|
||||||
|
>
|
||||||
|
<PenTool className="size-4" />
|
||||||
|
Zelf ontwerpen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - 6 Steps */}
|
||||||
|
<div className="flex flex-col justify-center space-y-3">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={step}
|
||||||
|
className="flex items-center gap-4 rounded-xl bg-white px-5 py-4 shadow-sm"
|
||||||
|
>
|
||||||
|
{/* Number Circle */}
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full border border-gray-200 text-sm font-semibold text-gray-700">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Text */}
|
||||||
|
<span className="flex-1 text-sm font-semibold text-gray-800">
|
||||||
|
{step}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Arrow */}
|
||||||
|
<ArrowRight className="size-4 shrink-0 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
components/layout/footer.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Mail, Phone, Star, Facebook, Instagram, Linkedin, Youtube } from "lucide-react";
|
||||||
|
|
||||||
|
const contactInfo = [
|
||||||
|
{ icon: Mail, text: "info@proinn.nl", href: "mailto:info@proinn.nl" },
|
||||||
|
{ icon: Phone, text: "085 - 1234 567", href: "tel:0851234567" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const companyInfo = [
|
||||||
|
{ label: "KVK", value: "12345678" },
|
||||||
|
{ label: "BTW", value: "NL123456789B01" },
|
||||||
|
{ label: "IBAN", value: "NL00 INGB 0000 0000 00" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const locations = [
|
||||||
|
"Nunspeet",
|
||||||
|
"Veghel",
|
||||||
|
"Amsterdam",
|
||||||
|
"Rotterdam",
|
||||||
|
"Utrecht",
|
||||||
|
];
|
||||||
|
|
||||||
|
const proinnLinks = [
|
||||||
|
{ label: "Projecten", href: "/projecten" },
|
||||||
|
{ label: "Configurator", href: "/offerte" },
|
||||||
|
{ label: "Over ons", href: "/over-ons" },
|
||||||
|
{ label: "Vacatures", href: "/vacatures" },
|
||||||
|
{ label: "Showrooms", href: "/showrooms" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const serviceLinks = [
|
||||||
|
{ label: "Contact", href: "/contact" },
|
||||||
|
{ label: "Kennisbank", href: "/kennisbank" },
|
||||||
|
{ label: "Veelgestelde vragen", href: "/faq" },
|
||||||
|
{ label: "Garantie", href: "/garantie" },
|
||||||
|
{ label: "Onderhoud", href: "/onderhoud" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const socialLinks = [
|
||||||
|
{ icon: Facebook, href: "#", label: "Facebook" },
|
||||||
|
{ icon: Instagram, href: "#", label: "Instagram" },
|
||||||
|
{ icon: Linkedin, href: "#", label: "LinkedIn" },
|
||||||
|
{ icon: Youtube, href: "#", label: "YouTube" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="bg-[#1A2E2E]">
|
||||||
|
{/* Main Footer */}
|
||||||
|
<div className="mx-auto max-w-7xl px-4 pt-16 pb-12 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 gap-10 md:grid-cols-2 lg:grid-cols-5 lg:gap-8">
|
||||||
|
{/* Col 1 - Logo & Contact */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<Link href="/" className="text-2xl font-extrabold tracking-tight text-white">
|
||||||
|
PROINN
|
||||||
|
</Link>
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{contactInfo.map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.text}
|
||||||
|
href={item.href}
|
||||||
|
className="flex items-center gap-2 text-sm text-gray-400 transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
<item.icon className="size-4 shrink-0" />
|
||||||
|
{item.text}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 space-y-2">
|
||||||
|
{companyInfo.map((item) => (
|
||||||
|
<p key={item.label} className="text-xs text-gray-500">
|
||||||
|
<span className="text-gray-400">{item.label}:</span> {item.value}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col 2 - Locaties */}
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-4 text-sm font-semibold text-white">Locaties</h4>
|
||||||
|
<ul className="space-y-2.5">
|
||||||
|
{locations.map((city) => (
|
||||||
|
<li key={city}>
|
||||||
|
<Link
|
||||||
|
href={`/showrooms/${city.toLowerCase()}`}
|
||||||
|
className="text-sm text-gray-400 transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
{city}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col 3 - Proinn */}
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-4 text-sm font-semibold text-white">Proinn</h4>
|
||||||
|
<ul className="space-y-2.5">
|
||||||
|
{proinnLinks.map((link) => (
|
||||||
|
<li key={link.label}>
|
||||||
|
<Link
|
||||||
|
href={link.href}
|
||||||
|
className="text-sm text-gray-400 transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col 4 - Service */}
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-4 text-sm font-semibold text-white">Service</h4>
|
||||||
|
<ul className="space-y-2.5">
|
||||||
|
{serviceLinks.map((link) => (
|
||||||
|
<li key={link.label}>
|
||||||
|
<Link
|
||||||
|
href={link.href}
|
||||||
|
className="text-sm text-gray-400 transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col 5 - Trustpilot */}
|
||||||
|
<div>
|
||||||
|
<div className="rounded-2xl bg-[#243636] p-6">
|
||||||
|
<p className="mb-3 text-xs font-medium uppercase tracking-wider text-gray-400">
|
||||||
|
Klantwaardering
|
||||||
|
</p>
|
||||||
|
<div className="mb-2 flex items-center gap-1">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className="size-5 fill-yellow-400 text-yellow-400"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">
|
||||||
|
4.8<span className="text-sm font-normal text-gray-400">/5</span>
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-1.5">
|
||||||
|
<Star className="size-4 fill-green-500 text-green-500" />
|
||||||
|
<span className="text-sm text-gray-400">Trustpilot</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Bar */}
|
||||||
|
<div className="border-t border-white/10">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 px-4 py-6 sm:flex-row sm:px-6 lg:px-8">
|
||||||
|
{/* Social Icons */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{socialLinks.map((social) => (
|
||||||
|
<a
|
||||||
|
key={social.label}
|
||||||
|
href={social.href}
|
||||||
|
aria-label={social.label}
|
||||||
|
className="flex size-9 items-center justify-center rounded-full bg-white/10 text-gray-400 transition-colors hover:bg-white/20 hover:text-white"
|
||||||
|
>
|
||||||
|
<social.icon className="size-4" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legal Links */}
|
||||||
|
<div className="flex items-center gap-6 text-xs text-gray-500">
|
||||||
|
<span>© {new Date().getFullYear()} Proinn</span>
|
||||||
|
<Link href="/privacy" className="transition-colors hover:text-gray-300">
|
||||||
|
Privacybeleid
|
||||||
|
</Link>
|
||||||
|
<Link href="/voorwaarden" className="transition-colors hover:text-gray-300">
|
||||||
|
Algemene voorwaarden
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
components/layout/header.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { TopBar } from "./top-bar";
|
||||||
|
import { MainNav } from "./main-nav";
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 w-full shadow-sm">
|
||||||
|
<TopBar />
|
||||||
|
<MainNav />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
components/layout/main-nav.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Menu } from "lucide-react";
|
||||||
|
import { MobileMenu } from "./mobile-menu";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function MainNav() {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bg-white">
|
||||||
|
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-2xl font-extrabold tracking-tight text-gray-900"
|
||||||
|
>
|
||||||
|
PROINN
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Right side: CTA + Hamburger */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/offerte"
|
||||||
|
className="inline-flex items-center rounded-md bg-[#C4D668] px-5 py-2.5 text-sm font-semibold text-black transition-colors hover:bg-[#b5c75a]"
|
||||||
|
>
|
||||||
|
Vraag Offerte
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen(true)}
|
||||||
|
className="inline-flex size-11 items-center justify-center rounded-md bg-gray-100 text-gray-600 transition-colors hover:bg-gray-200"
|
||||||
|
aria-label="Menu openen"
|
||||||
|
>
|
||||||
|
<Menu className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MobileMenu open={menuOpen} onOpenChange={setMenuOpen} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
components/layout/mobile-menu.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronDown, Phone, Mail } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
|
||||||
|
const menuLinks = [
|
||||||
|
{ href: "/", label: "Home" },
|
||||||
|
{ href: "/producten", label: "Producten", hasSubmenu: true },
|
||||||
|
{ href: "/maatwerk", label: "Maatwerk", hasSubmenu: true },
|
||||||
|
{ href: "/over-ons", label: "Over Ons" },
|
||||||
|
{ href: "/contact", label: "Contact" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MobileMenuProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileMenu({ open, onOpenChange }: MobileMenuProps) {
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="right" className="flex w-full max-w-sm flex-col p-0">
|
||||||
|
<SheetHeader className="border-b px-6 py-4">
|
||||||
|
<SheetTitle className="text-left text-lg font-extrabold tracking-tight">
|
||||||
|
PROINN
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{/* Navigation Links */}
|
||||||
|
<nav className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{menuLinks.map((link) => (
|
||||||
|
<li key={link.href}>
|
||||||
|
<Link
|
||||||
|
href={link.href}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="flex items-center justify-between rounded-md px-3 py-3 text-base font-medium text-gray-800 transition-colors hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
{link.hasSubmenu && (
|
||||||
|
<ChevronDown className="size-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<Link
|
||||||
|
href="/offerte"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="flex w-full items-center justify-center rounded-md bg-[#C4D668] px-5 py-3 text-sm font-semibold text-black transition-colors hover:bg-[#b5c75a]"
|
||||||
|
>
|
||||||
|
Vraag Offerte
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer Block */}
|
||||||
|
<div className="bg-[#2F3B3B] px-6 py-6">
|
||||||
|
<p className="mb-3 text-sm font-semibold text-white">
|
||||||
|
Wil je wat vragen?
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<a
|
||||||
|
href="tel:0851234567"
|
||||||
|
className="flex items-center gap-2 text-sm text-gray-300 transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
<Phone className="size-4" />
|
||||||
|
085 - 1234 567
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="mailto:info@proinn.nl"
|
||||||
|
className="flex items-center gap-2 text-sm text-gray-300 transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
<Mail className="size-4" />
|
||||||
|
info@proinn.nl
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
components/layout/navbar.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Menu, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ href: "/", label: "Home" },
|
||||||
|
{ href: "/producten", label: "Producten" },
|
||||||
|
{ href: "/maatwerk", label: "Maatwerk" },
|
||||||
|
{ href: "/contact", label: "Contact" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Navbar() {
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed top-0 left-0 right-0 z-50 border-b border-border/40 bg-background/80 backdrop-blur-lg">
|
||||||
|
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link href="/" className="text-xl font-bold tracking-tighter text-foreground">
|
||||||
|
PROINN
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<div className="hidden items-center gap-1 md:flex">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA Button + Mobile Toggle */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="sm"
|
||||||
|
className="bg-brand-orange text-white hover:bg-brand-orange/90"
|
||||||
|
>
|
||||||
|
<Link href="/offerte">Vraag Offerte</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(!mobileOpen)}
|
||||||
|
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:text-foreground md:hidden"
|
||||||
|
>
|
||||||
|
{mobileOpen ? <X className="size-5" /> : <Menu className="size-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Navigation */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden border-t border-border/40 bg-background/95 backdrop-blur-lg transition-all duration-200 md:hidden",
|
||||||
|
mobileOpen ? "max-h-64" : "max-h-0 border-t-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-1 px-4 py-3">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="block rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
components/layout/top-bar.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Star, Phone, Globe } from "lucide-react";
|
||||||
|
|
||||||
|
export function TopBar() {
|
||||||
|
return (
|
||||||
|
<div className="bg-[#E8E8E6]">
|
||||||
|
<div className="mx-auto flex h-10 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Trustpilot */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className="size-3.5 fill-yellow-400 text-yellow-400"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-gray-700">4.8/5</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Star className="size-3.5 fill-green-600 text-green-600" />
|
||||||
|
<span className="text-xs font-medium text-gray-600">
|
||||||
|
Trustpilot
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact & Language */}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-gray-600">
|
||||||
|
<a
|
||||||
|
href="tel:0851234567"
|
||||||
|
className="flex items-center gap-1.5 font-medium transition-colors hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<Phone className="size-3.5" />
|
||||||
|
<span>085 - 1234 567</span>
|
||||||
|
</a>
|
||||||
|
<div className="h-3.5 w-px bg-gray-400" />
|
||||||
|
<div className="flex items-center gap-1.5 font-medium">
|
||||||
|
<Globe className="size-3.5" />
|
||||||
|
<span>NL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
components/offerte/form-context.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import type { QuoteData } from "@/lib/validators";
|
||||||
|
|
||||||
|
const TOTAL_STEPS = 5; // 4 form steps + 1 summary
|
||||||
|
|
||||||
|
type FormData = Partial<QuoteData>;
|
||||||
|
|
||||||
|
interface FormContextValue {
|
||||||
|
currentStep: number;
|
||||||
|
formData: FormData;
|
||||||
|
totalSteps: number;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
goToStep: (step: number) => void;
|
||||||
|
updateData: (data: Partial<FormData>) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormContext = createContext<FormContextValue | null>(null);
|
||||||
|
|
||||||
|
export function FormProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [formData, setFormData] = useState<FormData>({});
|
||||||
|
|
||||||
|
const nextStep = useCallback(() => {
|
||||||
|
setCurrentStep((s) => Math.min(s + 1, TOTAL_STEPS - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prevStep = useCallback(() => {
|
||||||
|
setCurrentStep((s) => Math.max(s - 1, 0));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToStep = useCallback((step: number) => {
|
||||||
|
setCurrentStep(Math.max(0, Math.min(step, TOTAL_STEPS - 1)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateData = useCallback((data: Partial<FormData>) => {
|
||||||
|
setFormData((prev) => ({ ...prev, ...data }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setCurrentStep(0);
|
||||||
|
setFormData({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormContext.Provider
|
||||||
|
value={{
|
||||||
|
currentStep,
|
||||||
|
formData,
|
||||||
|
totalSteps: TOTAL_STEPS,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
goToStep,
|
||||||
|
updateData,
|
||||||
|
reset,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</FormContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFormContext() {
|
||||||
|
const ctx = useContext(FormContext);
|
||||||
|
if (!ctx) throw new Error("useFormContext must be used within <FormProvider>");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
83
components/offerte/step-contact.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormContext } from "@/components/offerte/form-context";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { User, Mail, Phone, MessageSquare } from "lucide-react";
|
||||||
|
|
||||||
|
export function StepContact() {
|
||||||
|
const { formData, updateData } = useFormContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-2 text-xl font-semibold">Contactgegevens</h2>
|
||||||
|
<p className="mb-6 text-sm text-muted-foreground">
|
||||||
|
Vul uw gegevens in zodat wij u een offerte kunnen sturen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name" className="flex items-center gap-2">
|
||||||
|
<User className="size-4 text-brand-orange" />
|
||||||
|
Naam
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="Uw volledige naam"
|
||||||
|
value={formData.name ?? ""}
|
||||||
|
onChange={(e) => updateData({ name: e.target.value })}
|
||||||
|
className="h-11 focus-visible:ring-brand-orange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email" className="flex items-center gap-2">
|
||||||
|
<Mail className="size-4 text-brand-orange" />
|
||||||
|
E-mail
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="naam@bedrijf.nl"
|
||||||
|
value={formData.email ?? ""}
|
||||||
|
onChange={(e) => updateData({ email: e.target.value })}
|
||||||
|
className="h-11 focus-visible:ring-brand-orange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone" className="flex items-center gap-2">
|
||||||
|
<Phone className="size-4 text-brand-orange" />
|
||||||
|
Telefoon
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="06 1234 5678"
|
||||||
|
value={formData.phone ?? ""}
|
||||||
|
onChange={(e) => updateData({ phone: e.target.value })}
|
||||||
|
className="h-11 focus-visible:ring-brand-orange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="note" className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="size-4 text-brand-orange" />
|
||||||
|
Opmerking
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">(optioneel)</span>
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
id="note"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Bijv. specifieke wensen, RAL-kleur, plaatsing..."
|
||||||
|
value={formData.note ?? ""}
|
||||||
|
onChange={(e) => updateData({ note: e.target.value })}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
components/offerte/step-dimensions.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormContext } from "@/components/offerte/form-context";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Ruler } from "lucide-react";
|
||||||
|
|
||||||
|
export function StepDimensions() {
|
||||||
|
const { formData, updateData } = useFormContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-2 text-xl font-semibold">Afmetingen</h2>
|
||||||
|
<p className="mb-6 text-sm text-muted-foreground">
|
||||||
|
Voer de gewenste afmetingen in millimeters in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="height" className="flex items-center gap-2">
|
||||||
|
<Ruler className="size-4 text-brand-orange" />
|
||||||
|
Hoogte (mm)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="height"
|
||||||
|
type="number"
|
||||||
|
placeholder="bijv. 2400"
|
||||||
|
value={formData.height ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
updateData({ height: val === "" ? undefined : Number(val) });
|
||||||
|
}}
|
||||||
|
className="h-12 text-lg focus-visible:ring-brand-orange"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Min: 2000mm — Max: 3000mm
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="width" className="flex items-center gap-2">
|
||||||
|
<Ruler className="size-4 rotate-90 text-brand-orange" />
|
||||||
|
Breedte (mm)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="width"
|
||||||
|
type="number"
|
||||||
|
placeholder="bijv. 900"
|
||||||
|
value={formData.width ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
updateData({ width: val === "" ? undefined : Number(val) });
|
||||||
|
}}
|
||||||
|
className="h-12 text-lg focus-visible:ring-brand-orange"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Min: 300mm — Max: 3000mm
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
components/offerte/step-options.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormContext } from "@/components/offerte/form-context";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { glassTypes, finishTypes } from "@/lib/validators";
|
||||||
|
import { Paintbrush, GlassWater } from "lucide-react";
|
||||||
|
|
||||||
|
export function StepOptions() {
|
||||||
|
const { formData, updateData } = useFormContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-2 text-xl font-semibold">Opties</h2>
|
||||||
|
<p className="mb-6 text-sm text-muted-foreground">
|
||||||
|
Kies de afwerking en het glastype voor uw product.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-8 sm:grid-cols-2">
|
||||||
|
{/* Glass Type */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label className="flex items-center gap-2 text-base font-semibold">
|
||||||
|
<GlassWater className="size-4 text-brand-orange" />
|
||||||
|
Glas Type
|
||||||
|
</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={formData.glassType ?? ""}
|
||||||
|
onValueChange={(val) => updateData({ glassType: val as typeof formData.glassType })}
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
{glassTypes.map((type) => (
|
||||||
|
<Label
|
||||||
|
key={type}
|
||||||
|
htmlFor={`glass-${type}`}
|
||||||
|
className="flex cursor-pointer items-center gap-3 rounded-lg border border-border p-4 transition-colors has-[[data-state=checked]]:border-brand-orange has-[[data-state=checked]]:bg-brand-orange/5"
|
||||||
|
>
|
||||||
|
<RadioGroupItem value={type} id={`glass-${type}`} />
|
||||||
|
<span className="text-sm font-medium">{type}</span>
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Finish */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label className="flex items-center gap-2 text-base font-semibold">
|
||||||
|
<Paintbrush className="size-4 text-brand-orange" />
|
||||||
|
Afwerking
|
||||||
|
</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={formData.finish ?? ""}
|
||||||
|
onValueChange={(val) => updateData({ finish: val as typeof formData.finish })}
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
{finishTypes.map((type) => (
|
||||||
|
<Label
|
||||||
|
key={type}
|
||||||
|
htmlFor={`finish-${type}`}
|
||||||
|
className="flex cursor-pointer items-center gap-3 rounded-lg border border-border p-4 transition-colors has-[[data-state=checked]]:border-brand-orange has-[[data-state=checked]]:bg-brand-orange/5"
|
||||||
|
>
|
||||||
|
<RadioGroupItem value={type} id={`finish-${type}`} />
|
||||||
|
<span className="text-sm font-medium">{type}</span>
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
components/offerte/step-product.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useFormContext } from "@/components/offerte/form-context";
|
||||||
|
import { productTypes } from "@/lib/validators";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const productImages: Record<string, string> = {
|
||||||
|
Taatsdeur: "/images/taats.jpg",
|
||||||
|
Scharnierdeur: "/images/scharnier.jpg",
|
||||||
|
"Vast Paneel": "/images/paneel.jpg",
|
||||||
|
};
|
||||||
|
|
||||||
|
const productDescriptions: Record<string, string> = {
|
||||||
|
Taatsdeur: "Pivoterende deur",
|
||||||
|
Scharnierdeur: "Klassiek scharnier",
|
||||||
|
"Vast Paneel": "Vast glaspaneel",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StepProduct() {
|
||||||
|
const { formData, updateData, nextStep } = useFormContext();
|
||||||
|
|
||||||
|
function select(type: (typeof productTypes)[number]) {
|
||||||
|
updateData({ productType: type });
|
||||||
|
nextStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-2 text-xl font-semibold">Kies uw product</h2>
|
||||||
|
<p className="mb-6 text-sm text-muted-foreground">
|
||||||
|
Selecteer het type stalen element dat u wilt configureren.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{productTypes.map((type) => {
|
||||||
|
const selected = formData.productType === type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => select(type)}
|
||||||
|
className={cn(
|
||||||
|
"group relative aspect-[3/4] overflow-hidden text-left transition-all",
|
||||||
|
selected
|
||||||
|
? "ring-4 ring-brand-orange ring-offset-2"
|
||||||
|
: "ring-0 hover:ring-2 hover:ring-brand-orange/40 hover:ring-offset-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Image fills entire card */}
|
||||||
|
<Image
|
||||||
|
src={productImages[type]}
|
||||||
|
alt={type}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom gradient with label */}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-4 pb-5 pt-16">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-white/60">
|
||||||
|
{productDescriptions[type]}
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-1 text-lg font-semibold text-white">
|
||||||
|
{type}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected state overlay */}
|
||||||
|
{selected && (
|
||||||
|
<div className="absolute inset-0 border-4 border-brand-orange" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
components/offerte/step-summary.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormContext } from "@/components/offerte/form-context";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Send, Check } from "lucide-react";
|
||||||
|
|
||||||
|
const fieldLabels: Record<string, string> = {
|
||||||
|
productType: "Product",
|
||||||
|
height: "Hoogte",
|
||||||
|
width: "Breedte",
|
||||||
|
glassType: "Glas Type",
|
||||||
|
finish: "Afwerking",
|
||||||
|
name: "Naam",
|
||||||
|
email: "E-mail",
|
||||||
|
phone: "Telefoon",
|
||||||
|
note: "Opmerking",
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldOrder = [
|
||||||
|
"productType",
|
||||||
|
"height",
|
||||||
|
"width",
|
||||||
|
"glassType",
|
||||||
|
"finish",
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"note",
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatValue(key: string, value: unknown): string {
|
||||||
|
if (value === undefined || value === null || value === "") return "—";
|
||||||
|
if (key === "height" || key === "width") return `${value} mm`;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepSummary() {
|
||||||
|
const { formData } = useFormContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-2 text-xl font-semibold">Overzicht</h2>
|
||||||
|
<p className="mb-6 text-sm text-muted-foreground">
|
||||||
|
Controleer uw configuratie en verstuur de aanvraag.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{fieldOrder.map((key, i) => {
|
||||||
|
const value = formData[key as keyof typeof formData];
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={key}
|
||||||
|
className={i % 2 === 0 ? "bg-muted/30" : "bg-card"}
|
||||||
|
>
|
||||||
|
<td className="w-1/3 px-4 py-3 font-medium text-muted-foreground">
|
||||||
|
{fieldLabels[key]}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-medium">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{value !== undefined && value !== "" && (
|
||||||
|
<Check className="size-3.5 text-green-600" />
|
||||||
|
)}
|
||||||
|
{formatValue(key, value)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="mt-8 w-full bg-brand-orange text-white hover:bg-brand-orange/90"
|
||||||
|
>
|
||||||
|
<Send className="size-4" />
|
||||||
|
Verzend Aanvraag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
components/ui/button.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
92
components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
21
components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
45
components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CircleIcon } from "lucide-react"
|
||||||
|
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
143
components/ui/sheet.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
side === "right" &&
|
||||||
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
side === "left" &&
|
||||||
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
side === "top" &&
|
||||||
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
side === "bottom" &&
|
||||||
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
53
lib/validators.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
// ── Step 1: Product ──────────────────────────────────────────────
|
||||||
|
export const productTypes = ["Taatsdeur", "Scharnierdeur", "Vast Paneel"] as const;
|
||||||
|
|
||||||
|
export const productSchema = z.object({
|
||||||
|
productType: z.enum(productTypes),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step 2: Dimensions ──────────────────────────────────────────
|
||||||
|
export const dimensionsSchema = z.object({
|
||||||
|
height: z
|
||||||
|
.number({ error: "Vul een geldige hoogte in" })
|
||||||
|
.min(2000, "Minimaal 2000mm")
|
||||||
|
.max(3000, "Maximaal 3000mm"),
|
||||||
|
width: z
|
||||||
|
.number({ error: "Vul een geldige breedte in" })
|
||||||
|
.min(300, "Minimaal 300mm")
|
||||||
|
.max(3000, "Maximaal 3000mm"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step 3: Options ─────────────────────────────────────────────
|
||||||
|
export const glassTypes = ["Helder", "Rookglas", "Melkglas"] as const;
|
||||||
|
export const finishTypes = ["Poedercoat Zwart", "Goud", "Brons"] as const;
|
||||||
|
|
||||||
|
export const optionsSchema = z.object({
|
||||||
|
glassType: z.enum(glassTypes),
|
||||||
|
finish: z.enum(finishTypes),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step 4: Contact ─────────────────────────────────────────────
|
||||||
|
export const contactSchema = z.object({
|
||||||
|
name: z.string().min(2, "Vul uw naam in"),
|
||||||
|
email: z.string().email("Vul een geldig e-mailadres in"),
|
||||||
|
phone: z.string().min(10, "Vul een geldig telefoonnummer in"),
|
||||||
|
note: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Combined (used on final submission) ─────────────────────────
|
||||||
|
export const quoteSchema = productSchema
|
||||||
|
.merge(dimensionsSchema)
|
||||||
|
.merge(optionsSchema)
|
||||||
|
.merge(contactSchema);
|
||||||
|
|
||||||
|
export type QuoteData = z.infer<typeof quoteSchema>;
|
||||||
|
|
||||||
|
// Per-step schemas for step-by-step validation
|
||||||
|
export const stepSchemas = [
|
||||||
|
productSchema,
|
||||||
|
dimensionsSchema,
|
||||||
|
optionsSchema,
|
||||||
|
contactSchema,
|
||||||
|
] as const;
|
||||||
5724
package-lock.json
generated
17
package.json
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "stalendeuren_init",
|
"name": "stalendeuren",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,18 +9,31 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"react-hook-form": "^7.71.1",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/cheerio": "^0.22.35",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
|
"shadcn": "^3.8.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
58
project_brief.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Project Specification: Proinn Website Rebuild
|
||||||
|
|
||||||
|
## 1. Project Overview
|
||||||
|
We are rebuilding the existing Lightspeed webshop (www.proinn.nl) into a modern, custom lead-generation website.
|
||||||
|
**Target Domain:** `proinn.youztech.nl`
|
||||||
|
|
||||||
|
## 2. Core Objective
|
||||||
|
Transform the site from a "shop" to a "showroom". Instead of a shopping cart, users will use a **Product Configurator** to request a formal quote.
|
||||||
|
|
||||||
|
## 3. Tech Stack (Strict)
|
||||||
|
- **Framework:** Next.js 14+ (App Router, TypeScript).
|
||||||
|
- **Styling:** Tailwind CSS.
|
||||||
|
- **Components:** Shadcn/UI (Base color: Slate/Neutral).
|
||||||
|
- **Icons:** Lucide-React.
|
||||||
|
- **Forms:** React Hook Form + Zod (Validation).
|
||||||
|
- **Email/Backend:** Resend (via Server Actions).
|
||||||
|
- **Deployment:** Vercel.
|
||||||
|
|
||||||
|
## 4. Design Guidelines
|
||||||
|
- **Style:** Industrial, clean, heavy. "Form follows function."
|
||||||
|
- **Colors:**
|
||||||
|
- Primary: Dark Grey / Black (Industrial feel).
|
||||||
|
- Accent: Orange & Blue (Derived from Proinn logo).
|
||||||
|
- Background: White / Off-white.
|
||||||
|
- **Typography:** Sans-serif, bold headers (Inter or Roboto).
|
||||||
|
|
||||||
|
## 5. Architecture (PAL Workflow)
|
||||||
|
- **Performer:** Writes the code, runs terminal commands.
|
||||||
|
- **Architect:** Plans the folder structure and component hierarchy.
|
||||||
|
- **Lead:** Ensures alignment with business goals (Quote generation).
|
||||||
|
|
||||||
|
## 6. Functional Requirements
|
||||||
|
### A. Homepage
|
||||||
|
- Hero section with industrial imagery.
|
||||||
|
- USP Grid (Fast delivery, Custom work, Quality).
|
||||||
|
- Short "About Us" teaser.
|
||||||
|
|
||||||
|
### B. The Configurator (/offerte)
|
||||||
|
A multi-step client-side wizard:
|
||||||
|
1. **Select Product:** Grid of clickable cards (e.g., "Industrial Table", "Custom Frame").
|
||||||
|
2. **Dimensions:** Input fields for Height, Width, Depth (mm).
|
||||||
|
3. **Options:** Checkboxes for extras (Coating, Wheels, Material type).
|
||||||
|
4. **Contact:** Form for Name, Company, Email, Phone.
|
||||||
|
5. **Summary:** Review page with "Request Quote" button.
|
||||||
|
|
||||||
|
### C. Backend Logic
|
||||||
|
- On submit, validate data with Zod.
|
||||||
|
- Send email via Resend to `info@proinn.nl`.
|
||||||
|
- Send confirmation email to the user.
|
||||||
|
- Store nothing in a database (stateless).
|
||||||
|
|
||||||
|
## 7. Implementation Phases
|
||||||
|
1. **Setup:** Initialize Next.js, Install Tailwind/Shadcn.
|
||||||
|
2. **Design System:** Set up fonts, colors, and global CSS.
|
||||||
|
3. **Layout:** Create Navbar (Logo + CTA) and Footer.
|
||||||
|
4. **Pages:** Build Home and Text pages.
|
||||||
|
5. **Configurator:** Build the logic and UI for the wizard.
|
||||||
|
6. **Integration:** Connect Resend API.
|
||||||
1
public/images/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.vercel
|
||||||
BIN
public/images/aanbod.jpg
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
public/images/about.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
public/images/hero.jpg
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
public/images/image-people1.png
Normal file
|
After Width: | Height: | Size: 6.8 MiB |
BIN
public/images/paneel.jpg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
public/images/proinn-spuiten.png
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
BIN
public/images/scharnier.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
public/images/taats.jpg
Normal file
|
After Width: | Height: | Size: 65 KiB |
87
scripts/get-images.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
const https = require("https");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const outDir = path.join(__dirname, "..", "public", "images");
|
||||||
|
|
||||||
|
const images = [
|
||||||
|
{
|
||||||
|
// Wide cinematic hero shot from homepage
|
||||||
|
url: "https://static.aluwdoors.com/site/uploads/2025/07/01.jpg",
|
||||||
|
name: "hero.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Stalen taatsdeur in brons - portrait shot
|
||||||
|
url: "https://static.aluwdoors.com/site/uploads/2025/08/Stalen-dubbele-taatsdeur-in-het-brons-368x460.jpg",
|
||||||
|
name: "taats.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Zwarte stalen scharnierdeur - portrait shot
|
||||||
|
url: "https://static.aluwdoors.com/site/uploads/2025/07/Zwarte-stalen-scharnierdeur-1-768x960.jpg",
|
||||||
|
name: "scharnier.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Bronzen schuifdeur (paneel/fixed panel) from homepage
|
||||||
|
url: "https://static.aluwdoors.com/site/uploads/2025/10/Bronzen-schuifdeur-tussen-woonkamer-en-keuken-1.jpg",
|
||||||
|
name: "paneel.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Interior shot for about section
|
||||||
|
url: "https://static.aluwdoors.com/site/uploads/2025/07/02.jpg",
|
||||||
|
name: "about.jpg",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function download(url, dest) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
const options = {
|
||||||
|
hostname: parsedUrl.hostname,
|
||||||
|
path: parsedUrl.pathname + parsedUrl.search,
|
||||||
|
headers: {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||||
|
Accept: "image/*,*/*",
|
||||||
|
Referer: "https://www.aluwdoors.com/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
https
|
||||||
|
.get(options, (res) => {
|
||||||
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
download(res.headers.location, dest).then(resolve).catch(reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = fs.createWriteStream(dest);
|
||||||
|
res.pipe(file);
|
||||||
|
file.on("finish", () => {
|
||||||
|
file.close();
|
||||||
|
const size = fs.statSync(dest).size;
|
||||||
|
console.log(
|
||||||
|
` OK: ${path.basename(dest).padEnd(16)} ${(size / 1024).toFixed(0).padStart(5)} KB`
|
||||||
|
);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("Downloading images from aluwdoors.com...\n");
|
||||||
|
for (const img of images) {
|
||||||
|
const dest = path.join(outDir, img.name);
|
||||||
|
try {
|
||||||
|
await download(img.url, dest);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` FAIL: ${img.name} - ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("\nDone.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
259
scripts/scrape-assets.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* Scrape product images from proinn.nl for local development.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/scrape-assets.ts
|
||||||
|
*
|
||||||
|
* Downloads hero, product, and detail images into public/images/.
|
||||||
|
* Falls back to placeholder files with instructions if scraping fails.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
|
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
const BASE = "https://www.proinn.nl";
|
||||||
|
const OUT_DIR = join(__dirname, "..", "public", "images");
|
||||||
|
|
||||||
|
const UA =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
|
const http = axios.create({
|
||||||
|
headers: { "User-Agent": UA },
|
||||||
|
timeout: 15_000,
|
||||||
|
maxRedirects: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure output directory exists
|
||||||
|
mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────
|
||||||
|
function abs(url: string): string {
|
||||||
|
if (url.startsWith("//")) return "https:" + url;
|
||||||
|
if (url.startsWith("/")) return BASE + url;
|
||||||
|
if (url.startsWith("http")) return url;
|
||||||
|
return BASE + "/" + url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download(url: string, filename: string): Promise<boolean> {
|
||||||
|
const dest = join(OUT_DIR, filename);
|
||||||
|
try {
|
||||||
|
console.log(` ↓ ${url}`);
|
||||||
|
const res = await http.get(url, { responseType: "arraybuffer" });
|
||||||
|
writeFileSync(dest, res.data);
|
||||||
|
const kb = Math.round(Buffer.byteLength(res.data) / 1024);
|
||||||
|
console.log(` ✓ Saved ${filename} (${kb} KB)`);
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(` ✗ Failed: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePlaceholder(filename: string) {
|
||||||
|
const dest = join(OUT_DIR, filename);
|
||||||
|
if (!existsSync(dest)) {
|
||||||
|
writeFileSync(
|
||||||
|
dest,
|
||||||
|
`Drag a real image here to replace this placeholder.\nExpected: ${filename}\nSource: ${BASE}\n`
|
||||||
|
);
|
||||||
|
console.log(` → Created placeholder: ${filename}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the largest image from a set of candidates (by URL heuristic or data-src)
|
||||||
|
function pickBest(srcs: string[]): string | undefined {
|
||||||
|
// Prefer larger sizes: sort descending by any numeric dimension in URL
|
||||||
|
return srcs.sort((a, b) => {
|
||||||
|
const numA = [...a.matchAll(/(\d{3,4})/g)].map(Number).sort((x, y) => y - x)[0] ?? 0;
|
||||||
|
const numB = [...b.matchAll(/(\d{3,4})/g)].map(Number).sort((x, y) => y - x)[0] ?? 0;
|
||||||
|
return numB - numA;
|
||||||
|
})[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ───────────────────────────────────────────────────
|
||||||
|
async function main() {
|
||||||
|
console.log("Scraping proinn.nl for assets…\n");
|
||||||
|
|
||||||
|
// ── Fetch homepage ──────────────────────────────────────
|
||||||
|
let $home: cheerio.CheerioAPI;
|
||||||
|
try {
|
||||||
|
const { data } = await http.get(BASE);
|
||||||
|
$home = cheerio.load(data);
|
||||||
|
console.log("✓ Homepage loaded\n");
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(`✗ Could not load homepage: ${err.message}`);
|
||||||
|
console.log(" Creating placeholder files instead.\n");
|
||||||
|
["hero.jpg", "taats.jpg", "scharnier.jpg", "paneel.jpg", "about.jpg"].forEach(writePlaceholder);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Target 1: Hero / slider image ───────────────────────
|
||||||
|
console.log("1) Hero image");
|
||||||
|
let heroOk = false;
|
||||||
|
// Look for common slider/banner patterns
|
||||||
|
const heroSelectors = [
|
||||||
|
".hero img",
|
||||||
|
".banner img",
|
||||||
|
".slider img",
|
||||||
|
".swiper img",
|
||||||
|
".carousel img",
|
||||||
|
'[class*="hero"] img',
|
||||||
|
'[class*="banner"] img',
|
||||||
|
'[class*="slider"] img',
|
||||||
|
"header img",
|
||||||
|
".header-image img",
|
||||||
|
// Lightspeed specific
|
||||||
|
".homepage-slider img",
|
||||||
|
".slideshow img",
|
||||||
|
"#slideshow img",
|
||||||
|
".rslides img",
|
||||||
|
// Fallback: first large image on page
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sel of heroSelectors) {
|
||||||
|
const imgs = $home(sel);
|
||||||
|
if (imgs.length) {
|
||||||
|
const srcs: string[] = [];
|
||||||
|
imgs.each((_, el) => {
|
||||||
|
const s = $home(el).attr("data-src") || $home(el).attr("src");
|
||||||
|
if (s) srcs.push(abs(s));
|
||||||
|
});
|
||||||
|
const best = pickBest(srcs);
|
||||||
|
if (best) {
|
||||||
|
heroOk = await download(best, "hero.jpg");
|
||||||
|
if (heroOk) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broader fallback: any large image
|
||||||
|
if (!heroOk) {
|
||||||
|
const allImgs: string[] = [];
|
||||||
|
$home("img").each((_, el) => {
|
||||||
|
const s = $home(el).attr("data-src") || $home(el).attr("src");
|
||||||
|
if (s && !s.includes("logo") && !s.includes("icon") && !s.includes("svg")) {
|
||||||
|
allImgs.push(abs(s));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const best = pickBest(allImgs);
|
||||||
|
if (best) heroOk = await download(best, "hero.jpg");
|
||||||
|
}
|
||||||
|
if (!heroOk) writePlaceholder("hero.jpg");
|
||||||
|
|
||||||
|
// ── Target 2: Product images ────────────────────────────
|
||||||
|
console.log("\n2) Product images");
|
||||||
|
|
||||||
|
// Try to find product pages / links first
|
||||||
|
const productKeywords: Record<string, string> = {
|
||||||
|
"taats.jpg": "taats",
|
||||||
|
"scharnier.jpg": "scharnier",
|
||||||
|
"paneel.jpg": "paneel|vast",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect all links + images from homepage
|
||||||
|
const pageLinks: string[] = [];
|
||||||
|
$home("a[href]").each((_, el) => {
|
||||||
|
const href = $home(el).attr("href");
|
||||||
|
if (href) pageLinks.push(abs(href));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [filename, pattern] of Object.entries(productKeywords)) {
|
||||||
|
let ok = false;
|
||||||
|
const re = new RegExp(pattern, "i");
|
||||||
|
|
||||||
|
// Check if any image on homepage matches
|
||||||
|
const matchImgs: string[] = [];
|
||||||
|
$home("img").each((_, el) => {
|
||||||
|
const src = $home(el).attr("data-src") || $home(el).attr("src") || "";
|
||||||
|
const alt = $home(el).attr("alt") || "";
|
||||||
|
const title = $home(el).attr("title") || "";
|
||||||
|
if (re.test(src) || re.test(alt) || re.test(title)) {
|
||||||
|
matchImgs.push(abs(src));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchImgs.length) {
|
||||||
|
const best = pickBest(matchImgs);
|
||||||
|
if (best) ok = await download(best, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try product listing pages
|
||||||
|
if (!ok) {
|
||||||
|
const productLinks = pageLinks.filter((l) => re.test(l));
|
||||||
|
for (const link of productLinks.slice(0, 2)) {
|
||||||
|
try {
|
||||||
|
const { data: html } = await http.get(link);
|
||||||
|
const $p = cheerio.load(html);
|
||||||
|
const imgs: string[] = [];
|
||||||
|
$p("img").each((_, el) => {
|
||||||
|
const s = $p(el).attr("data-src") || $p(el).attr("src");
|
||||||
|
if (s && !s.includes("logo") && !s.includes("icon")) {
|
||||||
|
imgs.push(abs(s));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const best = pickBest(imgs);
|
||||||
|
if (best) {
|
||||||
|
ok = await download(best, filename);
|
||||||
|
if (ok) break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore page load failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok) writePlaceholder(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Target 3: About / detail shot ─────────────────────
|
||||||
|
console.log("\n3) About / detail image");
|
||||||
|
let aboutOk = false;
|
||||||
|
|
||||||
|
// Check common about pages
|
||||||
|
const aboutLinks = pageLinks.filter(
|
||||||
|
(l) => /over-ons|about|werkplaats|atelier|contact/i.test(l)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const link of aboutLinks.slice(0, 3)) {
|
||||||
|
try {
|
||||||
|
const { data: html } = await http.get(link);
|
||||||
|
const $a = cheerio.load(html);
|
||||||
|
const imgs: string[] = [];
|
||||||
|
$a("img").each((_, el) => {
|
||||||
|
const s = $a(el).attr("data-src") || $a(el).attr("src");
|
||||||
|
if (s && !s.includes("logo") && !s.includes("icon")) {
|
||||||
|
imgs.push(abs(s));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const best = pickBest(imgs);
|
||||||
|
if (best) {
|
||||||
|
aboutOk = await download(best, "about.jpg");
|
||||||
|
if (aboutOk) break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: grab a distinctive image from homepage
|
||||||
|
if (!aboutOk) {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
$home("img").each((_, el) => {
|
||||||
|
const s = $home(el).attr("data-src") || $home(el).attr("src");
|
||||||
|
if (s && !s.includes("logo") && !s.includes("icon") && !s.includes("svg")) {
|
||||||
|
candidates.push(abs(s));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Skip first (likely hero), pick second
|
||||||
|
if (candidates.length > 1) {
|
||||||
|
aboutOk = await download(candidates[1], "about.jpg");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!aboutOk) writePlaceholder("about.jpg");
|
||||||
|
|
||||||
|
console.log("\n── Done ──");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
@@ -30,5 +30,5 @@
|
|||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "scripts"]
|
||||||
}
|
}
|
||||||
|
|||||||