Modernizing + /contact rewire

This commit is contained in:
root 2025-10-25 21:14:15 +02:00
parent 9094a4d0e4
commit b8c514013c
19 changed files with 1080 additions and 277 deletions

View File

@ -1,150 +1,264 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useState } from "react";
import { site } from "@/lib/site"; import { useRouter, useSearchParams } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { toast } from "sonner";
type Status = { state: "idle" | "submitting" | "success" | "error"; message?: string }; export default function RequestPage() {
const router = useRouter();
const searchParams = useSearchParams();
const prefillType = searchParams.get("type") as RequestType | null;
export default function ContactPage() { const [step, setStep] = useState(1);
const [startedAt, setStartedAt] = useState<string>(""); const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<Status>({ state: "idle" }); const [form, setForm] = useState({
type: prefillType || "",
name: "",
email: "",
domain: "",
company: "",
message: "",
hosting: "",
concern: "",
});
useEffect(() => { type RequestType =
setStartedAt(String(Date.now())); | "audit"
}, []); | "consultation"
| "support"
| "tool"
| "partnership";
const disabledEarly = useMemo(() => { const requestTypes: { id: RequestType; label: string; desc: string; icon: string }[] = [
if (!startedAt) return true; {
const min = Number(process.env.NEXT_PUBLIC_CONTACT_MIN_SUBMIT_SECONDS ?? 3) || 3; id: "audit",
return Date.now() - Number(startedAt) < min * 1000; label: "Free Audit",
}, [startedAt]); desc: "Request a free website, DNS or performance check.",
icon: "🧠",
},
{
id: "consultation",
label: "Consultation / Quote",
desc: "Discuss a project, hosting setup, or optimization.",
icon: "⚙️",
},
{
id: "support",
label: "Technical Support",
desc: "Report an issue or request hands-on help.",
icon: "🛠️",
},
{
id: "tool",
label: "Tool Follow-Up",
desc: "Continue from one of our free tools or reports.",
icon: "📊",
},
{
id: "partnership",
label: "Partnership / Collaboration",
desc: "Discuss a potential collaboration or integration.",
icon: "🤝",
},
];
async function onSubmit(e: React.FormEvent<HTMLFormElement>) { const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setForm((f) => ({ ...f, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setStatus({ state: "submitting" }); setLoading(true);
const form = e.currentTarget;
const formData = new FormData(form);
const payload = Object.fromEntries(formData.entries());
try { try {
const res = await fetch("/api/contact", { const res = await fetch("/api/request", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(form),
}); });
const json = (await res.json()) as { ok: boolean; error?: string };
if (!res.ok || !json.ok) throw new Error(json.error || "Failed"); if (!res.ok) throw new Error("Submission failed");
setStatus({ state: "success", message: "Thanks! Well get back to you shortly." });
form.reset(); toast.success("Request submitted successfully!");
setStartedAt(String(Date.now())); // reset time trap router.push("/request/success");
} catch { } catch (err) {
setStatus({ state: "error", message: "Could not send. Please try again." }); console.error(err);
} toast.error("Something went wrong. Please try again.");
} finally {
setLoading(false);
} }
};
return ( return (
<section className="container py-12"> <main className="relative isolate min-h-screen bg-gradient-to-b from-neutral-50 via-white to-neutral-100 dark:from-neutral-950 dark:via-neutral-900 dark:to-neutral-950">
<h1 className="text-3xl font-semibold tracking-tight">Contact</h1> <section className="border-b border-neutral-200 dark:border-neutral-800">
<p className="mt-2 text-muted-foreground"> <div className="container mx-auto max-w-4xl px-6 py-16 text-center">
Tell us what you want fixed or managed. Well confirm scope and start fast. <h1 className="text-4xl sm:text-5xl font-bold tracking-tight bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 bg-clip-text text-transparent">
Start a Request
</h1>
<p className="mt-4 text-neutral-700 dark:text-neutral-300 text-lg">
Choose what youd like to do audits, consultations, or support.
Well guide you through the right steps and get back within one business day.
</p> </p>
<form onSubmit={onSubmit} className="mt-8 space-y-5">
{/* Honeypot (hidden from users & screen readers) */}
<div aria-hidden className="hidden">
<label htmlFor="website">Website</label>
<input id="website" name="website" type="text" tabIndex={-1} autoComplete="off" />
</div>
{/* Time trap */}
<input type="hidden" name="startedAt" value={startedAt} readOnly />
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="name" className="text-sm font-medium">
Name<span className="text-red-600"> *</span>
</label>
<input
id="name"
name="name"
required
className="rounded-xl border bg-white px-3 py-2 outline-none ring-2 ring-transparent focus:ring-primary"
placeholder="Jane Doe"
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="email" className="text-sm font-medium">
Email<span className="text-red-600"> *</span>
</label>
<input
id="email"
name="email"
type="email"
required
className="rounded-xl border bg-white px-3 py-2 outline-none ring-2 ring-transparent focus:ring-primary"
placeholder="jane@company.com"
inputMode="email"
autoComplete="email"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="company" className="text-sm font-medium">
Company
</label>
<input
id="company"
name="company"
className="rounded-xl border bg-white px-3 py-2 outline-none ring-2 ring-transparent focus:ring-primary"
placeholder="Company B.V."
autoComplete="organization"
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="message" className="text-sm font-medium">
What do you need?<span className="text-red-600"> *</span>
</label>
<textarea
id="message"
name="message"
required
rows={6}
className="rounded-xl border bg-white px-3 py-2 outline-none ring-2 ring-transparent focus:ring-primary"
placeholder="Briefly describe the problem or goal. Links are optional."
/>
</div>
<div className="flex items-start justify-between gap-4">
<p className="text-xs text-muted-foreground">
By submitting, you agree we can contact you about your request. We dont sell data.
</p>
<button
type="submit"
disabled={status.state === "submitting" || disabledEarly}
className="inline-flex min-w-[9rem] items-center justify-center rounded-xl bg-primary px-4 py-2 text-primary-foreground transition-opacity disabled:cursor-not-allowed disabled:opacity-60"
>
{status.state === "submitting" ? "Sending..." : "Send"}
</button>
</div>
{status.state === "success" && (
<div role="status" className="rounded-xl border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
{status.message}
</div>
)}
{status.state === "error" && (
<div role="alert" className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{status.message}
</div>
)}
</form>
<div className="mt-10 text-sm text-muted-foreground">
Prefer email? Reach us at <a className="underline" href={`mailto:${site.contact.email}`}>{site.contact.email}</a>.
</div> </div>
</section> </section>
<section className="container mx-auto max-w-3xl px-6 py-12">
{/* Step 1 Select Request Type */}
<AnimatePresence mode="wait">
{step === 1 && (
<motion.div
key="step1"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<h2 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-6 text-center">
What kind of request do you have?
</h2>
<div className="grid gap-6 sm:grid-cols-2">
{requestTypes.map((t) => (
<button
key={t.id}
onClick={() => {
setForm((f) => ({ ...f, type: t.id }));
setStep(2);
}}
className={`rounded-xl border border-neutral-200 dark:border-neutral-800 p-6 text-left hover:shadow-md transition-all duration-200 ${
form.type === t.id
? "bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white"
: "bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
}`}
>
<div className="text-3xl mb-2">{t.icon}</div>
<h3 className="font-semibold text-lg mb-1">{t.label}</h3>
<p className="text-sm opacity-80">{t.desc}</p>
</button>
))}
</div>
</motion.div>
)}
{/* Step 2 Request Form */}
{step === 2 && (
<motion.form
key="step2"
onSubmit={handleSubmit}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="mt-4 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-sm p-8"
>
<h2 className="text-xl font-semibold mb-6 text-neutral-900 dark:text-white text-center">
{requestTypes.find((t) => t.id === form.type)?.label}
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1 text-neutral-800 dark:text-neutral-200">
Name
</label>
<input
type="text"
name="name"
value={form.name}
onChange={handleChange}
className="w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white/80 dark:bg-neutral-900/80 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-neutral-800 dark:text-neutral-200">
Email
</label>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
required
className="w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white/80 dark:bg-neutral-900/80 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Show domain and technical fields only for audits / consultations */}
{(form.type === "audit" || form.type === "consultation") && (
<>
<div>
<label className="block text-sm font-medium mb-1 text-neutral-800 dark:text-neutral-200">
Website or Domain
</label>
<input
type="text"
name="domain"
placeholder="example.com"
value={form.domain}
onChange={handleChange}
className="w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white/80 dark:bg-neutral-900/80 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-neutral-800 dark:text-neutral-200">
Main Concern
</label>
<select
name="concern"
value={form.concern}
onChange={handleChange}
className="w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white/80 dark:bg-neutral-900/80 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select one</option>
<option value="speed">Speed / Performance</option>
<option value="security">Security</option>
<option value="dns">DNS / Email Issues</option>
<option value="hosting">Hosting Migration</option>
<option value="other">Other</option>
</select>
</div>
</>
)}
<div>
<label className="block text-sm font-medium mb-1 text-neutral-800 dark:text-neutral-200">
Message / Details
</label>
<textarea
name="message"
rows={4}
value={form.message}
onChange={handleChange}
placeholder="Describe your request..."
className="w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white/80 dark:bg-neutral-900/80 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="mt-8 flex justify-between items-center">
<button
type="button"
onClick={() => setStep(1)}
className="text-sm text-neutral-600 dark:text-neutral-400 hover:underline"
>
Back
</button>
<button
type="submit"
disabled={loading}
className="rounded-lg bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white font-medium px-6 py-2 hover:opacity-90 transition"
>
{loading ? "Submitting..." : "Submit Request"}
</button>
</div>
</motion.form>
)}
</AnimatePresence>
</section>
</main>
); );
} }

View File

@ -1 +1,42 @@
"use client"; export default function GlobalError({error,reset}:{error:Error&{digest?:string},reset:()=>void}){return (<section className="container py-16 text-center"><h1 className="text-3xl font-semibold tracking-tight">Something went wrong</h1><p className="mt-2 text-muted-foreground">An unexpected error occurred. Please try again.</p><div className="mt-6 flex items-center justify-center gap-3"><button onClick={()=>reset()} className="btn-primary">Retry</button></div></section>);} "use client";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<main className="relative isolate min-h-screen flex items-center justify-center bg-gradient-to-b from-neutral-50 via-white to-neutral-100 dark:from-neutral-950 dark:via-neutral-900 dark:to-neutral-950">
<section className="container mx-auto max-w-lg px-6 py-16 text-center animate-[fadeInUp_0.6s_ease_forwards]">
<div className="mx-auto inline-flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-r from-red-500 via-pink-600 to-purple-600 text-white text-3xl shadow-lg">
</div>
<h1 className="mt-6 text-4xl font-bold tracking-tight bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 bg-clip-text text-transparent">
Something went wrong
</h1>
<p className="mt-4 text-neutral-700 dark:text-neutral-300">
An unexpected error occurred. Please try again.
</p>
{error?.digest && (
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-500">
Error ID: <code>{error.digest}</code>
</p>
)}
<div className="mt-8 flex items-center justify-center">
<button
onClick={() => reset()}
className="inline-flex items-center rounded-lg bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white px-6 py-2.5 text-sm font-medium hover:opacity-90 transition"
>
Retry
</button>
</div>
</section>
</main>
);
}

View File

@ -1,19 +1,254 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
export const metadata: Metadata = { title: "Free tools", description: "Quick checks for email, edge security headers and DNS health.", robots: { index: false, follow: true }, alternates: { canonical: "/free" } };
export const metadata: Metadata = {
title: "Free Tools & Audits — Van Hunen IT",
description:
"Run free reliability, DNS, and performance checks or request quick IT audits. Diagnose, optimize, and secure your website in minutes.",
robots: { index: true, follow: true },
alternates: { canonical: "/free" },
};
export default function FreeIndex() { export default function FreeIndex() {
const tools = [ const tools = [
{ title:"Email Auth Check (DMARC/SPF/DKIM)", excerpt:"See if DMARC, SPF and common DKIM selectors are configured.", href:"/free/email-check" }, {
{ title:"Edge Security Headers Check", excerpt:"Inspect HSTS, CSP, X-Frame-Options, Referrer-Policy and more.", href:"/free/edge-check" }, title: "Web Reliability Check",
{ title:"DNS Health Snapshot", excerpt:"Look up A/AAAA, MX, NS and CAA records at a glance.", href:"/free/dns-health" } excerpt:
"Test your websites uptime, SSL, and DNS performance — get an instant reliability grade (AF).",
href: "/free/web-reliability-check",
},
{
title: "DNS Health Analyzer",
excerpt:
"Check your domains DNS setup for missing SPF, DMARC, or CAA records and other critical issues.",
href: "/free/dns-health-analyzer",
},
{
title: "Website Speed Test (Lite)",
excerpt:
"Run a Lighthouse-based performance test and see how your site scores for speed, SEO, and accessibility.",
href: "/free/website-speed-test",
},
{
title: "Server Header Inspector",
excerpt:
"Check your sites security headers like HSTS, CSP, and Referrer-Policy to spot missing protections.",
href: "/free/server-header-inspector",
},
{
title: "Cloudflare Optimization Checker",
excerpt:
"Analyze if your Cloudflare settings are optimized for caching, SSL, and performance.",
href: "/free/cloudflare-checker",
},
{
title: "Uptime Monitor (Free Tier)",
excerpt:
"Set up free uptime monitoring for your website and receive downtime alerts instantly.",
href: "/free/uptime-monitor",
},
{
title: "SSL/TLS Expiry Tracker",
excerpt:
"Track when your SSL certificate expires and receive an automated renewal reminder.",
href: "/free/ssl-expiry-tracker",
},
{
title: "Website Backup Readiness Test",
excerpt:
"Check if your website is protected by regular automated backups and detect missing layers.",
href: "/free/backup-readiness-test",
},
{
title: "Email Deliverability Tester",
excerpt:
"Send a test email and verify SPF, DKIM, and DMARC for maximum inbox reliability.",
href: "/free/email-deliverability-tester",
},
{
title: "Docker Deployment Readiness Tool",
excerpt:
"Scan your public repository for Docker configuration quality and best practices.",
href: "/free/docker-readiness",
},
{
title: "Kubernetes Cost Estimator",
excerpt:
"Estimate infrastructure costs for your Kubernetes or k3s setup based on your resources.",
href: "/free/kubernetes-cost-estimator",
},
{
title: "Minecraft Server Performance Checker",
excerpt:
"Test TPS, latency, and plugin performance of your Minecraft server in seconds.",
href: "/free/minecraft-checker",
},
{
title: "Site Downtime Simulation Demo",
excerpt:
"Simulate downtime to visualize business impact and recovery time objectives.",
href: "/free/downtime-simulator",
},
{
title: "Automation ROI Calculator",
excerpt:
"See how much time and cost you could save by automating repetitive IT or admin tasks.",
href: "/free/automation-roi-calculator",
},
{
title: "VPS Performance Benchmark Tool",
excerpt:
"Run a quick benchmark on your VPS or server to test CPU, I/O, and disk performance.",
href: "/free/vps-benchmark",
},
]; ];
const services = [
{
title: "20-Minute Tech Health Check",
excerpt:
"A quick diagnostic session covering your hosting, DNS, SSL, and uptime setup.",
href: "/free/tech-health-check",
},
{
title: "DNS & Mail Deliverability Audit",
excerpt:
"Get a personalized DNS and email configuration audit to fix SPF, DKIM, and DMARC issues.",
href: "/free/dns-mail-audit",
},
{
title: "Website Speed & Optimization Preview",
excerpt:
"Receive a short performance report and improvement roadmap for your website.",
href: "/free/website-optimization-preview",
},
{
title: "Cloudflare Setup Review",
excerpt:
"Let us analyze your Cloudflare configuration for speed, caching, and SSL performance.",
href: "/free/cloudflare-review",
},
{
title: "Security Headers Checkup",
excerpt:
"Well scan your site headers for vulnerabilities and send a one-page hardening report.",
href: "/free/security-checkup",
},
{
title: "Backup & Recovery Simulation",
excerpt:
"Test your current backup setup and identify gaps in your disaster recovery plan.",
href: "/free/backup-simulation",
},
{
title: "Uptime Reliability Snapshot",
excerpt:
"Well monitor your domain for 24 hours and share an uptime report with insights.",
href: "/free/reliability-snapshot",
},
{
title: "Automation Opportunity Call",
excerpt:
"Tell us your weekly routines — well identify tasks that could be automated instantly.",
href: "/free/automation-call",
},
];
return ( return (
<section className="container py-12"> <main className="relative isolate min-h-screen bg-gradient-to-b from-neutral-50 via-white to-neutral-100 dark:from-neutral-950 dark:via-neutral-900 dark:to-neutral-950">
<h1 className="text-3xl font-semibold tracking-tight">Free tools</h1> {/* --- Header --- */}
<p className="mt-2 text-muted-foreground">Helpful checks you can run in seconds. No sign-up needed.</p> <section className="relative overflow-hidden border-b border-neutral-200 dark:border-neutral-800">
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="container mx-auto max-w-5xl px-6 py-20 text-center">
{tools.map((t)=>(<article key={t.href} className="card"><h2 className="text-lg font-semibold">{t.title}</h2><p className="mt-2 text-sm text-muted-foreground">{t.excerpt}</p><Link href={t.href} className="btn-primary mt-4 no-underline w-full text-center">Open</Link></article>))} <h1 className="text-4xl sm:text-5xl font-bold tracking-tight bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 bg-clip-text text-transparent">
Free Tools & Quick Audits
</h1>
<p className="mt-4 text-lg text-neutral-700 dark:text-neutral-300 max-w-2xl mx-auto">
Diagnose, benchmark, and optimize your IT setup with a collection of
free utilities and expert mini-audits.
No sign-up required, privacy-friendly, and built by{" "}
<span className="font-semibold">Van Hunen IT</span>.
</p>
</div> </div>
</section> </section>
{/* --- Automated Tools --- */}
<section className="container mx-auto max-w-6xl px-6 py-16">
<h2 className="text-2xl font-bold mb-10 text-neutral-900 dark:text-white text-center">
Automated Tools
</h2>
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{tools.map((t, i) => (
<article
key={t.href}
className="group relative overflow-hidden rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/70 dark:bg-neutral-900/70 p-8 shadow-sm hover:shadow-lg transition-all duration-300 backdrop-blur animate-[fadeInUp_0.6s_ease_forwards]"
style={{ animationDelay: `${i * 60}ms` }}
>
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/0 via-transparent to-purple-100/0 dark:from-blue-900/10 dark:to-purple-900/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
{t.title}
</h3>
<p className="mt-3 text-sm text-neutral-700 dark:text-neutral-300">
{t.excerpt}
</p>
<Link
href={t.href}
className="mt-6 inline-block w-full rounded-lg bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white text-center py-2.5 text-sm font-medium hover:opacity-90 transition"
>
Open Tool
</Link>
</article>
))}
</div>
</section>
{/* --- Free Audits & Services --- */}
<section className="container mx-auto max-w-6xl px-6 py-16 border-t border-neutral-200 dark:border-neutral-800">
<h2 className="text-2xl font-bold mb-10 text-neutral-900 dark:text-white text-center">
Free Audits & Quick Services
</h2>
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{services.map((s, i) => (
<article
key={s.href}
className="group relative overflow-hidden rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/70 dark:bg-neutral-900/70 p-8 shadow-sm hover:shadow-lg transition-all duration-300 backdrop-blur animate-[fadeInUp_0.6s_ease_forwards]"
style={{ animationDelay: `${i * 80}ms` }}
>
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/0 via-transparent to-purple-100/0 dark:from-blue-900/10 dark:to-purple-900/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
{s.title}
</h3>
<p className="mt-3 text-sm text-neutral-700 dark:text-neutral-300">
{s.excerpt}
</p>
<Link
href={s.href}
className="mt-6 inline-block w-full rounded-lg bg-gradient-to-r from-purple-600 via-blue-600 to-purple-600 text-white text-center py-2.5 text-sm font-medium hover:opacity-90 transition"
>
Request
</Link>
</article>
))}
</div>
</section>
{/* --- CTA Banner --- */}
<section className="border-t border-neutral-200 dark:border-neutral-800 bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white py-16 mt-12 text-center">
<div className="container mx-auto max-w-4xl px-6">
<h2 className="text-2xl sm:text-3xl font-bold mb-4">
Not Sure Where to Start?
</h2>
<p className="text-base sm:text-lg text-white/90 mb-6">
Book a free 15-minute consultation and well help you pick the right
checks or services for your setup.
</p>
<Link
href="/contact?topic=consultation"
className="inline-block rounded-lg bg-white text-blue-700 font-semibold py-3 px-6 shadow hover:shadow-md transition"
>
Book Free Call
</Link>
</div>
</section>
</main>
); );
} }

View File

@ -10,3 +10,14 @@ a{@apply underline-offset-2} .btn-primary{@apply inline-flex items-center justif
@media (prefers-reduced-motion: reduce){*{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}} @media (prefers-reduced-motion: reduce){*{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
.skip-link{position:absolute;left:-9999px;top:0;z-index:9999;padding:.5rem .75rem;background:hsl(var(--primary));color:hsl(var(--primary-foreground));border-radius:.75rem} .skip-link{position:absolute;left:-9999px;top:0;z-index:9999;padding:.5rem .75rem;background:hsl(var(--primary));color:hsl(var(--primary-foreground));border-radius:.75rem}
.skip-link:focus-visible{left:.5rem;top:.5rem;outline:0} .skip-link:focus-visible{left:.5rem;top:.5rem;outline:0}
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -1 +1,36 @@
import Link from 'next/link'; export default function NotFound(){return (<section className='container py-16 text-center'><h1 className='text-3xl font-semibold tracking-tight'>Page not found</h1><p className='mt-2 text-muted-foreground'>The page youre looking for doesnt exist.</p><div className='mt-6 flex items-center justify-center gap-3'><Link href='/' className='btn-primary no-underline'>Go home</Link><Link href='/services' className='no-underline rounded-xl border px-5 py-3'>Browse services</Link></div></section>);} import Link from "next/link";
export default function NotFound() {
return (
<main className="relative isolate min-h-screen flex items-center justify-center bg-gradient-to-b from-neutral-50 via-white to-neutral-100 dark:from-neutral-950 dark:via-neutral-900 dark:to-neutral-950">
<section className="container mx-auto max-w-lg px-6 py-16 text-center animate-[fadeInUp_0.6s_ease_forwards]">
<div className="mx-auto inline-flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white text-3xl shadow-lg">
404
</div>
<h1 className="mt-6 text-4xl font-bold tracking-tight bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 bg-clip-text text-transparent">
Page not found
</h1>
<p className="mt-4 text-neutral-700 dark:text-neutral-300">
The page youre looking for doesnt exist or has been moved.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-4">
<Link
href="/"
className="inline-flex items-center rounded-lg bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white px-6 py-2.5 text-sm font-medium hover:opacity-90 transition no-underline"
>
Go home
</Link>
<Link
href="/services"
className="inline-flex items-center rounded-lg border border-neutral-300 dark:border-neutral-700 px-6 py-2.5 text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition no-underline"
>
Browse services
</Link>
</div>
</section>
</main>
);
}

View File

@ -1,15 +1,15 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Hero from "@/components/Hero"; import Hero from "@/components/Hero";
import ServiceCards from "@/components/ServiceCards";
import Process from "@/components/Process"; import Process from "@/components/Process";
import Pricing from "@/components/Pricing"; import Pricing from "@/components/Pricing";
import Testimonials from "@/components/Testimonials"; import Testimonials from "@/components/Testimonials";
import FAQ from "@/components/FAQ"; import FAQ from "@/components/FAQ";
import CTA from "@/components/CTA"; import CTA from "@/components/CTA";
import { services } from "@/lib/services"; import { SERVICE_CATEGORIES } from "@/lib/services";
import { plans } from "@/lib/pricing"; import { plans } from "@/lib/pricing";
import { site } from "@/lib/site"; import { site } from "@/lib/site";
import JsonLd from "@/components/JsonLd"; import JsonLd from "@/components/JsonLd";
import Link from "next/link";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Productized IT for SMBs", title: "Productized IT for SMBs",
@ -23,7 +23,7 @@ export default function HomePage() {
name: site.name, name: site.name,
url: site.url, url: site.url,
logo: site.org.logo, logo: site.org.logo,
sameAs: site.org.sameAs sameAs: site.org.sameAs,
}; };
const faqLd = { const faqLd = {
@ -33,36 +33,157 @@ export default function HomePage() {
{ {
"@type": "Question", "@type": "Question",
name: "How fast can you start?", name: "How fast can you start?",
acceptedAnswer: { "@type": "Answer", text: "Most sprints start within 23 business days after scope confirmation." } acceptedAnswer: {
"@type": "Answer",
text: "Most sprints start within 23 business days after scope confirmation.",
},
}, },
{ {
"@type": "Question", "@type": "Question",
name: "Do you work under NDA?", name: "Do you work under NDA?",
acceptedAnswer: { "@type": "Answer", text: "Yes—mutual NDA available on request; we keep credentials least-privilege." } acceptedAnswer: {
"@type": "Answer",
text: "Yes—mutual NDA available on request; we keep credentials least-privilege.",
},
}, },
{ {
"@type": "Question", "@type": "Question",
name: "Can we switch providers later?", name: "Can we switch providers later?",
acceptedAnswer: { "@type": "Answer", text: "Yes. Everything is documented; you own the accounts and artifacts." } acceptedAnswer: {
} "@type": "Answer",
] text: "Yes. Everything is documented; you own the accounts and artifacts.",
},
},
],
};
// Service category summaries
const categoryDescriptions: Record<string, string> = {
"infrastructure-devops":
"Reliable servers, containers, and automation—Docker, Kubernetes, Cloudflare, and backups that just work.",
"web-performance":
"Make your website fast, secure, and consistently available. Core Web Vitals, deliverability, and uptime care.",
"dev-platforms":
"Private Git hosting, CI/CD, staging environments, and secrets management—developer efficiency built in.",
"migrations":
"Move from legacy hosting or apps to modern, containerized infrastructure with zero-to-minimal downtime.",
"minecraft":
"Managed Minecraft servers, plugin development, and performance tuning for creators and communities.",
"web-dev":
"Modern websites and headless CMS setups designed for speed, SEO, and maintainability.",
}; };
return ( return (
<> <main className="relative isolate min-h-screen bg-gradient-to-b from-neutral-50 via-white to-neutral-100 dark:from-neutral-950 dark:via-neutral-900 dark:to-neutral-950">
<Hero /> <Hero />
<ServiceCards services={services} />
{/* --- Service Areas --- */}
<section className="relative overflow-hidden border-b border-neutral-200 dark:border-neutral-800">
<div className="container mx-auto max-w-6xl px-4 py-20 text-center">
<h2 className="text-4xl font-bold tracking-tight sm:text-5xl bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 bg-clip-text text-transparent">
Service Areas
</h2>
<p className="mt-5 text-lg text-neutral-700 dark:text-neutral-300 max-w-2xl mx-auto">
Fixed-scope sprints and managed plans for infrastructure, performance, and development.
Each category leads to specialized services tailored to small and mid-sized teams.
</p>
</div>
<div className="container mx-auto max-w-6xl px-4 pb-20">
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Object.entries(SERVICE_CATEGORIES).map(([id, cat], i) => (
<article
key={id}
className="group relative overflow-hidden rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/60 dark:bg-neutral-900/60 p-8 shadow-sm hover:shadow-lg transition-all duration-300 backdrop-blur"
style={{ animation: `fadeInUp 0.6s ease forwards`, animationDelay: `${i * 80}ms` }}
>
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/0 via-transparent to-purple-100/0 dark:from-blue-900/10 dark:to-purple-900/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<h3 className="text-xl font-semibold tracking-tight text-neutral-900 dark:text-white">
{cat.label}
</h3>
<p className="mt-3 text-sm text-neutral-700 dark:text-neutral-300">
{categoryDescriptions[id] ?? ""}
</p>
<Link
href={`/services#${cat.anchor}`}
className="mt-5 inline-flex items-center text-sm font-medium text-blue-600 hover:underline"
>
Explore services
</Link>
</article>
))}
</div>
<div className="mt-14 text-center">
<Link
href="/services"
className="inline-flex items-center rounded-lg bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white px-6 py-3 text-sm font-medium hover:opacity-90 transition"
>
View all services
</Link>
</div>
</div>
</section>
{/* --- Process --- */}
<Process /> <Process />
{/* --- Pricing --- */}
<section className="relative py-24 bg-gradient-to-br from-blue-50/40 via-white to-purple-50/40 dark:from-blue-950/10 dark:to-purple-950/10">
<div className="container mx-auto max-w-6xl px-4">
<Pricing plans={plans} /> <Pricing plans={plans} />
</div>
</section>
{/* --- Testimonials --- */}
<section className="relative py-24 border-t border-neutral-200 dark:border-neutral-800">
<div className="container mx-auto max-w-6xl px-4">
<Testimonials /> <Testimonials />
<FAQ items={[ </div>
{ q: "How fast can you start?", a: "Most sprints start within 23 business days after scope confirmation." }, </section>
{ q: "Do you work under NDA?", a: "Yes—mutual NDA available on request; we keep credentials least-privilege." },
{ q: "Whats your guarantee?", a: "We show proof of outcomes. If scope isnt met, we make it right or refund the sprint fee." } {/* --- FAQ --- */}
]} /> <section className="relative py-24 bg-gradient-to-b from-neutral-50 via-white to-neutral-100 dark:from-neutral-950 dark:via-neutral-900 dark:to-neutral-950">
<CTA /> <div className="container mx-auto max-w-4xl px-4">
<FAQ
items={[
{
q: "How fast can you start?",
a: "Most sprints start within 23 business days after scope confirmation.",
},
{
q: "Do you work under NDA?",
a: "Yes—mutual NDA available on request; we keep credentials least-privilege.",
},
{
q: "Whats your guarantee?",
a: "We show proof of outcomes. If scope isnt met, we make it right or refund the sprint fee.",
},
]}
/>
</div>
</section>
{/* --- Call to Action --- */}
<section className="relative py-24 text-center bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white">
<div className="container mx-auto max-w-4xl px-6">
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight">
Ready to make your IT infrastructure reliable and scalable?
</h2>
<p className="mt-4 text-lg text-blue-100">
Talk with Van Hunen IT get a fixed-scope sprint or managed plan that fits your business.
</p>
<Link
href="/contact"
className="mt-8 inline-flex items-center rounded-lg bg-white text-blue-700 font-semibold px-6 py-3 hover:bg-blue-50 transition"
>
Contact Us
</Link>
</div>
</section>
<JsonLd data={orgLd} /> <JsonLd data={orgLd} />
<JsonLd data={faqLd} /> <JsonLd data={faqLd} />
</> </main>
); );
} }

View File

@ -1,40 +1,119 @@
import type { Metadata } from "next"; import { Metadata } from "next";
import { getAllServices, SERVICE_CATEGORIES, type ServiceCategoryId } from "@/lib/services";
import ServiceCard from "@/components/ServiceCard";
import Link from "next/link"; import Link from "next/link";
import { services } from "@/lib/services";
import JsonLd from "@/components/JsonLd"; export const revalidate = 86400;
import { site } from "@/lib/site";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Services", title: "Services — Van Hunen IT",
description: "Fixed-scope sprints and managed plans for email, Cloudflare, web and ops.", description:
alternates: { canonical: "/services" } "Explore Van Hunen IT's fixed-scope sprints and managed solutions — from VPS hardening and Docker orchestration to Kubernetes, Cloudflare, Core Web Vitals, and Minecraft operations.",
alternates: { canonical: "/services" },
}; };
export default function ServicesIndex() { export default function ServicesPage() {
const collectionLd = { const services = getAllServices();
"@context": "https://schema.org", const categories: ServiceCategoryId[] = Object.keys(SERVICE_CATEGORIES) as ServiceCategoryId[];
"@type": "CollectionPage",
name: "Services",
url: `${site.url}/services`,
hasPart: services.map((s) => ({ "@type": "Service", name: s.title, description: s.excerpt, url: `${site.url}/services/${s.slug}` }))
};
return ( return (
<section className="container py-12"> <main className="relative isolate min-h-screen bg-gradient-to-b from-neutral-50 via-white to-neutral-100 dark:from-neutral-950 dark:via-neutral-900 dark:to-neutral-950">
<h1 className="text-3xl font-semibold tracking-tight">Services</h1> {/* --- Hero Header --- */}
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <section className="relative overflow-hidden border-b border-neutral-200 dark:border-neutral-800">
{services.map(s => ( <div className="container mx-auto max-w-6xl px-4 py-20 text-center">
<article key={s.slug} className="card"> <h1 className="text-4xl font-bold tracking-tight sm:text-5xl bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 bg-clip-text text-transparent">
<div className="text-xs uppercase tracking-wide text-accent">{s.category}</div> Professional IT Services
<h3 className="mt-1 text-lg font-semibold">{s.title}</h3> </h1>
<p className="mt-2 text-sm text-muted-foreground">{s.excerpt}</p> <p className="mt-5 text-lg text-neutral-700 dark:text-neutral-300 max-w-2xl mx-auto">
<div className="mt-4 flex items-center justify-between"> Fixed-scope sprints and managed plans for{" "}
<span className="text-sm font-semibold">{s.price}</span> <strong>VPS</strong>, <strong>Docker</strong>, <strong>Kubernetes</strong>,{" "}
<Link className="no-underline rounded-xl border px-3 py-2 text-sm" href={`/services/${s.slug}`}>View</Link> <strong>Cloudflare</strong>, <strong>Core Web Vitals</strong>, and{" "}
</div> <strong>Minecraft</strong>. Clear outcomes, flat pricing, and proof you can keep.
</article> </p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
{categories.map((id) => (
<a
key={id}
href={`#${SERVICE_CATEGORIES[id].anchor}`}
className="rounded-full border border-neutral-300 dark:border-neutral-700 px-4 py-1.5 text-sm font-medium text-neutral-700 dark:text-neutral-300 hover:border-blue-500 hover:text-blue-600 dark:hover:text-blue-400 transition"
>
{SERVICE_CATEGORIES[id].label}
</a>
))} ))}
</div> </div>
<JsonLd data={collectionLd} /> </div>
</section>
{/* --- Service Categories --- */}
<div className="container mx-auto max-w-6xl px-4 py-16">
{categories.map((id, index) => {
const list = services.filter((s) => s.category === id);
if (!list.length) return null;
return (
<section
key={id}
id={SERVICE_CATEGORIES[id].anchor}
aria-labelledby={`${id}-heading`}
className={`relative py-16 scroll-mt-24 ${
index % 2 === 0
? "bg-white dark:bg-neutral-900/50"
: "bg-neutral-50 dark:bg-neutral-900/30"
} rounded-2xl mb-12 shadow-sm`}
>
<div className="absolute inset-0 -z-10 bg-gradient-to-br from-blue-50/10 via-transparent to-purple-50/10 dark:from-blue-950/10 dark:to-purple-950/10 rounded-2xl" />
<div className="px-4 sm:px-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
<h2
id={`${id}-heading`}
className="text-2xl sm:text-3xl font-bold tracking-tight bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent"
>
{SERVICE_CATEGORIES[id].label}
</h2>
<Link
href={`/services#${SERVICE_CATEGORIES[id].anchor}`}
className="mt-3 sm:mt-0 inline-flex items-center text-sm font-medium text-blue-600 hover:underline dark:text-blue-400"
>
Jump to top
</Link>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{list.map((svc, i) => (
<div
key={svc.slug}
className="animate-[fadeInUp_0.6s_ease_forwards]"
style={{ animationDelay: `${i * 80}ms` }}
>
<ServiceCard svc={svc} />
</div>
))}
</div>
</div>
</section> </section>
); );
})}
</div>
{/* --- CTA footer --- */}
<section className="relative py-24 text-center bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white">
<div className="container mx-auto max-w-4xl px-6">
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight">
Ready to optimize your IT infrastructure?
</h2>
<p className="mt-4 text-lg text-blue-100">
Get in touch to discuss your goals well help you choose the right sprint or managed plan.
</p>
<Link
href="/contact"
className="mt-8 inline-flex items-center rounded-lg bg-white text-blue-700 font-semibold px-6 py-3 hover:bg-blue-50 transition"
>
Contact Us
</Link>
</div>
</section>
</main>
);
} }

View File

@ -2,15 +2,25 @@ import Link from "next/link";
export default function CTA() { export default function CTA() {
return ( return (
<section className="container py-14"> <section className="relative py-24 text-center bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white">
<div className="card flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="container mx-auto max-w-3xl px-6">
<div> <h3 className="text-3xl font-bold tracking-tight">Ready to ship a fix or start care?</h3>
<h3 className="text-xl font-semibold">Ready to ship a fix or start care?</h3> <p className="mt-3 text-lg text-blue-100">
<p className="text-sm text-muted-foreground">Book a free 20-minute check. Clear scope, then we execute.</p> Book a free 20-minute check. Clear scope, then we execute.
</div> </p>
<div className="flex gap-3"> <div className="mt-8 flex flex-wrap justify-center gap-4">
<Link href="/contact" className="btn-primary no-underline">Book free check</Link> <Link
<Link href="/services" className="no-underline rounded-xl border px-5 py-3">Browse services</Link> href="/contact"
className="inline-flex items-center rounded-lg bg-white text-blue-700 font-semibold px-6 py-3 hover:bg-blue-50 transition"
>
Book free check
</Link>
<Link
href="/services"
className="inline-flex items-center rounded-lg border border-white/70 px-6 py-3 text-white font-medium hover:bg-white/10 transition"
>
Browse services
</Link>
</div> </div>
</div> </div>
</section> </section>

View File

@ -2,16 +2,26 @@ type QA = { q: string; a: string };
export default function FAQ({ items }: { items: QA[] }) { export default function FAQ({ items }: { items: QA[] }) {
return ( return (
<section className="container py-14"> <section className="relative py-24 bg-gradient-to-b from-neutral-50 via-white to-neutral-100 dark:from-neutral-950 dark:via-neutral-900 dark:to-neutral-950">
<h2 className="text-2xl font-semibold tracking-tight">FAQ</h2> <div className="container mx-auto max-w-4xl px-4">
<div className="mt-6 grid gap-4"> <h2 className="text-3xl font-bold tracking-tight bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent text-center">
Frequently Asked Questions
</h2>
<div className="mt-10 space-y-4">
{items.map((x, i) => ( {items.map((x, i) => (
<details key={i} className="rounded-2xl border p-5"> <details
<summary className="cursor-pointer text-base font-medium">{x.q}</summary> key={i}
<p className="mt-2 text-sm text-muted-foreground">{x.a}</p> className="group rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/70 dark:bg-neutral-900/70 p-5 shadow-sm hover:shadow-md transition"
>
<summary className="cursor-pointer text-lg font-medium text-neutral-900 dark:text-white flex items-center justify-between">
{x.q}
<span className="transition-transform group-open:rotate-180"></span>
</summary>
<p className="mt-3 text-sm text-neutral-700 dark:text-neutral-300">{x.a}</p>
</details> </details>
))} ))}
</div> </div>
</div>
</section> </section>
); );
} }

View File

@ -1,27 +1,52 @@
import Link from "next/link"; import Link from "next/link";
export default function Footer() { export default function Footer() {
return ( return (
<footer className="border-t"> <footer className="relative border-t border-neutral-200 dark:border-neutral-800 bg-gradient-to-b from-white via-neutral-50 to-neutral-100 dark:from-neutral-950 dark:via-neutral-900 dark:to-neutral-950">
<div className="container grid gap-6 py-10 sm:grid-cols-3"> <div className="container grid gap-8 py-16 sm:grid-cols-3">
<div> <div>
<div className="text-lg font-semibold">Van Hunen IT</div> <div className="text-xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 bg-clip-text text-transparent">
<p className="mt-2 text-sm text-muted-foreground">Fixes in days. Uptime for months.</p> Van Hunen IT
</div> </div>
<p className="mt-3 text-sm text-neutral-600 dark:text-neutral-400">
Fixes in days. Uptime for months.
</p>
</div>
<div> <div>
<div className="text-sm font-semibold">Links</div> <div className="text-sm font-semibold text-neutral-900 dark:text-white">Links</div>
<ul className="mt-2 space-y-1 text-sm"> <ul className="mt-3 space-y-1 text-sm">
<li><Link href="/services" className="no-underline hover:underline">Services</Link></li> {[
<li><Link href="/free" className="no-underline hover:underline">Free tools</Link></li> { href: "/services", label: "Services" },
<li><Link href="/contact" className="no-underline hover:underline">Contact</Link></li> { href: "/free", label: "Free tools" },
<li><Link href="/privacy" className="no-underline hover:underline">Privacy</Link></li> { href: "/contact", label: "Contact" },
<li><Link href="/terms" className="no-underline hover:underline">Terms</Link></li> { href: "/privacy", label: "Privacy" },
<li><Link href="/cookie" className="no-underline hover:underline">Cookie</Link></li> { href: "/terms", label: "Terms" },
{ href: "/cookie", label: "Cookie" },
].map((l) => (
<li key={l.href}>
<Link
href={l.href}
className="no-underline text-neutral-700 dark:text-neutral-300 hover:underline"
>
{l.label}
</Link>
</li>
))}
</ul> </ul>
</div> </div>
<div> <div>
<div className="text-sm font-semibold">Contact</div> <div className="text-sm font-semibold text-neutral-900 dark:text-white">Contact</div>
<p className="mt-2 text-sm text-muted-foreground">Email: <a href="mailto:hello@vanhunen.it" className="underline">hello@vanhunen.it</a></p> <p className="mt-3 text-sm text-neutral-600 dark:text-neutral-400">
<p className="mt-2 text-xs text-muted-foreground">© {new Date().getFullYear()} Van Hunen IT. All rights reserved.</p> Email:{" "}
<a href="mailto:hello@vanhunen.it" className="underline">
hello@vanhunen.it
</a>
</p>
<p className="mt-3 text-xs text-neutral-500 dark:text-neutral-500">
© {new Date().getFullYear()} Van Hunen IT. All rights reserved.
</p>
</div> </div>
</div> </div>
</footer> </footer>

View File

@ -1,17 +1,35 @@
import Link from "next/link"; import Link from "next/link";
export default function Header() { export default function Header() {
return ( return (
<header className="sticky top-0 z-40 w-full border-b bg-background/80 backdrop-blur"> <header className="sticky top-0 z-50 w-full border-b border-neutral-200 dark:border-neutral-800 bg-white/80 dark:bg-neutral-950/70 backdrop-blur-md">
<div className="container flex items-center justify-between py-4"> <div className="container flex items-center justify-between py-4">
<Link href="/" className="flex items-center gap-2 no-underline"> <Link href="/" className="flex items-center gap-2 no-underline">
<span className="text-lg font-semibold">Van Hunen IT</span> <span className="text-lg font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 bg-clip-text text-transparent">
Van Hunen IT
</span>
</Link>
<nav aria-label="Main" className="flex items-center gap-1 text-sm">
{[
{ href: "/services", label: "Services" },
{ href: "/free", label: "Free tools" },
{ href: "/contact", label: "Contact" },
].map((item) => (
<Link
key={item.href}
href={item.href}
className="no-underline rounded-lg px-3 py-2 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition"
>
{item.label}
</Link> </Link>
<nav aria-label="Main" className="flex items-center gap-2 text-sm">
{[{ href: "/services", label: "Services" },{ href: "/free", label: "Free tools" },{ href: "/contact", label: "Contact" },{ href: "/privacy", label: "Privacy" },{ href: "/terms", label: "Terms" }].map((item) => (
<Link key={item.href} href={item.href} className="no-underline rounded-lg px-3 py-2 hover:bg-muted focus-visible:bg-muted transition">{item.label}</Link>
))} ))}
</nav> </nav>
<Link href="/contact" className="btn-primary no-underline">Start now</Link> <Link
href="/contact"
className="hidden sm:inline-flex items-center rounded-lg bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white px-4 py-2 text-sm font-medium hover:opacity-90 transition"
>
Start now
</Link>
</div> </div>
</header> </header>
); );

View File

@ -2,29 +2,40 @@ import Link from "next/link";
export default function Hero() { export default function Hero() {
return ( return (
<section className="container py-14 sm:py-20"> <section className="relative overflow-hidden border-b border-neutral-200 dark:border-neutral-800 bg-gradient-to-b from-neutral-50 via-white to-neutral-100 dark:from-neutral-950 dark:via-neutral-900 dark:to-neutral-950">
<div className="grid items-center gap-8 sm:grid-cols-2"> <div className="container mx-auto grid items-center gap-12 py-24 sm:grid-cols-2 px-6">
<div> <div>
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl"> <h1 className="text-5xl sm:text-6xl font-extrabold tracking-tight leading-tight bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 bg-clip-text text-transparent">
Fixes in days. <span className="text-primary">Uptime</span> for months. Fixes in days. <br /> <span className="text-blue-600 dark:text-blue-400">Uptime</span> for months.
</h1> </h1>
<p className="mt-4 text-lg text-muted-foreground"> <p className="mt-6 text-lg text-neutral-700 dark:text-neutral-300 max-w-lg">
Productized IT for small businesses: inbox-ready email, secure Cloudflare/DNS, Productized IT for small businesses: inbox-ready email, secure Cloudflare & DNS, and website care
and website careclear scope and flat pricing. clear scope, flat pricing, and measurable proof.
</p> </p>
<div className="mt-6 flex gap-3"> <div className="mt-8 flex flex-wrap gap-4">
<Link href="/contact" className="btn-primary no-underline">Start now</Link> <Link
<Link href="/services" className="no-underline rounded-xl border px-5 py-3">See services</Link> href="/contact"
className="inline-flex items-center rounded-lg bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white px-6 py-3 text-sm font-medium hover:opacity-90 transition"
>
Start now
</Link>
<Link
href="/services"
className="inline-flex items-center rounded-lg border border-neutral-300 dark:border-neutral-700 px-6 py-3 text-sm font-medium hover:bg-neutral-50 dark:hover:bg-neutral-800 transition"
>
See services
</Link>
</div> </div>
</div> </div>
<div className="card">
<ul className="space-y-3 text-sm"> <div className="rounded-2xl bg-white/70 dark:bg-neutral-900/70 backdrop-blur p-8 shadow-lg border border-neutral-200 dark:border-neutral-800 animate-[fadeInUp_0.8s_ease_forwards]">
<ul className="space-y-3 text-sm text-neutral-800 dark:text-neutral-200">
<li> DMARC aligned, spoofing blocked</li> <li> DMARC aligned, spoofing blocked</li>
<li> Cloudflare WAF & HTTP/3, faster TTFB</li> <li> Cloudflare WAF & HTTP/3, faster TTFB</li>
<li> Daily backups with restore tests</li> <li> Daily backups with restore tests</li>
<li> Uptime watch + incident notes</li> <li> Uptime watch + incident notes</li>
</ul> </ul>
<div className="mt-5 rounded-xl bg-muted p-4 text-sm text-muted-foreground"> <div className="mt-6 rounded-xl bg-neutral-100 dark:bg-neutral-800 p-4 text-sm text-neutral-600 dark:text-neutral-300">
We agree scope up front and show proof: before/after reports and restore tests. We agree scope up front and show proof: before/after reports and restore tests.
</div> </div>
</div> </div>

View File

@ -3,25 +3,46 @@ import type { Plan } from "@/lib/pricing";
export default function Pricing({ plans }: { plans: Plan[] }) { export default function Pricing({ plans }: { plans: Plan[] }) {
return ( return (
<section className="container py-14"> <section className="relative py-24 border-t border-neutral-200 dark:border-neutral-800 bg-gradient-to-b from-white via-neutral-50 to-white dark:from-neutral-950 dark:via-neutral-900 dark:to-neutral-950">
<h2 className="text-2xl font-semibold tracking-tight">Simple, flat pricing</h2> <div className="container mx-auto max-w-6xl px-4 text-center">
<div className="mt-6 grid gap-6 sm:grid-cols-3"> <h2 className="text-3xl font-bold tracking-tight bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
{plans.map((p) => ( Simple, flat pricing
<div key={p.id} className={`card ${p.popular ? "ring-2 ring-primary" : ""}`}> </h2>
{p.popular && <div className="mb-2 inline-block rounded-full bg-primary px-3 py-1 text-xs text-primary-foreground">Most popular</div>} <div className="mt-12 grid gap-8 sm:grid-cols-3">
<div className="text-lg font-semibold">{p.name}</div> {plans.map((p, i) => (
<div className="mt-2 flex items-baseline gap-1"> <div
<div className="text-3xl font-bold">{p.price}</div> key={p.id}
{p.periodicity && <div className="text-sm text-muted-foreground">{p.periodicity}</div>} className={`rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/70 dark:bg-neutral-900/70 p-8 shadow-sm hover:shadow-lg transition backdrop-blur animate-[fadeInUp_0.6s_ease_forwards]`}
style={{ animationDelay: `${i * 100}ms` }}
>
{p.popular && (
<div className="mb-3 inline-block rounded-full bg-gradient-to-r from-blue-600 to-purple-600 px-3 py-1 text-xs text-white">
Most popular
</div> </div>
<p className="mt-2 text-sm text-muted-foreground">{p.description}</p> )}
<ul className="mt-4 space-y-2 text-sm"> <div className="text-xl font-semibold">{p.name}</div>
{p.features.map((f, i) => <li key={i}> {f}</li>)} <div className="mt-3 flex items-baseline justify-center gap-1">
<div className="text-4xl font-bold text-neutral-900 dark:text-white">{p.price}</div>
{p.periodicity && (
<div className="text-sm text-neutral-500 dark:text-neutral-400">{p.periodicity}</div>
)}
</div>
<p className="mt-3 text-sm text-neutral-600 dark:text-neutral-300">{p.description}</p>
<ul className="mt-5 space-y-2 text-sm text-neutral-700 dark:text-neutral-300 text-left">
{p.features.map((f, idx) => (
<li key={idx}> {f}</li>
))}
</ul> </ul>
<Link href={p.cta.href} className="btn-primary mt-6 no-underline w-full text-center">{p.cta.label}</Link> <Link
href={p.cta.href}
className="mt-8 inline-block w-full rounded-lg bg-gradient-to-r from-blue-600 to-purple-600 text-white py-3 text-sm font-medium hover:opacity-90 transition"
>
{p.cta.label}
</Link>
</div> </div>
))} ))}
</div> </div>
</div>
</section> </section>
); );
} }

View File

@ -1,21 +1,32 @@
export default function Process() { export default function Process() {
const steps = [ const steps = [
{ t: "Free check", d: "We assess scope, risks and impact in 2030 minutes." }, { t: "Free check", d: "We assess scope, risks and impact in 2030 minutes." },
{ t: "Sprint or managed", d: "Pick a fixed sprint or a managed plan with clear outcomes." }, { t: "Sprint or managed", d: "Pick a fixed sprint or managed plan with clear outcomes." },
{ t: "Proof", d: "We deliver and prove it: reports, tests, and notes you keep." }, { t: "Proof", d: "We deliver and prove it: reports, tests, and notes you keep." },
]; ];
return ( return (
<section className="container py-10"> <section className="relative py-24 bg-gradient-to-br from-blue-50/30 via-white to-purple-50/30 dark:from-blue-950/10 dark:to-purple-950/10">
<h2 className="text-2xl font-semibold tracking-tight">How we work</h2> <div className="container mx-auto max-w-6xl px-4 text-center">
<div className="mt-6 grid gap-6 sm:grid-cols-3"> <h2 className="text-3xl font-bold tracking-tight bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
How we work
</h2>
<div className="mt-12 grid gap-8 sm:grid-cols-3">
{steps.map((s, i) => ( {steps.map((s, i) => (
<div key={i} className="card"> <div
<div className="h-10 w-10 rounded-xl bg-primary text-primary-foreground grid place-items-center font-semibold">{i+1}</div> key={i}
<div className="mt-3 text-lg font-semibold">{s.t}</div> className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/60 dark:bg-neutral-900/60 p-8 shadow-sm hover:shadow-md transition backdrop-blur animate-[fadeInUp_0.6s_ease_forwards]"
<p className="mt-1 text-sm text-muted-foreground">{s.d}</p> style={{ animationDelay: `${i * 120}ms` }}
>
<div className="mx-auto h-10 w-10 rounded-xl bg-gradient-to-r from-blue-600 to-purple-600 text-white grid place-items-center font-semibold">
{i + 1}
</div>
<div className="mt-4 text-lg font-semibold text-neutral-900 dark:text-white">{s.t}</div>
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-300">{s.d}</p>
</div> </div>
))} ))}
</div> </div>
</div>
</section> </section>
); );
} }

View File

@ -29,7 +29,7 @@ export default function ServiceCard({ svc }: { svc: Service }) {
</Link> </Link>
<Link <Link
href={`/contact?topic=${encodeURIComponent(svc.slug)}`} href={`/contact?topic=${encodeURIComponent(svc.slug)}`}
className="inline-flex items-center rounded-md bg-black text-white px-3 py-1.5 text-sm hover:opacity-90" className="inline-flex items-center rounded-md bg-blue-600 text-white px-3 py-1.5 text-sm hover:opacity-90"
> >
Start now Start now
</Link> </Link>

View File

@ -1,9 +1,12 @@
// path: components/Testimonials.tsx
export default function Testimonials() { export default function Testimonials() {
return ( return (
<section className="py-16 text-center"> <section className="relative py-24 bg-gradient-to-b from-neutral-50 via-white to-neutral-100 dark:from-neutral-950 dark:via-neutral-900 dark:to-neutral-950 text-center">
<h2 className="text-2xl font-semibold mb-4">What clients say</h2> <h2 className="text-3xl font-bold tracking-tight bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
<p className="text-gray-600">Testimonials coming soon.</p> What clients say
</h2>
<p className="mt-6 text-neutral-600 dark:text-neutral-300">
Testimonials coming soon.
</p>
</section> </section>
); );
} }

View File

@ -5,4 +5,8 @@ export const site = {
description: description:
"Inbox-ready email, Cloudflare edge security, and website care for small businesses—clear scope, flat pricing, and proof of outcomes.", "Inbox-ready email, Cloudflare edge security, and website care for small businesses—clear scope, flat pricing, and proof of outcomes.",
contact: { email: "hello@vanhunen.it" }, contact: { email: "hello@vanhunen.it" },
org: {
logo: "/og.png",
sameAs: ["https://www.linkedin.com/company/van-hunen-it"],
},
} as const; } as const;

View File

@ -16,9 +16,11 @@
"node": ">=20 <23" "node": ">=20 <23"
}, },
"dependencies": { "dependencies": {
"framer-motion": "^12.23.24",
"next": "14.2.13", "next": "14.2.13",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1" "react-dom": "18.3.1",
"sonner": "^2.0.7"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "24.9.1", "@types/node": "24.9.1",

View File

@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
framer-motion:
specifier: ^12.23.24
version: 12.23.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: next:
specifier: 14.2.13 specifier: 14.2.13
version: 14.2.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 14.2.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -17,6 +20,9 @@ importers:
react-dom: react-dom:
specifier: 18.3.1 specifier: 18.3.1
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: 24.9.1 specifier: 24.9.1
@ -844,6 +850,20 @@ packages:
fraction.js@4.3.7: fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
framer-motion@12.23.24:
resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fs.realpath@1.0.0: fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@ -1199,6 +1219,12 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
motion-dom@12.23.23:
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
motion-utils@12.23.6:
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -1529,6 +1555,12 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'} engines: {node: '>=14'}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2709,6 +2741,15 @@ snapshots:
fraction.js@4.3.7: {} fraction.js@4.3.7: {}
framer-motion@12.23.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
motion-dom: 12.23.23
motion-utils: 12.23.6
tslib: 2.8.1
optionalDependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
fs.realpath@1.0.0: {} fs.realpath@1.0.0: {}
fsevents@2.3.3: fsevents@2.3.3:
@ -3076,6 +3117,12 @@ snapshots:
minipass@7.1.2: {} minipass@7.1.2: {}
motion-dom@12.23.23:
dependencies:
motion-utils: 12.23.6
motion-utils@12.23.6: {}
ms@2.1.3: {} ms@2.1.3: {}
mz@2.7.0: mz@2.7.0:
@ -3429,6 +3476,11 @@ snapshots:
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
stable-hash@0.0.5: {} stable-hash@0.0.5: {}