Initial commit
This commit is contained in:
parent
b7d2a113d1
commit
9094a4d0e4
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal 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
17
web/.eslintrc.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"extends": [
|
||||||
|
"next/core-web-vitals"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"no-console": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"allow": [
|
||||||
|
"warn",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
web/.nvmrc
Normal file
1
web/.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
lts/*
|
||||||
13
web/Dockerfile
Normal file
13
web/Dockerfile
Normal 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"]
|
||||||
14
web/app/api/contact/route.ts
Normal file
14
web/app/api/contact/route.ts
Normal 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}); }
|
||||||
|
}
|
||||||
22
web/app/api/free/dns-health/route.ts
Normal file
22
web/app/api/free/dns-health/route.ts
Normal 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}); }
|
||||||
|
}
|
||||||
14
web/app/api/free/edge-headers/route.ts
Normal file
14
web/app/api/free/edge-headers/route.ts
Normal 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}); }
|
||||||
|
}
|
||||||
18
web/app/api/free/email-auth/route.ts
Normal file
18
web/app/api/free/email-auth/route.ts
Normal 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}); }
|
||||||
|
}
|
||||||
1
web/app/api/health/route.ts
Normal file
1
web/app/api/health/route.ts
Normal 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
150
web/app/contact/page.tsx
Normal 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! We’ll 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. We’ll 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 don’t 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
1
web/app/cookie/page.tsx
Normal 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
1
web/app/error.tsx
Normal 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>);}
|
||||||
10
web/app/free/email-check/page.tsx
Normal file
10
web/app/free/email-check/page.tsx
Normal 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
19
web/app/free/page.tsx
Normal 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
12
web/app/globals.css
Normal 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
34
web/app/layout.tsx
Normal 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
1
web/app/not-found.tsx
Normal 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 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>);}
|
||||||
68
web/app/page.tsx
Normal file
68
web/app/page.tsx
Normal 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 2–3 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 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." }
|
||||||
|
]} />
|
||||||
|
<CTA />
|
||||||
|
<JsonLd data={orgLd} />
|
||||||
|
<JsonLd data={faqLd} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
web/app/privacy/page.tsx
Normal file
1
web/app/privacy/page.tsx
Normal 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
1
web/app/robots.ts
Normal 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/*'] }] }; }
|
||||||
139
web/app/services/[slug]/page.tsx
Normal file
139
web/app/services/[slug]/page.tsx
Normal 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 it’s 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. We’ll confirm scope, risks, and the fastest path to proof."
|
||||||
|
href={`/contact?topic=${encodeURIComponent(svc.slug)}`}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
web/app/services/page.tsx
Normal file
40
web/app/services/page.tsx
Normal 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
24
web/app/sitemap.ts
Normal 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
1
web/app/terms/page.tsx
Normal 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
8
web/app/viewport.ts
Normal 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;
|
||||||
6
web/components/Analytics.tsx
Normal file
6
web/components/Analytics.tsx
Normal 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
18
web/components/CTA.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
web/components/CtaPanel.tsx
Normal file
17
web/components/CtaPanel.tsx
Normal 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
17
web/components/FAQ.tsx
Normal 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
29
web/components/Footer.tsx
Normal 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
18
web/components/Header.tsx
Normal 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
34
web/components/Hero.tsx
Normal 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 care—clear 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
12
web/components/JsonLd.tsx
Normal 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) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
web/components/Pricing.tsx
Normal file
27
web/components/Pricing.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
web/components/Process.tsx
Normal file
21
web/components/Process.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
export default function Process() {
|
||||||
|
const steps = [
|
||||||
|
{ 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: "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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
web/components/ServiceBullets.tsx
Normal file
16
web/components/ServiceBullets.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
web/components/ServiceCards.tsx
Normal file
40
web/components/ServiceCards.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
web/components/Testimonials.tsx
Normal file
9
web/components/Testimonials.tsx
Normal 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
24
web/docker-compose.yml
Normal 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
1
web/lib/analytics.ts
Normal 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
17
web/lib/pricing.ts
Normal 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","1–2 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
19
web/lib/server/guard.ts
Normal 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
703
web/lib/services.ts
Normal 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., "1–2 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: "1–2 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: "2–3 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: "1–2 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: "1–2 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: "1–2 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: "2–3 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: "1–2 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.5–1 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: "2–4 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: "2–3 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 3–7 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: [
|
||||||
|
"5–7 sections, forms, OG/Twitter cards",
|
||||||
|
"Analytics & deploy to your VPS",
|
||||||
|
"Basic SEO & sitemap",
|
||||||
|
],
|
||||||
|
timeline: "5–7 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: "2–3 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: "1–2 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
8
web/lib/site.ts
Normal 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
13
web/lib/validators.ts
Normal 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
5
web/next-env.d.ts
vendored
Normal 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
15
web/next.config.mjs
Normal 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
33
web/package.json
Normal 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
3748
web/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
1
web/postcss.config.cjs
Normal file
1
web/postcss.config.cjs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
||||||
BIN
web/public/og.png
Normal file
BIN
web/public/og.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 B |
23
web/tailwind.config.ts
Normal file
23
web/tailwind.config.ts
Normal 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
42
web/tsconfig.json
Normal 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
2
web/types/plausible.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export {};
|
||||||
|
declare global { interface Window { plausible?: (event: string, opts?: { props?: Record<string, unknown> }) => void; } }
|
||||||
Loading…
Reference in New Issue
Block a user