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>
This commit is contained in:
88
components/home/about-section.tsx
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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,
|
||||
}
|
||||
Reference in New Issue
Block a user