Modernizing + /contact rewire
This commit is contained in:
parent
9094a4d0e4
commit
b8c514013c
|
|
@ -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! We’ll 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. We’ll 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">
|
||||||
</p>
|
Start a Request
|
||||||
|
</h1>
|
||||||
<form onSubmit={onSubmit} className="mt-8 space-y-5">
|
<p className="mt-4 text-neutral-700 dark:text-neutral-300 text-lg">
|
||||||
{/* Honeypot (hidden from users & screen readers) */}
|
Choose what you’d like to do — audits, consultations, or support.
|
||||||
<div aria-hidden className="hidden">
|
We’ll guide you through the right steps and get back within one business day.
|
||||||
<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 don’t sell data.
|
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{status.state === "success" && (
|
<section className="container mx-auto max-w-3xl px-6 py-12">
|
||||||
<div role="status" className="rounded-xl border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
|
{/* Step 1 – Select Request Type */}
|
||||||
{status.message}
|
<AnimatePresence mode="wait">
|
||||||
</div>
|
{step === 1 && (
|
||||||
)}
|
<motion.div
|
||||||
{status.state === "error" && (
|
key="step1"
|
||||||
<div role="alert" className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
{status.message}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
</div>
|
exit={{ opacity: 0, y: -20 }}
|
||||||
)}
|
transition={{ duration: 0.3 }}
|
||||||
</form>
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-10 text-sm text-muted-foreground">
|
{/* Step 2 – Request Form */}
|
||||||
Prefer email? Reach us at <a className="underline" href={`mailto:${site.contact.email}`}>{site.contact.email}</a>.
|
{step === 2 && (
|
||||||
</div>
|
<motion.form
|
||||||
</section>
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 website’s uptime, SSL, and DNS performance — get an instant reliability grade (A–F).",
|
||||||
|
href: "/free/web-reliability-check",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "DNS Health Analyzer",
|
||||||
|
excerpt:
|
||||||
|
"Check your domain’s 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 site’s 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:
|
||||||
|
"We’ll 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:
|
||||||
|
"We’ll 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 — we’ll 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">
|
||||||
</div>
|
Free Tools & Quick Audits
|
||||||
</section>
|
</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>
|
||||||
|
</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 we’ll 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 you’re looking for doesn’t 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 you’re looking for doesn’t 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
159
web/app/page.tsx
159
web/app/page.tsx
|
|
@ -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 2–3 business days after scope confirmation." }
|
acceptedAnswer: {
|
||||||
|
"@type": "Answer",
|
||||||
|
text: "Most sprints start within 2–3 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 plans={plans} />
|
|
||||||
<Testimonials />
|
{/* --- Pricing --- */}
|
||||||
<FAQ items={[
|
<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">
|
||||||
{ q: "How fast can you start?", a: "Most sprints start within 2–3 business days after scope confirmation." },
|
<div className="container mx-auto max-w-6xl px-4">
|
||||||
{ q: "Do you work under NDA?", a: "Yes—mutual NDA available on request; we keep credentials least-privilege." },
|
<Pricing plans={plans} />
|
||||||
{ q: "What’s your guarantee?", a: "We show proof of outcomes. If scope isn’t met, we make it right or refund the sprint fee." }
|
</div>
|
||||||
]} />
|
</section>
|
||||||
<CTA />
|
|
||||||
|
{/* --- 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 />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --- 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">
|
||||||
|
<div className="container mx-auto max-w-4xl px-4">
|
||||||
|
<FAQ
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
q: "How fast can you start?",
|
||||||
|
a: "Most sprints start within 2–3 business days after scope confirmation.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Do you work under NDA?",
|
||||||
|
a: "Yes—mutual NDA available on request; we keep credentials least-privilege.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "What’s your guarantee?",
|
||||||
|
a: "We show proof of outcomes. If scope isn’t 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<JsonLd data={collectionLd} />
|
|
||||||
</section>
|
{/* --- 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 — we’ll 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,25 @@ 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">
|
||||||
{items.map((x, i) => (
|
Frequently Asked Questions
|
||||||
<details key={i} className="rounded-2xl border p-5">
|
</h2>
|
||||||
<summary className="cursor-pointer text-base font-medium">{x.q}</summary>
|
<div className="mt-10 space-y-4">
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{x.a}</p>
|
{items.map((x, i) => (
|
||||||
</details>
|
<details
|
||||||
))}
|
key={i}
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
<p className="mt-3 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
Fixes in days. Uptime for months.
|
||||||
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</Link>
|
||||||
<nav aria-label="Main" className="flex items-center gap-2 text-sm">
|
<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" },{ 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>
|
{ 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>
|
||||||
))}
|
))}
|
||||||
</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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 care—clear 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>
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,45 @@ 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 className="text-xl font-semibold">{p.name}</div>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{p.description}</p>
|
))}
|
||||||
<ul className="mt-4 space-y-2 text-sm">
|
</div>
|
||||||
{p.features.map((f, i) => <li key={i}>• {f}</li>)}
|
|
||||||
</ul>
|
|
||||||
<Link href={p.cta.href} className="btn-primary mt-6 no-underline w-full text-center">{p.cta.label}</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,31 @@
|
||||||
export default function Process() {
|
export default function Process() {
|
||||||
const steps = [
|
const steps = [
|
||||||
{ t: "Free check", d: "We assess scope, risks and impact in 20–30 minutes." },
|
{ t: "Free check", d: "We assess scope, risks and impact in 20–30 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">
|
||||||
{steps.map((s, i) => (
|
How we work
|
||||||
<div key={i} className="card">
|
</h2>
|
||||||
<div className="h-10 w-10 rounded-xl bg-primary text-primary-foreground grid place-items-center font-semibold">{i+1}</div>
|
<div className="mt-12 grid gap-8 sm:grid-cols-3">
|
||||||
<div className="mt-3 text-lg font-semibold">{s.t}</div>
|
{steps.map((s, i) => (
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{s.d}</p>
|
<div
|
||||||
</div>
|
key={i}
|
||||||
))}
|
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]"
|
||||||
|
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>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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: {}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user