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 |
138
app/globals.css
@@ -1,26 +1,136 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--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 {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--brand-orange: #F97316;
|
||||
--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 { 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";
|
||||
|
||||
const geistSans = Geist({
|
||||
const inter = Inter({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "PROINN | Stalen Deuren & Maatwerk",
|
||||
description:
|
||||
"Industriële stalen deuren, kozijnen en maatwerk van PROINN. Vraag direct een offerte aan via onze configurator.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,11 +21,11 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<html lang="nl">
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</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() {
|
||||
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">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
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>
|
||||
<>
|
||||
<Hero />
|
||||
<OfferSection />
|
||||
<ProcessSection />
|
||||
<AboutSection />
|
||||
<CraftsmanshipSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -9,18 +9,31 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"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",
|
||||
"radix-ui": "^1.4.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": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/cheerio": "^0.22.35",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"axios": "^1.13.5",
|
||||
"cheerio": "^1.2.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"shadcn": "^3.8.4",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"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",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "scripts"]
|
||||
}
|
||||
|
||||