Initial commit

This commit is contained in:
Thimon 2025-10-25 20:37:00 +02:00
parent b7d2a113d1
commit 9094a4d0e4
54 changed files with 5539 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
# Node/Next
node_modules
.next
out
dist
.env*
!.env.example
# Editor
.vscode
.DS_Store

17
web/.eslintrc.json Normal file
View File

@ -0,0 +1,17 @@
{
"root": true,
"extends": [
"next/core-web-vitals"
],
"rules": {
"no-console": [
"warn",
{
"allow": [
"warn",
"error"
]
}
]
}
}

1
web/.nvmrc Normal file
View File

@ -0,0 +1 @@
lts/*

13
web/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
# Install dependencies early for caching
COPY package*.json ./
RUN npm install
# Copy everything else
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

View File

@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
export async function POST(req: NextRequest){
try{
const body = await req.json();
const name=(body.name||"").toString().trim(); const email=(body.email||"").toString().trim(); const message=(body.message||"").toString().trim();
if(!name||!email||!message) return NextResponse.json({ok:false,error:"Missing fields."},{status:400});
const TO=process.env.CONTACT_TO_EMAIL, FROM=process.env.CONTACT_FROM_EMAIL, KEY=process.env.RESEND_API_KEY;
if(!TO||!FROM||!KEY) return NextResponse.json({ok:false,error:"Server not configured."},{status:500});
const res=await fetch("https://api.resend.com/emails",{method:"POST",headers:{Authorization:`Bearer ${KEY}`,"Content-Type":"application/json"},body:JSON.stringify({from:FROM,to:[TO],subject:`New inquiry from ${name}`,text:`From: ${name} <${email}>\n\n${message}`})});
if(!res.ok) return NextResponse.json({ok:false,error:"Email send failed."},{status:502});
return NextResponse.json({ok:true});
}catch{ return NextResponse.json({ok:false,error:"Unexpected."},{status:500}); }
}

View File

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { promises as dns } from "dns";
import { isValidDomain, normalizeDomain } from "@/lib/validators";
export const runtime = "nodejs";
export async function POST(req: NextRequest){
try{
const {domain} = await req.json();
const d = normalizeDomain(String(domain||""));
if(!isValidDomain(d)) return NextResponse.json({ok:false,error:"Invalid domain."},{status:400});
const res:any={ok:true,domain:d,notes:[]};
try{ res.a=await dns.resolve4(d);}catch{}
try{ res.aaaa=await dns.resolve6(d);}catch{}
try{ res.mx=(await dns.resolveMx(d)).sort((a,b)=>a.priority-b.priority);}catch{}
try{ res.ns=await dns.resolveNs(d);}catch{}
try{ const caa=(dns as any).resolveCaa?await (dns as any).resolveCaa(d):null; if(caa) res.caa=caa;}catch{}
if(!(res.a?.length||res.aaaa?.length)) res.notes.push("No A/AAAA at apex.");
if(!res.mx?.length) res.notes.push("No MX records found.");
if(!res.caa?.length) res.notes.push("No CAA records.");
if(!res.ns?.length) res.notes.push("No NS records detected.");
return NextResponse.json(res);
}catch{ return NextResponse.json({ok:false,error:"Unexpected error."},{status:500}); }
}

View File

@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
function gradeFrom(headers: Headers, https:boolean){ const get=(k:string)=>headers.get(k)||headers.get(k.toLowerCase())||""; const hsts=get("strict-transport-security"); const csp=get("content-security-policy"); const xfo=get("x-frame-options"); const rp=get("referrer-policy"); const psp=get("permissions-policy"); const xcto=get("x-content-type-options"); let score=0; const s:string[]=[]; if(https&&/max-age=\d+/.test(hsts)) score+=2; else s.push("Add HSTS."); if(/^nosniff$/i.test(xcto)) score+=1; else s.push("Add X-Content-Type-Options: nosniff."); if(/^deny$|^sameorigin$/i.test(xfo)) score+=1; else s.push("Set X-Frame-Options."); if(rp) score+=1; else s.push("Add Referrer-Policy."); if(psp) score+=1; else s.push("Add Permissions-Policy."); if(csp) score+=3; else s.push("Add a Content-Security-Policy."); if(!https) s.unshift("Use HTTPS."); const g = score>=8?"A":score>=6?"B":score>=4?"C":score>=2?"D":"F"; return {grade:g as any, suggestions:s}; }
export async function POST(req: NextRequest){
try{
const {url}=await req.json(); let target:URL; try{ target=new URL(String(url||"")); }catch{ return NextResponse.json({ok:false,error:"Invalid URL."},{status:400}); }
const controller=new AbortController(); const to=setTimeout(()=>controller.abort(),5000); let resp:Response;
try{ resp=await fetch(target.toString(),{method:"HEAD",redirect:"follow",signal:controller.signal}); }catch{ resp=await fetch(target.toString(),{method:"GET",redirect:"follow",signal:controller.signal}); } finally{ clearTimeout(to); }
const https = target.protocol==="https:" || resp.url.startsWith("https://");
const keep:Record<string,string>={}; for(const k of ["strict-transport-security","content-security-policy","x-frame-options","referrer-policy","permissions-policy","x-content-type-options","server"]){ const v=resp.headers.get(k); if(v) keep[k]=v; }
const {grade,suggestions}=gradeFrom(resp.headers,https);
return NextResponse.json({ok:true,url:resp.url,https,headers:keep,grade,suggestions});
}catch{ return NextResponse.json({ok:false,error:"Unexpected error."},{status:500}); }
}

View File

@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { promises as dns } from "dns";
import { isValidDomain, normalizeDomain } from "@/lib/validators";
export const runtime = "nodejs";
export async function POST(req: NextRequest){
try{
const {domain} = await req.json();
const d = normalizeDomain(String(domain||""));
if(!isValidDomain(d)) return NextResponse.json({ok:false,error:"Invalid domain."},{status:400});
let dmarc=null, spf=null, dkim:{selectors:string[];found:string[]}|null=null;
try{ const t=await dns.resolveTxt(`_dmarc.${d}`); const flat=t.map(a=>a.join("")).find(s=>/v=DMARC1/i.test(s)); dmarc={present:!!flat, record: flat||undefined}; }catch{ dmarc={present:false}; }
try{ const t=await dns.resolveTxt(d); const flat=t.map(a=>a.join("")).find(s=>/^v=spf1\s/i.test(s)); spf={present:!!flat, record: flat||undefined}; }catch{ spf={present:false}; }
const SEL=["default","selector1","google","s1","s2","mail","mx"]; const found:string[]=[];
for(const s of SEL){ try{ const rr=await dns.resolveTxt(`${s}._domainkey.${d}`); if(rr.map(a=>a.join("")).find(x=>/v=DKIM1/i.test(x))) found.push(s);}catch{} }
dkim={selectors:SEL,found};
return NextResponse.json({ok:true,domain:d,dmarc,spf,dkim});
}catch(e){ return NextResponse.json({ok:false,error:"Unexpected error."},{status:500}); }
}

View File

@ -0,0 +1 @@
import { NextResponse } from 'next/server'; export const runtime='edge'; export async function GET(){return NextResponse.json({ok:true,service:'vanhunen-it-web',time:new Date().toISOString()});}

150
web/app/contact/page.tsx Normal file
View File

@ -0,0 +1,150 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { site } from "@/lib/site";
type Status = { state: "idle" | "submitting" | "success" | "error"; message?: string };
export default function ContactPage() {
const [startedAt, setStartedAt] = useState<string>("");
const [status, setStatus] = useState<Status>({ state: "idle" });
useEffect(() => {
setStartedAt(String(Date.now()));
}, []);
const disabledEarly = useMemo(() => {
if (!startedAt) return true;
const min = Number(process.env.NEXT_PUBLIC_CONTACT_MIN_SUBMIT_SECONDS ?? 3) || 3;
return Date.now() - Number(startedAt) < min * 1000;
}, [startedAt]);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus({ state: "submitting" });
const form = e.currentTarget;
const formData = new FormData(form);
const payload = Object.fromEntries(formData.entries());
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const json = (await res.json()) as { ok: boolean; error?: string };
if (!res.ok || !json.ok) throw new Error(json.error || "Failed");
setStatus({ state: "success", message: "Thanks! Well get back to you shortly." });
form.reset();
setStartedAt(String(Date.now())); // reset time trap
} catch {
setStatus({ state: "error", message: "Could not send. Please try again." });
}
}
return (
<section className="container py-12">
<h1 className="text-3xl font-semibold tracking-tight">Contact</h1>
<p className="mt-2 text-muted-foreground">
Tell us what you want fixed or managed. Well confirm scope and start fast.
</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>
</section>
);
}

1
web/app/cookie/page.tsx Normal file
View File

@ -0,0 +1 @@
export const metadata={title:'Cookie Policy',alternates:{canonical:'/cookie'}}; export default function Page(){return (<section className='container py-12'><h1 className='text-3xl font-semibold tracking-tight'>Cookie Policy</h1><p className='mt-2 text-muted-foreground'>We use Plausible (no cookies) by default.</p></section>);}

1
web/app/error.tsx Normal file
View File

@ -0,0 +1 @@
"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>);}

View File

@ -0,0 +1,10 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { isValidDomain, normalizeDomain } from "@/lib/validators";
export const metadata = { title: "Email Auth Check", description: "Check DMARC, SPF and common DKIM selectors.", robots: { index: false, follow: true }, alternates: { canonical: "/free/email-check" } };
export default function Page(){
const [domain,setDomain]=useState(""); const [startedAt,setStartedAt]=useState(""); const [hp,setHp]=useState(""); const [loading,setLoading]=useState(false); const [res,setRes]=useState(null as any);
useEffect(()=>setStartedAt(String(Date.now())),[]);
const disabled=useMemo(()=>{ if(!domain) return True; if(!isValidDomain(normalizeDomain(domain))) return true; return Date.now()-Number(startedAt)<3000; },[domain,startedAt]);
async function onSubmit(e:any){ e.preventDefault(); setLoading(true); setRes(null); try{ const r=await fetch("/api/free/email-auth",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain,startedAt,website:hp})}); setRes(await r.json()); }catch{ setRes({ok:false,error:"Request failed"}) }finally{ setLoading(false);} }
return (<section className="container py-12"><h1 className="text-3xl font-semibold tracking-tight">Email Auth Check</h1><form onSubmit={onSubmit} className="mt-6 space-y-4"><div aria-hidden className="hidden"><label htmlFor="website">Website</label><input id="website" value={hp} onChange={(e)=>setHp(e.target.value)} tabIndex={-1} autoComplete="off"/></div><input type="hidden" value={startedAt} readOnly/><div className="flex flex-col gap-2 sm:max-w-md"><label htmlFor="domain" className="text-sm font-medium">Domain</label><input id="domain" value={domain} onChange={(e)=>setDomain(e.target.value)} placeholder="example.com" className="rounded-xl border bg-background px-3 py-2" inputMode="url" autoCapitalize="none" autoCorrect="off"/></div><button type="submit" disabled={disabled||loading} className="btn-primary">{loading?"Checking…":"Run check"}</button></form>{res&&(<pre className="mt-6 rounded-xl bg-muted p-4 text-xs overflow-auto">{JSON.stringify(res,null,2)}</pre>)}</section>); }

19
web/app/free/page.tsx Normal file
View File

@ -0,0 +1,19 @@
import type { Metadata } from "next";
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 default function FreeIndex() {
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:"DNS Health Snapshot", excerpt:"Look up A/AAAA, MX, NS and CAA records at a glance.", href:"/free/dns-health" }
];
return (
<section className="container py-12">
<h1 className="text-3xl font-semibold tracking-tight">Free tools</h1>
<p className="mt-2 text-muted-foreground">Helpful checks you can run in seconds. No sign-up needed.</p>
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{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>))}
</div>
</section>
);
}

12
web/app/globals.css Normal file
View File

@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root{--background:0 0% 100%;--foreground:222 47% 11%;--primary:221 83% 53%;--primary-foreground:0 0% 100%;--accent:173 86% 41%;--accent-foreground:0 0% 100%;--muted:220 14% 96%;--muted-foreground:215 16% 40%;--card:0 0% 100%;--card-foreground:222 47% 11%}
@media (prefers-color-scheme: dark){:root{--background:222 47% 11%;--foreground:210 40% 98%;--primary:221 83% 60%;--primary-foreground:210 40% 98%;--accent:173 86% 48%;--accent-foreground:210 40% 98%;--muted:222 41% 15%;--muted-foreground:215 20% 75%;--card:222 47% 13%;--card-foreground:210 40% 98%}}
html,body{height:100%} body{@apply bg-background text-foreground antialiased} .container{@apply mx-auto max-w-6xl px-4}
a{@apply underline-offset-2} .btn-primary{@apply inline-flex items-center justify-center rounded-xl bg-primary px-5 py-3 font-medium text-primary-foreground shadow-soft transition hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed}
.card{@apply rounded-2xl border bg-card p-6 shadow-soft} .text-muted-foreground{color:hsl(var(--muted-foreground))}
:where(a,button,input,textarea,select){@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background}
@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:focus-visible{left:.5rem;top:.5rem;outline:0}

34
web/app/layout.tsx Normal file
View File

@ -0,0 +1,34 @@
import "./globals.css";
import type { Metadata } from "next";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import Analytics from "@/components/Analytics";
import { site } from "@/lib/site";
export const metadata: Metadata = {
metadataBase: new URL(site.url),
title: { default: site.name, template: `%s · ${site.name}` },
description: site.description,
alternates: { canonical: "/" },
};
const PLAUSIBLE_HOST = process.env.NEXT_PUBLIC_PLAUSIBLE_HOST || "https://plausible.io";
const PLAUSIBLE_DOMAIN = process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || "";
export default function RootLayout({ children }: { children: React.ReactNode }) {
const enablePlausible = Boolean(PLAUSIBLE_DOMAIN);
return (
<html lang="en">
<head>
{enablePlausible && (<><link rel="preconnect" href={PLAUSIBLE_HOST} crossOrigin="" /><link rel="dns-prefetch" href={PLAUSIBLE_HOST} /></>)}
</head>
<body>
<a href="#main" className="skip-link">Skip to content</a>
<Header />
<main id="main" className="min-h-[60vh]">{children}</main>
<Footer />
<Analytics host={PLAUSIBLE_HOST} domain={PLAUSIBLE_DOMAIN} />
</body>
</html>
);
}

1
web/app/not-found.tsx Normal file
View File

@ -0,0 +1 @@
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>);}

68
web/app/page.tsx Normal file
View File

@ -0,0 +1,68 @@
import type { Metadata } from "next";
import Hero from "@/components/Hero";
import ServiceCards from "@/components/ServiceCards";
import Process from "@/components/Process";
import Pricing from "@/components/Pricing";
import Testimonials from "@/components/Testimonials";
import FAQ from "@/components/FAQ";
import CTA from "@/components/CTA";
import { services } from "@/lib/services";
import { plans } from "@/lib/pricing";
import { site } from "@/lib/site";
import JsonLd from "@/components/JsonLd";
export const metadata: Metadata = {
title: "Productized IT for SMBs",
description: site.description,
};
export default function HomePage() {
const orgLd = {
"@context": "https://schema.org",
"@type": "Organization",
name: site.name,
url: site.url,
logo: site.org.logo,
sameAs: site.org.sameAs
};
const faqLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: [
{
"@type": "Question",
name: "How fast can you start?",
acceptedAnswer: { "@type": "Answer", text: "Most sprints start within 23 business days after scope confirmation." }
},
{
"@type": "Question",
name: "Do you work under NDA?",
acceptedAnswer: { "@type": "Answer", text: "Yes—mutual NDA available on request; we keep credentials least-privilege." }
},
{
"@type": "Question",
name: "Can we switch providers later?",
acceptedAnswer: { "@type": "Answer", text: "Yes. Everything is documented; you own the accounts and artifacts." }
}
]
};
return (
<>
<Hero />
<ServiceCards services={services} />
<Process />
<Pricing plans={plans} />
<Testimonials />
<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." }
]} />
<CTA />
<JsonLd data={orgLd} />
<JsonLd data={faqLd} />
</>
);
}

1
web/app/privacy/page.tsx Normal file
View File

@ -0,0 +1 @@
export const metadata={title:'Privacy Policy',alternates:{canonical:'/privacy'}}; export default function Page(){return (<section className='container py-12'><h1 className='text-3xl font-semibold tracking-tight'>Privacy Policy</h1><p className='mt-2 text-muted-foreground'>GDPR-aware, concise.</p></section>);}

1
web/app/robots.ts Normal file
View File

@ -0,0 +1 @@
import type { MetadataRoute } from 'next'; import { site } from '@/lib/site'; export default function robots(): MetadataRoute.Robots { const host=site.url.replace(/\/$/,''); return { host, sitemap: `${host}/sitemap.xml`, rules: [{ userAgent:'*', allow:['/'], disallow:['/api/','/api/*'] }] }; }

View File

@ -0,0 +1,139 @@
// app/services/[slug]/page.tsx
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import JsonLd from "@/components/JsonLd";
import CtaPanel from "@/components/CtaPanel";
import { ServiceBullets } from "@/components/ServiceBullets";
import { getAllServices, getServiceBySlug } from "@/lib/services";
import { site } from "@/lib/site"; // assumes lib/site.ts exports { site: { url, name, ... } }
export const revalidate = 86400;
type Params = { slug: string };
export async function generateStaticParams() {
return getAllServices().map((s) => ({ slug: s.slug }));
}
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const svc = getServiceBySlug(params.slug);
if (!svc) return {};
return {
title: svc.metaTitle,
description: svc.metaDescription,
alternates: { canonical: `/services/${svc.slug}` },
openGraph: {
title: svc.metaTitle,
description: svc.metaDescription,
url: `${site.url}/services/${svc.slug}`,
type: "article",
images: [{ url: "/og.png", width: 1200, height: 630, alt: "Van Hunen IT" }],
},
twitter: {
card: "summary_large_image",
title: svc.metaTitle,
description: svc.metaDescription,
images: ["/og.png"],
},
};
}
export default function ServicePage({ params }: { params: Params }) {
const svc = getServiceBySlug(params.slug);
if (!svc) notFound();
const breadcrumbLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", position: 1, name: "Services", item: `${site.url}/services` },
{ "@type": "ListItem", position: 2, name: svc.title, item: `${site.url}/services/${svc.slug}` },
],
};
const svcLd = {
"@context": "https://schema.org",
"@type": "Service",
name: svc.title,
description: svc.metaDescription || svc.outcome,
provider: { "@type": "Organization", name: site.name, url: site.url, logo: `${site.url}/og.png` },
areaServed: "EU",
offers: { "@type": "Offer", price: svc.price.replace(/[^\d]/g, "") || "0", priceCurrency: "EUR", availability: "https://schema.org/InStock" },
};
const related = (svc.relatedSlugs || [])
.map((slug) => getAllServices().find((s) => s.slug === slug))
.filter(Boolean) as NonNullable<ReturnType<typeof getServiceBySlug>>[];
return (
<main className="container mx-auto max-w-3xl px-4 py-10">
<JsonLd data={breadcrumbLd} />
<JsonLd data={svcLd} />
<header aria-labelledby="service-title">
<p className="text-sm text-neutral-500">
<Link className="hover:underline" href="/services"> Services</Link>
</p>
<h1 id="service-title" className="mt-2 text-3xl font-bold tracking-tight">{svc.title}</h1>
<p className="mt-2 text-neutral-700 dark:text-neutral-300">{svc.outcome}</p>
</header>
<section aria-labelledby="who-heading" className="mt-8">
<h2 id="who-heading" className="text-xl font-semibold tracking-tight">Who its for</h2>
<ul className="mt-3 list-disc pl-6 space-y-1 text-neutral-800 dark:text-neutral-200">
{svc.who.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</section>
{svc.outcomes && <ServiceBullets title="Outcomes" items={svc.outcomes} />}
<ServiceBullets title="Deliverables" items={svc.deliverables} />
<section aria-labelledby="timeline-price-heading" className="mt-8 grid gap-4 sm:grid-cols-2">
<div>
<h3 id="timeline-price-heading" className="text-xl font-semibold tracking-tight">Timeline</h3>
<p className="mt-2">{svc.timeline}</p>
</div>
<div>
<h3 className="text-xl font-semibold tracking-tight">Price</h3>
<p className="mt-2">{svc.price}</p>
</div>
</section>
<ServiceBullets title="Proof we deliver" items={svc.proof} />
{svc.faq?.length ? (
<section aria-labelledby="faq-heading" className="mt-8">
<h2 id="faq-heading" className="text-xl font-semibold tracking-tight">FAQ</h2>
<div className="mt-4 space-y-4">
{svc.faq.map((f, i) => (
<details key={i} className="rounded-md border p-4">
<summary className="cursor-pointer font-medium">{f.q}</summary>
<p className="mt-2 text-neutral-700 dark:text-neutral-300">{f.a}</p>
</details>
))}
</div>
</section>
) : null}
{related.length ? (
<section aria-labelledby="related-heading" className="mt-10">
<h2 id="related-heading" className="text-xl font-semibold tracking-tight">Related services</h2>
<ul className="mt-3 space-y-2">
{related.map((r) => (
<li key={r.slug}>
<Link href={`/services/${r.slug}`} className="hover:underline">{r.title}</Link>
</li>
))}
</ul>
</section>
) : null}
<CtaPanel
title="Ready to start?"
body="Book a free 20-minute check. Well confirm scope, risks, and the fastest path to proof."
href={`/contact?topic=${encodeURIComponent(svc.slug)}`}
/>
</main>
);
}

40
web/app/services/page.tsx Normal file
View File

@ -0,0 +1,40 @@
import type { Metadata } from "next";
import Link from "next/link";
import { services } from "@/lib/services";
import JsonLd from "@/components/JsonLd";
import { site } from "@/lib/site";
export const metadata: Metadata = {
title: "Services",
description: "Fixed-scope sprints and managed plans for email, Cloudflare, web and ops.",
alternates: { canonical: "/services" }
};
export default function ServicesIndex() {
const collectionLd = {
"@context": "https://schema.org",
"@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 (
<section className="container py-12">
<h1 className="text-3xl font-semibold tracking-tight">Services</h1>
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{services.map(s => (
<article key={s.slug} className="card">
<div className="text-xs uppercase tracking-wide text-accent">{s.category}</div>
<h3 className="mt-1 text-lg font-semibold">{s.title}</h3>
<p className="mt-2 text-sm text-muted-foreground">{s.excerpt}</p>
<div className="mt-4 flex items-center justify-between">
<span className="text-sm font-semibold">{s.price}</span>
<Link className="no-underline rounded-xl border px-3 py-2 text-sm" href={`/services/${s.slug}`}>View</Link>
</div>
</article>
))}
</div>
<JsonLd data={collectionLd} />
</section>
);
}

24
web/app/sitemap.ts Normal file
View File

@ -0,0 +1,24 @@
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { site } from "@/lib/site";
import { getAllServices } from "@/lib/services";
// If you already include other routes, merge them below.
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date().toISOString();
const base: MetadataRoute.Sitemap = [
{ url: `${site.url}/`, lastModified: now, changeFrequency: "weekly", priority: 1 },
{ url: `${site.url}/services`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${site.url}/contact`, lastModified: now, changeFrequency: "monthly", priority: 0.6 },
];
const services = getAllServices().map((s) => ({
url: `${site.url}/services/${s.slug}`,
lastModified: now,
changeFrequency: "monthly" as const,
priority: 0.7,
}));
return [...base, ...services];
}

1
web/app/terms/page.tsx Normal file
View File

@ -0,0 +1 @@
export const metadata={title:'Terms',alternates:{canonical:'/terms'}}; export default function Page(){return (<section className='container py-12'><h1 className='text-3xl font-semibold tracking-tight'>Terms</h1><p className='mt-2 text-muted-foreground'>Lean service terms.</p></section>);}

8
web/app/viewport.ts Normal file
View File

@ -0,0 +1,8 @@
import type { Viewport } from "next";
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0b1220" }
]
};
export default viewport;

View File

@ -0,0 +1,6 @@
import Script from "next/script";
export default function Analytics({ host, domain }: { host?: string; domain?: string }) {
if (!domain) return null;
const h = host || "https://plausible.io";
return <Script defer data-domain={domain} src={`${h.replace(/\/$/, "")}/js/script.js`} strategy="afterInteractive" />;
}

18
web/components/CTA.tsx Normal file
View File

@ -0,0 +1,18 @@
import Link from "next/link";
export default function CTA() {
return (
<section className="container py-14">
<div className="card flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-xl font-semibold">Ready to ship a fix or start care?</h3>
<p className="text-sm text-muted-foreground">Book a free 20-minute check. Clear scope, then we execute.</p>
</div>
<div className="flex gap-3">
<Link href="/contact" className="btn-primary no-underline">Book free check</Link>
<Link href="/services" className="no-underline rounded-xl border px-5 py-3">Browse services</Link>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,17 @@
// components/CtaPanel.tsx
import Link from "next/link";
export default function CtaPanel({ title, body, href }: { title: string; body?: string; href: string }) {
return (
<aside className="mt-10 rounded-lg border p-6 bg-neutral-50 dark:bg-neutral-900/40">
<h3 className="text-lg font-semibold">{title}</h3>
{body ? <p className="mt-2 text-neutral-700 dark:text-neutral-300">{body}</p> : null}
<Link
href={href}
className="mt-4 inline-flex items-center rounded-md bg-black text-white px-4 py-2 text-sm hover:opacity-90"
>
Start now
</Link>
</aside>
);
}

17
web/components/FAQ.tsx Normal file
View File

@ -0,0 +1,17 @@
type QA = { q: string; a: string };
export default function FAQ({ items }: { items: QA[] }) {
return (
<section className="container py-14">
<h2 className="text-2xl font-semibold tracking-tight">FAQ</h2>
<div className="mt-6 grid gap-4">
{items.map((x, i) => (
<details key={i} className="rounded-2xl border p-5">
<summary className="cursor-pointer text-base font-medium">{x.q}</summary>
<p className="mt-2 text-sm text-muted-foreground">{x.a}</p>
</details>
))}
</div>
</section>
);
}

29
web/components/Footer.tsx Normal file
View File

@ -0,0 +1,29 @@
import Link from "next/link";
export default function Footer() {
return (
<footer className="border-t">
<div className="container grid gap-6 py-10 sm:grid-cols-3">
<div>
<div className="text-lg font-semibold">Van Hunen IT</div>
<p className="mt-2 text-sm text-muted-foreground">Fixes in days. Uptime for months.</p>
</div>
<div>
<div className="text-sm font-semibold">Links</div>
<ul className="mt-2 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>
<li><Link href="/contact" className="no-underline hover:underline">Contact</Link></li>
<li><Link href="/privacy" className="no-underline hover:underline">Privacy</Link></li>
<li><Link href="/terms" className="no-underline hover:underline">Terms</Link></li>
<li><Link href="/cookie" className="no-underline hover:underline">Cookie</Link></li>
</ul>
</div>
<div>
<div className="text-sm font-semibold">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-2 text-xs text-muted-foreground">© {new Date().getFullYear()} Van Hunen IT. All rights reserved.</p>
</div>
</div>
</footer>
);
}

18
web/components/Header.tsx Normal file
View File

@ -0,0 +1,18 @@
import Link from "next/link";
export default function Header() {
return (
<header className="sticky top-0 z-40 w-full border-b bg-background/80 backdrop-blur">
<div className="container flex items-center justify-between py-4">
<Link href="/" className="flex items-center gap-2 no-underline">
<span className="text-lg font-semibold">Van Hunen IT</span>
</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>
<Link href="/contact" className="btn-primary no-underline">Start now</Link>
</div>
</header>
);
}

34
web/components/Hero.tsx Normal file
View File

@ -0,0 +1,34 @@
import Link from "next/link";
export default function Hero() {
return (
<section className="container py-14 sm:py-20">
<div className="grid items-center gap-8 sm:grid-cols-2">
<div>
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
Fixes in days. <span className="text-primary">Uptime</span> for months.
</h1>
<p className="mt-4 text-lg text-muted-foreground">
Productized IT for small businesses: inbox-ready email, secure Cloudflare/DNS,
and website careclear scope and flat pricing.
</p>
<div className="mt-6 flex gap-3">
<Link href="/contact" className="btn-primary no-underline">Start now</Link>
<Link href="/services" className="no-underline rounded-xl border px-5 py-3">See services</Link>
</div>
</div>
<div className="card">
<ul className="space-y-3 text-sm">
<li> DMARC aligned, spoofing blocked</li>
<li> Cloudflare WAF & HTTP/3, faster TTFB</li>
<li> Daily backups with restore tests</li>
<li> Uptime watch + incident notes</li>
</ul>
<div className="mt-5 rounded-xl bg-muted p-4 text-sm text-muted-foreground">
We agree scope up front and show proof: before/after reports and restore tests.
</div>
</div>
</div>
</section>
);
}

12
web/components/JsonLd.tsx Normal file
View File

@ -0,0 +1,12 @@
// components/JsonLd.tsx
"use client";
import React from "react";
export default function JsonLd({ data }: { data: Record<string, any> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}

View File

@ -0,0 +1,27 @@
import Link from "next/link";
import type { Plan } from "@/lib/pricing";
export default function Pricing({ plans }: { plans: Plan[] }) {
return (
<section className="container py-14">
<h2 className="text-2xl font-semibold tracking-tight">Simple, flat pricing</h2>
<div className="mt-6 grid gap-6 sm:grid-cols-3">
{plans.map((p) => (
<div key={p.id} className={`card ${p.popular ? "ring-2 ring-primary" : ""}`}>
{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="text-lg font-semibold">{p.name}</div>
<div className="mt-2 flex items-baseline gap-1">
<div className="text-3xl font-bold">{p.price}</div>
{p.periodicity && <div className="text-sm text-muted-foreground">{p.periodicity}</div>}
</div>
<p className="mt-2 text-sm text-muted-foreground">{p.description}</p>
<ul className="mt-4 space-y-2 text-sm">
{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>
</section>
);
}

View File

@ -0,0 +1,21 @@
export default function Process() {
const steps = [
{ 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: "Proof", d: "We deliver and prove it: reports, tests, and notes you keep." },
];
return (
<section className="container py-10">
<h2 className="text-2xl font-semibold tracking-tight">How we work</h2>
<div className="mt-6 grid gap-6 sm:grid-cols-3">
{steps.map((s, i) => (
<div key={i} className="card">
<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-3 text-lg font-semibold">{s.t}</div>
<p className="mt-1 text-sm text-muted-foreground">{s.d}</p>
</div>
))}
</div>
</section>
);
}

View File

@ -0,0 +1,16 @@
// components/ServiceBullets.tsx
export function ServiceBullets({ title, items }: { title: string; items: string[] }) {
if (!items?.length) return null;
return (
<section className="mt-8" aria-labelledby={`${title.replace(/\s+/g, "-").toLowerCase()}-heading`}>
<h3 id={`${title.replace(/\s+/g, "-").toLowerCase()}-heading`} className="text-xl font-semibold tracking-tight">
{title}
</h3>
<ul className="mt-3 list-disc pl-6 space-y-1 text-neutral-800 dark:text-neutral-200">
{items.map((it, idx) => (
<li key={idx}>{it}</li>
))}
</ul>
</section>
);
}

View File

@ -0,0 +1,40 @@
// components/ServiceCard.tsx
import Link from "next/link";
import type { Service } from "@/lib/services";
export default function ServiceCard({ svc }: { svc: Service }) {
return (
<article className="rounded-lg border bg-white/50 dark:bg-neutral-900/50 p-5 shadow-sm hover:shadow transition">
<h3 className="text-lg font-semibold tracking-tight">
<Link href={`/services/${svc.slug}`} className="hover:underline">
{svc.title}
</Link>
</h3>
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-300">{svc.outcome}</p>
<ul className="mt-3 list-disc pl-5 text-sm text-neutral-700 dark:text-neutral-200 space-y-1">
{svc.deliverables.slice(0, 4).map((d, i) => (
<li key={i}>{d}</li>
))}
</ul>
<div className="mt-4 flex items-center justify-between">
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{svc.price}</span>
<div className="flex gap-2">
<Link
href={`/services/${svc.slug}`}
className="inline-flex items-center rounded-md border px-3 py-1.5 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-800"
>
See details
</Link>
<Link
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"
>
Start now
</Link>
</div>
</div>
</article>
);
}

View File

@ -0,0 +1,9 @@
// path: components/Testimonials.tsx
export default function Testimonials() {
return (
<section className="py-16 text-center">
<h2 className="text-2xl font-semibold mb-4">What clients say</h2>
<p className="text-gray-600">Testimonials coming soon.</p>
</section>
);
}

24
web/docker-compose.yml Normal file
View File

@ -0,0 +1,24 @@
version: "3.9"
services:
vanhunen-it-dev:
container_name: vanhunen-it
build:
context: .
dockerfile: Dockerfile
volumes:
- .:/app
- /app/node_modules # prevent overwriting node_modules with empty host dir
ports:
- "3000:3000"
environment:
NODE_ENV: development
NEXT_TELEMETRY_DISABLED: 1
command: npm run dev
restart: unless-stopped
networks:
- devnet
networks:
devnet:
driver: bridge

1
web/lib/analytics.ts Normal file
View File

@ -0,0 +1 @@
"use client"; export function track(e:string,p?:Record<string,unknown>){ if(typeof window!=="undefined"&&(window as any).plausible) (window as any).plausible(e,p?{props:p}:undefined);}

17
web/lib/pricing.ts Normal file
View File

@ -0,0 +1,17 @@
export type Plan = {
id: string; name: string; price: string; periodicity?: "/mo" | "/yr";
description: string; features: string[]; notIncluded?: string[];
cta: { label: string; href: string }; popular?: boolean;
};
export const plans: Plan[] = [
{ id: "fix-sprint", name: "Fix Sprint", price: "€490", description: "One focused fix: deliverability, DNS, speed or backup restore test.",
features: ["Scope agreed upfront","12 day turnaround","Before/after proof","30-day safety net"],
notIncluded: ["Ongoing monitoring","Major refactors","Recurring changes"],
cta: { label: "Book a Sprint", href: "/contact?subject=Fix%20Sprint" } },
{ id: "care-basic", name: "Care Basic", price: "€149", periodicity: "/mo", description: "Website care, uptime watch, monthly backups and patching.",
features: ["Updates","Backups + restore test / quarter","Uptime & SSL watch","Incident credits"],
cta: { label: "Start Care", href: "/contact?subject=Care%20Basic" }, popular: true },
{ id: "care-plus", name: "Care Plus", price: "€299", periodicity: "/mo", description: "Everything in Basic plus Cloudflare edge hardening and speed checks.",
features: ["Cloudflare WAF","Bot fight tuning","Monthly Web Vitals","Priority incidents"],
cta: { label: "Start Plus", href: "/contact?subject=Care%20Plus" } }
];

19
web/lib/server/guard.ts Normal file
View File

@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
export function clientIp(req: NextRequest): string {
const xff = req.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0]?.trim() || "unknown";
return (req as any).ip ?? req.headers.get("x-real-ip") ?? "unknown";
}
const buckets = new Map<string, number[]>();
export function isRateLimited(key: string, maxHits: number, windowMs: number): boolean {
const now = Date.now();
const arr = (buckets.get(key) ?? []).filter((t) => now - t < windowMs);
if (arr.length >= maxHits) { buckets.set(key, arr); return true; }
arr.push(now); buckets.set(key, arr); return false;
}
export function timeTrapOk(startedAt?: string | null, minSeconds = 4): boolean {
const started = Number(startedAt ?? 0); if (!started) return false;
return Date.now() - started >= minSeconds * 1000;
}
export function isHoneyFilled(value?: string | null): boolean { return !!value && value.trim().length > 0; }
export function sanitize(input: string): string { return input.replace(/<[^>]*>?/gm, "").trim(); }

703
web/lib/services.ts Normal file
View File

@ -0,0 +1,703 @@
// lib/services.ts
export type ServiceCategoryId =
| "infrastructure-devops"
| "web-performance"
| "dev-platforms"
| "migrations"
| "minecraft"
| "web-dev";
export interface FaqItem {
q: string;
a: string;
}
export interface Service {
slug: string;
title: string;
category: ServiceCategoryId;
outcome: string; // one-liner outcome under the H1
who: string[]; // "Who it's for"
outcomes?: string[]; // optional detailed outcomes list
deliverables: string[];
timeline: string; // e.g., "12 days"
price: string; // e.g., "€490" or "from €99/mo"
proof: string[]; // what you verify and how you show it
faq: FaqItem[];
relatedSlugs?: string[]; // internal cross-links
metaTitle: string;
metaDescription: string;
}
export const SERVICE_CATEGORIES: Record<ServiceCategoryId, { label: string; anchor: string; }> = {
"infrastructure-devops": { label: "Infrastructure & DevOps", anchor: "infrastructure-devops" },
"web-performance": { label: "Web Performance & Reliability", anchor: "web-performance" },
"dev-platforms": { label: "Developer Platforms & Tooling", anchor: "dev-platforms" },
"migrations": { label: "Migrations & Refreshes", anchor: "migrations" },
"minecraft": { label: "Minecraft Services", anchor: "minecraft" },
"web-dev": { label: "Web Development", anchor: "web-dev" },
};
// ------- 23 services -------
export const SERVICES: Service[] = [
{
slug: "vps-hardening-care",
title: "VPS Hardening & Care",
category: "infrastructure-devops",
outcome: "Secure, monitored, and backed-up VPS ready for production.",
who: [
"SMBs running WordPress/Next.js/apps on a VPS",
"Teams needing a baseline security and backup posture",
],
deliverables: [
"SSH/CIS hardening, firewall (ufw), fail2ban",
"Automated updates, audit log, intrusion checks",
"Backups + restore test, uptime & resource monitoring",
],
timeline: "12 days",
price: "€390 one-off or €89/mo care",
proof: [
"Hardened config diff & CIS checklist",
"Restore test report and monitoring dashboard screenshots",
],
faq: [
{ q: "Do you need root access?", a: "Yes, temporary privileged access is required to apply hardening, install monitoring, and verify backups. Access is removed post-delivery." },
{ q: "Which OS do you support?", a: "Debian/Ubuntu preferred. We can adapt to AlmaLinux/RHEL-family on request." },
],
relatedSlugs: ["cloudflare-edge-hardening", "backup-disaster-recovery-drill"],
metaTitle: "VPS Hardening & Care — Van Hunen IT",
metaDescription: "Secure your VPS with hardening, monitoring, and tested backups. One-off setup or ongoing care.",
},
{
slug: "dockerize-deploy",
title: "Dockerize & Deploy",
category: "infrastructure-devops",
outcome: "Your app runs in reproducible containers with minimal manual steps.",
who: [
"Teams moving from pets to containers",
"Startups needing consistent envs from dev to prod",
],
deliverables: [
"Dockerfiles & Compose, healthchecks, .env templating",
"Secrets handling, rollouts, runbook",
"Docs for local dev & production deployment",
],
timeline: "23 days",
price: "€690",
proof: [
"Successful build/deploy logs with healthcheck passes",
"Rollback steps validated in a dry-run",
],
faq: [
{ q: "Can you support multiple services?", a: "Yes—compose a multi-service stack with networks, volumes, and per-service healthchecks." },
{ q: "Will you set up CI?", a: "CI/CD can be added. See Git-to-Prod CI/CD." },
],
relatedSlugs: ["git-to-prod-ci-cd", "observability-stack"],
metaTitle: "Dockerize & Deploy — Van Hunen IT",
metaDescription: "Containerize your app with Docker and deploy with confidence—healthchecks, secrets, and runbooks included.",
},
{
slug: "container-registry-setup",
title: "Private Container Registry Setup",
category: "infrastructure-devops",
outcome: "Secure image storage and CI/CD-friendly workflows.",
who: [
"Teams needing private images with access control",
"Orgs adopting container scanning & retention policies",
],
deliverables: [
"GHCR/Harbor registry, RBAC & tokens",
"Retention & vulnerability scanning",
"CI push/pull integration docs",
],
timeline: "12 days",
price: "€490",
proof: [
"Policy & RBAC screenshots",
"Pipeline run showing signed/pushed images",
],
faq: [
{ q: "Do you support Harbor on-prem?", a: "Yes, Harbor on VPS/K8s with TLS and persistence." },
{ q: "Image signing?", a: "Cosign support can be added on request." },
],
relatedSlugs: ["git-to-prod-ci-cd", "dockerize-deploy"],
metaTitle: "Private Container Registry — Van Hunen IT",
metaDescription: "Set up a private, secure container registry with RBAC, retention, and CI integration.",
},
{
slug: "k3s-kubernetes-cluster",
title: "k3s/Kubernetes Cluster in a Day",
category: "infrastructure-devops",
outcome: "Production-ready k3s/K8s with ingress, TLS, and app namespaces.",
who: [
"Projects outgrowing Docker Compose",
"Teams needing multi-app isolation with ingress",
],
deliverables: [
"k3s/K8s install, ingress (Traefik/NGINX), TLS",
"Storage class, namespaces, RBAC",
"Example deployment & runbook",
],
timeline: "12 days",
price: "€890",
proof: [
"kubectl outputs validating health & RBAC",
"Ingress verification and SSL pass",
],
faq: [
{ q: "Is this managed?", a: "We provision and hand over; optional care add-on available." },
{ q: "On which infra?", a: "Single VPS, multi-node, or cloud—sized to your load." },
],
relatedSlugs: ["observability-stack", "secrets-management", "staging-environment"],
metaTitle: "Kubernetes (k3s) in a Day — Van Hunen IT",
metaDescription: "Get a production-ready k3s/Kubernetes cluster with ingress, TLS, RBAC, and a sample app.",
},
{
slug: "git-to-prod-ci-cd",
title: "Git-to-Prod CI/CD",
category: "infrastructure-devops",
outcome: "Push to main → build → test → deploy.",
who: [
"Teams wanting predictable deployment pipelines",
"Shops standardizing environments and rollbacks",
],
deliverables: [
"Pipelines (GitHub Actions/Woodpecker)",
"Image build & tagging, environment promotion",
"Automated rollbacks & notifications",
],
timeline: "2 days",
price: "€780",
proof: [
"Green pipeline run from commit to deploy",
"Rollback rehearsal recorded in logs",
],
faq: [
{ q: "Do you support monorepos?", a: "Yes—matrix builds and targeted deploys." },
{ q: "Secrets in CI?", a: "We wire secure secrets management per provider." },
],
relatedSlugs: ["dockerize-deploy", "container-registry-setup"],
metaTitle: "CI/CD to Production — Van Hunen IT",
metaDescription: "Automated pipelines from commit to production with tagging, promotion, and rollbacks.",
},
{
slug: "observability-stack",
title: "Observability Stack (Prometheus/Grafana/Loki)",
category: "infrastructure-devops",
outcome: "Metrics, logs, and alerts you can act on.",
who: [
"Apps needing visibility and alerting",
"Teams consolidating logs and dashboards",
],
deliverables: [
"Prometheus metrics & alert rules",
"Grafana dashboards for app & infra",
"Loki log aggregation & retention",
],
timeline: "12 days",
price: "€740",
proof: [
"Dashboards with baseline SLOs",
"Test alert firing to Slack/Email",
],
faq: [
{ q: "Can you integrate with K8s?", a: "Yes, via exporters and service monitors." },
{ q: "Retention strategy?", a: "Right-sized for your VPS budget and compliance." },
],
relatedSlugs: ["k3s-kubernetes-cluster", "git-to-prod-ci-cd"],
metaTitle: "Observability Stack — Van Hunen IT",
metaDescription: "Prometheus, Grafana, and Loki set up with alerts and actionable dashboards.",
},
{
slug: "backup-disaster-recovery-drill",
title: "Backup & Disaster-Recovery Drill",
category: "infrastructure-devops",
outcome: "Verified restore path—not just backups.",
who: [
"Sites that never tested restore",
"Teams formalizing RPO/RTO targets",
],
deliverables: [
"Backup plan (files/db), encryption & rotation",
"Restore test with documented steps",
"RPO/RTO notes & recommendations",
],
timeline: "1 day",
price: "€490",
proof: [
"Restore demonstration on staging",
"Report with timings and gaps",
],
faq: [
{ q: "Which databases?", a: "MySQL/MariaDB/Postgres supported; others on request." },
{ q: "Offsite options?", a: "S3-compatible storage or rsync to secondary VPS." },
],
relatedSlugs: ["vps-hardening-care", "website-care-plan"],
metaTitle: "Backup & DR Drill — Van Hunen IT",
metaDescription: "We plan, back up, and verify restores with a documented drill and RPO/RTO notes.",
},
{
slug: "cloudflare-edge-hardening",
title: "Cloudflare Edge Hardening",
category: "infrastructure-devops",
outcome: "Lower TTFB, fewer bad bots, safer origins.",
who: [
"Sites facing spam/bot abuse or high TTFB",
"Teams needing sane edge security fast",
],
deliverables: [
"WAF & bot tuning, page rules, cache keys",
"Origin shielding, HTTP/3, rate limiting",
"TTFB and cache-hit improvements",
],
timeline: "1 day",
price: "€420",
proof: [
"Before/after WebPageTest/TTFB screenshots",
"WAF rule set export & notes",
],
faq: [
{ q: "Pro/Business plan required?", a: "We work with Free+ plans; some features need Pro/Business." },
{ q: "Will it break APIs?", a: "Rules are staged and tested with allowlists where needed." },
],
relatedSlugs: ["vps-hardening-care", "core-web-vitals-sprint"],
metaTitle: "Cloudflare Edge Hardening — Van Hunen IT",
metaDescription: "Tune WAF, caching, and HTTP/3 to reduce TTFB and block abusive traffic.",
},
{
slug: "core-web-vitals-sprint",
title: "Core Web Vitals Sprint",
category: "web-performance",
outcome: "CLS/LCP/INP into the green.",
who: [
"Marketing sites and shops with poor CWV",
"Next.js/WordPress teams needing a focused fix",
],
deliverables: [
"Image strategy (WebP/next/image), font loading",
"Script defers, critical CSS, caching headers",
"Before/after CWV report",
],
timeline: "23 days",
price: "€820",
proof: [
"Lighthouse/CrUX before vs after",
"Largest contentful paint assets diff",
],
faq: [
{ q: "Will you change design?", a: "Only insofar as needed to stabilize layout and loading." },
{ q: "Third-party scripts?", a: "We reduce impact via defer/async and budgeting." },
],
relatedSlugs: ["cloudflare-edge-hardening", "website-care-plan"],
metaTitle: "Core Web Vitals Sprint — Van Hunen IT",
metaDescription: "Improve LCP/CLS/INP with image, font, and script strategy plus caching.",
},
{
slug: "website-care-plan",
title: "Website Care Plan",
category: "web-performance",
outcome: "Updated site with restore-on-demand and uptime eyes on.",
who: [
"SMBs wanting stable updates and monitoring",
"Teams without in-house ops",
],
deliverables: [
"Updates, uptime monitoring, backups + monthly restore test",
"Incident credits and priority support",
"Security checks & reporting",
],
timeline: "Monthly",
price: "from €99/mo",
proof: [
"Monthly report & restore test evidence",
"Incident notes with timestamps",
],
faq: [
{ q: "What platforms?", a: "WordPress, Next.js, Node backends; others on request." },
{ q: "SLA?", a: "Incident response windows depend on plan tier." },
],
relatedSlugs: ["backup-disaster-recovery-drill", "core-web-vitals-sprint"],
metaTitle: "Website Care Plan — Van Hunen IT",
metaDescription: "Monthly updates, uptime, and verified restores for peace of mind.",
},
{
slug: "email-deliverability-pack",
title: "Secure Contact & Email Deliverability Pack",
category: "web-performance",
outcome: "Inbox-ready email + safe forms.",
who: [
"Domains landing in spam or failing DMARC",
"Sites receiving contact-form spam",
],
deliverables: [
"SPF/DKIM/DMARC config & reports",
"Seed tests, alignment verification",
"Form honeypot/time-trap/rate-limit",
],
timeline: "12 days",
price: "€520",
proof: [
"Before/after seed test screenshots",
"DMARC alignment & provider screenshots",
],
faq: [
{ q: "BIMI?", a: "Supported if you provide a valid SVG and VMC (optional)." },
{ q: "Multiple providers?", a: "Yes—ESP+transactional combos are supported." },
],
relatedSlugs: ["website-care-plan", "cloudflare-edge-hardening"],
metaTitle: "Email Deliverability Pack — Van Hunen IT",
metaDescription: "Fix spam issues with SPF/DKIM/DMARC, verified tests, and safer contact forms.",
},
{
slug: "self-hosted-gitea-sso",
title: "Self-Hosted Git (Gitea) with SSO",
category: "dev-platforms",
outcome: "Private Git with teams and permissions.",
who: [
"Teams needing on-prem/private Git",
"Shops standardizing code workflows",
],
deliverables: [
"Gitea + runner, backup/restore",
"OAuth/SSO, protected branches",
"Repo templates & permissions",
],
timeline: "1 day",
price: "€460",
proof: [
"Admin settings & SSO validation",
"Backup/restore rehearsal log",
],
faq: [
{ q: "Migrate from GitHub/GitLab?", a: "Yes—repositories and permissions where possible." },
{ q: "Runner support?", a: "Gitea Actions or Woodpecker runners on request." },
],
relatedSlugs: ["git-to-prod-ci-cd", "container-registry-setup"],
metaTitle: "Self-Hosted Gitea with SSO — Van Hunen IT",
metaDescription: "Own your source control with Gitea, SSO, backups, and runners.",
},
{
slug: "secrets-management",
title: "Secrets Management (SOPS/age or Sealed-Secrets)",
category: "dev-platforms",
outcome: "Safe secrets in Git and Kubernetes.",
who: [
"Teams committing .env by mistake",
"K8s users needing encrypted manifests",
],
deliverables: [
"SOPS/age or Sealed-Secrets setup",
"Key management & rotation policy",
"Usage examples & policy notes",
],
timeline: "0.51 day",
price: "€380",
proof: [
"Encrypted secrets in repo & decrypt flow",
"Rotation drill notes",
],
faq: [
{ q: "Which to choose?", a: "SOPS for Git-centric flow; Sealed-Secrets for cluster-centric flow." },
{ q: "CI integration?", a: "We wire CI to decrypt securely where needed." },
],
relatedSlugs: ["k3s-kubernetes-cluster", "git-to-prod-ci-cd"],
metaTitle: "Secrets Management — Van Hunen IT",
metaDescription: "Implement SOPS/age or Sealed-Secrets for safe secrets handling in Git/K8s.",
},
{
slug: "staging-environment",
title: "Staging Environment on the Same VPS/Cluster",
category: "dev-platforms",
outcome: "Risk-free previews before prod.",
who: [
"Teams deploying without review",
"Sites needing UAT previews",
],
deliverables: [
"Staging namespace/compose stack",
"Preview URLs & deploy gates",
"Masked data & access controls",
],
timeline: "1 day",
price: "€520",
proof: [
"Preview deployment validation",
"Access restricted and logged",
],
faq: [
{ q: "Separate VPS needed?", a: "Not required; we can isolate on the same host if resources allow." },
{ q: "Data masking?", a: "We provide safe anonymization for staging data." },
],
relatedSlugs: ["git-to-prod-ci-cd", "dockerize-deploy"],
metaTitle: "Staging Environment — Van Hunen IT",
metaDescription: "Add a staging environment with preview URLs and deploy gates.",
},
{
slug: "server-app-migration",
title: "Server/App Migration to VPS/Kubernetes",
category: "migrations",
outcome: "Zero-to-minimal downtime move with rollback.",
who: [
"Teams changing hosts or platforms",
"Apps consolidating infra",
],
deliverables: [
"Inventory & plan, containerization if needed",
"DNS/cutover & rollback plan",
"Smoke tests & timed runbook",
],
timeline: "24 days",
price: "€1,190",
proof: [
"Cutover timeline & metrics",
"Rollback rehearsal log",
],
faq: [
{ q: "Can you migrate databases?", a: "Yes—logical or physical migration with validated checks." },
{ q: "Downtime window?", a: "We schedule low-impact windows and offer blue/green where possible." },
],
relatedSlugs: ["legacy-to-container-refresh", "k3s-kubernetes-cluster"],
metaTitle: "Server/App Migration — Van Hunen IT",
metaDescription: "Plan and execute migrations to VPS or Kubernetes with rollback protection.",
},
{
slug: "legacy-to-container-refresh",
title: "Legacy to Container Refresh",
category: "migrations",
outcome: "From pets to cattle—documented and reproducible.",
who: [
"Legacy apps lacking deployment consistency",
"Teams modernizing delivery",
],
deliverables: [
"Dockerfiles & manifests",
"Healthchecks, backups, docs",
"Operational runbook",
],
timeline: "23 days",
price: "€990",
proof: [
"Green healthchecks post-deploy",
"Disaster recovery walk-through",
],
faq: [
{ q: "Unsupported stacks?", a: "We assess feasibility; some apps may need refactors." },
{ q: "Windows workloads?", a: "Case-by-case; Linux recommended for best results." },
],
relatedSlugs: ["dockerize-deploy", "git-to-prod-ci-cd"],
metaTitle: "Legacy to Container Refresh — Van Hunen IT",
metaDescription: "Containerize legacy apps with healthchecks, backups, and docs.",
},
{
slug: "minecraft-managed-server",
title: "Managed Minecraft Server",
category: "minecraft",
outcome: "Fast, stable, and safe server on a dedicated VPS.",
who: [
"Communities, schools, creators",
"Small networks needing reliable ops",
],
deliverables: [
"VPS sizing/hardening, Paper/Velocity setup",
"Auto-backups + restore test",
"Performance tuning, grief/anti-cheat basics, monitoring",
],
timeline: "Setup 1 day · Ongoing monthly",
price: "Starter €49/mo · Pro €99/mo · Network €199/mo (+ VPS)",
proof: [
"TPS baseline & timings report",
"Restore test proof & monitoring",
],
faq: [
{ q: "Java or Bedrock?", a: "Java by default; Bedrock or Geyser support on request." },
{ q: "Modpacks?", a: "CurseForge/modded supported—resource-dependent." },
],
relatedSlugs: ["minecraft-performance-audit", "minecraft-plugin-development", "minecraft-monetization-pack"],
metaTitle: "Managed Minecraft Server — Van Hunen IT",
metaDescription: "Turnkey Minecraft hosting on a hardened VPS with backups and performance tuning.",
},
{
slug: "minecraft-performance-audit",
title: "Minecraft Performance & Stability Audit",
category: "minecraft",
outcome: "Higher TPS, fewer crashes.",
who: [
"Servers with lag or frequent crashes",
"Owners scaling to more players",
],
deliverables: [
"Profiler run (Spark), timings analysis",
"JVM flags & plugin audit",
"Before/after TPS report",
],
timeline: "1 day",
price: "€390",
proof: [
"Timings & Spark screenshots",
"Updated config diff & TPS before/after",
],
faq: [
{ q: "Supports Bungee/Velocity?", a: "Yes—networked setups supported." },
{ q: "Player cap increase?", a: "We optimize, then size infra appropriately." },
],
relatedSlugs: ["minecraft-managed-server"],
metaTitle: "Minecraft Performance Audit — Van Hunen IT",
metaDescription: "Fix lag with timings, JVM flags, and plugin optimizations plus TPS reporting.",
},
{
slug: "minecraft-plugin-development",
title: "Custom Minecraft Plugin Development",
category: "minecraft",
outcome: "Features tailored to your server/community.",
who: [
"Servers needing unique mechanics",
"Creators monetizing custom content",
],
deliverables: [
"Spec, plugin build & tests",
"Config & permissions",
"Maintenance window",
],
timeline: "From 37 days",
price: "€85/hr or fixed from €650",
proof: [
"Feature demo & test suite run",
"Config docs and changelog",
],
faq: [
{ q: "Source code ownership?", a: "You own it after payment unless agreed otherwise (private repo transfer included)." },
{ q: "API compatibility?", a: "Paper API targeted; cross-version support is scoped case-by-case." },
],
relatedSlugs: ["minecraft-managed-server", "minecraft-monetization-pack"],
metaTitle: "Minecraft Plugin Development — Van Hunen IT",
metaDescription: "Build custom Paper/Spigot plugins with tests, configs, and docs.",
},
{
slug: "minecraft-monetization-pack",
title: "Creator Monetization Pack (Tebex)",
category: "minecraft",
outcome: "Clean store + safe donations.",
who: [
"Servers adding a store",
"Creators formalizing monetization",
],
deliverables: [
"Tebex setup & product catalog",
"Rank automation & receipts",
"Anti-fraud notes & webhooks",
],
timeline: "1 day",
price: "€420",
proof: [
"Test purchase flow",
"Webhook logs to grants",
],
faq: [
{ q: "Compliance?", a: "We avoid P2W violations and follow platform rules." },
{ q: "Branding?", a: "Store theme aligned with your site and server style." },
],
relatedSlugs: ["minecraft-plugin-development", "minecraft-managed-server"],
metaTitle: "Minecraft Monetization Pack — Van Hunen IT",
metaDescription: "Set up Tebex with products, ranks, and safe donation flows.",
},
{
slug: "quick-launch-website",
title: "Quick-Launch Website (Next.js)",
category: "web-dev",
outcome: "Fast, SEO-ready site in days.",
who: [
"SMBs needing a credible web presence fast",
"Consultants/creators launching offers",
],
deliverables: [
"57 sections, forms, OG/Twitter cards",
"Analytics & deploy to your VPS",
"Basic SEO & sitemap",
],
timeline: "57 days",
price: "€2,450",
proof: [
"Lighthouse pass for basics",
"Deployed site link & repo handover",
],
faq: [
{ q: "Copy & assets?", a: "We provide a brief and templates; you can supply or we refine." },
{ q: "CMS?", a: "Optional—see Headless CMS Setup." },
],
relatedSlugs: ["headless-cms-setup", "core-web-vitals-sprint"],
metaTitle: "Quick-Launch Website (Next.js) — Van Hunen IT",
metaDescription: "Launch a fast, SEO-ready site in days with forms, analytics, and deployment.",
},
{
slug: "headless-cms-setup",
title: "Headless CMS Setup (Ghost/Strapi)",
category: "web-dev",
outcome: "Non-tech content updates without redeploys.",
who: [
"Teams wanting easy publishing",
"Sites separating content from code",
],
deliverables: [
"CMS install & roles",
"Content model & CI/CD",
"Image optimization & docs",
],
timeline: "23 days",
price: "€1,190",
proof: [
"Editor demo & role permissions",
"Publishing pipeline test",
],
faq: [
{ q: "Migration from WordPress?", a: "We can import and map key content types." },
{ q: "Auth & SSO?", a: "SSO/OAuth possible depending on CMS chosen." },
],
relatedSlugs: ["quick-launch-website", "website-care-plan"],
metaTitle: "Headless CMS Setup — Van Hunen IT",
metaDescription: "Install and configure Ghost/Strapi with roles, CI/CD, and image optimization.",
},
{
slug: "security-compliance-baseline",
title: "Security & Compliance Baseline (GDPR-aware)",
category: "web-dev",
outcome: "Reasonable security for small teams.",
who: [
"SMBs formalizing access and logging",
"Teams preparing for audits",
],
deliverables: [
"Password policy, 2FA & least-privilege",
"Audit logging & data retention",
"Incident checklist & drills",
],
timeline: "12 days",
price: "€740",
proof: [
"Policy documents & checklists",
"Access review and logging tests",
],
faq: [
{ q: "Covers DPIA?", a: "We provide input; legal sign-off remains with your DPO/counsel." },
{ q: "Tooling?", a: "We match to your stack—SaaS or self-hosted where appropriate." },
],
relatedSlugs: ["vps-hardening-care", "backup-disaster-recovery-drill"],
metaTitle: "Security & Compliance Baseline — Van Hunen IT",
metaDescription: "Implement practical security policies, logging, and incident readiness for SMBs.",
},
// (Web performance group already added 3; infra/devops group has 7; dev-platforms 3; migrations 2; minecraft 4; web-dev 3)
];
export function getAllServices(): Service[] {
// Keep a stable order by category then title
return [...SERVICES].sort((a, b) =>
a.category === b.category ? a.title.localeCompare(b.title) : a.category.localeCompare(b.category)
);
}
export function getServiceBySlug(slug: string): Service | undefined {
return SERVICES.find(s => s.slug === slug);
}
export function getServicesByCategory(category: ServiceCategoryId): Service[] {
return getAllServices().filter(s => s.category === category);
}

8
web/lib/site.ts Normal file
View File

@ -0,0 +1,8 @@
// lib/site.ts
export const site = {
name: "Van Hunen IT",
url: process.env.SITE_URL ?? "https://vanhunen.it",
description:
"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" },
} as const;

13
web/lib/validators.ts Normal file
View File

@ -0,0 +1,13 @@
export function isValidDomain(domain: string): boolean {
const d = domain.trim().replace(/\.$/, "");
const re = /^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))+$/;
return re.test(d);
}
export function normalizeDomain(domain: string): string {
return domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/\.$/, "");
}
export function normalizeUrl(input: string): string {
let url = input.trim();
if (!/^https?:\/\//i.test(url)) url = "https://" + url;
try { return new URL(url).toString(); } catch { return ""; }
}

5
web/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

15
web/next.config.mjs Normal file
View File

@ -0,0 +1,15 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
poweredByHeader: false,
async headers() {
const headers = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "Permissions-Policy", value: "geolocation=(), camera=(), microphone=(), interest-cohort=()" },
];
return [{ source: "/:path*", headers }];
},
};
export default nextConfig;

33
web/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "vanhunen-it",
"private": true,
"version": "0.1.0",
"type": "module",
"packageManager": "pnpm@9.0.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"format": "prettier --write ."
},
"engines": {
"node": ">=20 <23"
},
"dependencies": {
"next": "14.2.13",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/node": "24.9.1",
"@types/react": "19.2.2",
"autoprefixer": "10.4.20",
"eslint": "8.57.0",
"eslint-config-next": "14.2.13",
"postcss": "8.4.49",
"tailwindcss": "3.4.14",
"typescript": "5.6.2"
}
}

3748
web/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

1
web/postcss.config.cjs Normal file
View File

@ -0,0 +1 @@
module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };

BIN
web/public/og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

23
web/tailwind.config.ts Normal file
View File

@ -0,0 +1,23 @@
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: "hsl(var(--primary))",
"primary-foreground": "hsl(var(--primary-foreground))",
accent: "hsl(var(--accent))",
"accent-foreground": "hsl(var(--accent-foreground))",
muted: "hsl(var(--muted))",
"muted-foreground": "hsl(var(--muted-foreground))",
card: "hsl(var(--card))",
"card-foreground": "hsl(var(--card-foreground))",
},
boxShadow: { soft: "0 6px 24px rgba(0,0,0,0.08)" },
borderRadius: { xl: "1rem", "2xl": "1.25rem" }
},
},
plugins: [],
} satisfies Config;

42
web/tsconfig.json Normal file
View File

@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"es2022"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./*"
]
},
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.d.ts",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

2
web/types/plausible.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
export {};
declare global { interface Window { plausible?: (event: string, opts?: { props?: Record<string, unknown> }) => void; } }