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:
Ubuntu
2026-02-10 15:59:37 +00:00
parent c283d7193a
commit 9cf5cea3ba
55 changed files with 8411 additions and 99 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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 &middot; Vakmanschap &middot; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>&copy; {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>
);
}

View 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>
);
}

View 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} />
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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 &mdash; 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 &mdash; Max: 3000mm
</p>
</div>
</div>
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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 }

View 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
View 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,
}