From 9094a4d0e45e9f022eeac2733bfc4e8eab1378e5 Mon Sep 17 00:00:00 2001 From: Thimon Date: Sat, 25 Oct 2025 20:37:00 +0200 Subject: [PATCH] Initial commit --- .gitignore | 11 + web/.eslintrc.json | 17 + web/.nvmrc | 1 + web/Dockerfile | 13 + web/app/api/contact/route.ts | 14 + web/app/api/free/dns-health/route.ts | 22 + web/app/api/free/edge-headers/route.ts | 14 + web/app/api/free/email-auth/route.ts | 18 + web/app/api/health/route.ts | 1 + web/app/contact/page.tsx | 150 + web/app/cookie/page.tsx | 1 + web/app/error.tsx | 1 + web/app/free/email-check/page.tsx | 10 + web/app/free/page.tsx | 19 + web/app/globals.css | 12 + web/app/layout.tsx | 34 + web/app/not-found.tsx | 1 + web/app/page.tsx | 68 + web/app/privacy/page.tsx | 1 + web/app/robots.ts | 1 + web/app/services/[slug]/page.tsx | 139 + web/app/services/page.tsx | 40 + web/app/sitemap.ts | 24 + web/app/terms/page.tsx | 1 + web/app/viewport.ts | 8 + web/components/Analytics.tsx | 6 + web/components/CTA.tsx | 18 + web/components/CtaPanel.tsx | 17 + web/components/FAQ.tsx | 17 + web/components/Footer.tsx | 29 + web/components/Header.tsx | 18 + web/components/Hero.tsx | 34 + web/components/JsonLd.tsx | 12 + web/components/Pricing.tsx | 27 + web/components/Process.tsx | 21 + web/components/ServiceBullets.tsx | 16 + web/components/ServiceCards.tsx | 40 + web/components/Testimonials.tsx | 9 + web/docker-compose.yml | 24 + web/lib/analytics.ts | 1 + web/lib/pricing.ts | 17 + web/lib/server/guard.ts | 19 + web/lib/services.ts | 703 +++++ web/lib/site.ts | 8 + web/lib/validators.ts | 13 + web/next-env.d.ts | 5 + web/next.config.mjs | 15 + web/package.json | 33 + web/pnpm-lock.yaml | 3748 ++++++++++++++++++++++++ web/postcss.config.cjs | 1 + web/public/og.png | Bin 0 -> 67 bytes web/tailwind.config.ts | 23 + web/tsconfig.json | 42 + web/types/plausible.d.ts | 2 + 54 files changed, 5539 insertions(+) create mode 100644 .gitignore create mode 100644 web/.eslintrc.json create mode 100644 web/.nvmrc create mode 100644 web/Dockerfile create mode 100644 web/app/api/contact/route.ts create mode 100644 web/app/api/free/dns-health/route.ts create mode 100644 web/app/api/free/edge-headers/route.ts create mode 100644 web/app/api/free/email-auth/route.ts create mode 100644 web/app/api/health/route.ts create mode 100644 web/app/contact/page.tsx create mode 100644 web/app/cookie/page.tsx create mode 100644 web/app/error.tsx create mode 100644 web/app/free/email-check/page.tsx create mode 100644 web/app/free/page.tsx create mode 100644 web/app/globals.css create mode 100644 web/app/layout.tsx create mode 100644 web/app/not-found.tsx create mode 100644 web/app/page.tsx create mode 100644 web/app/privacy/page.tsx create mode 100644 web/app/robots.ts create mode 100644 web/app/services/[slug]/page.tsx create mode 100644 web/app/services/page.tsx create mode 100644 web/app/sitemap.ts create mode 100644 web/app/terms/page.tsx create mode 100644 web/app/viewport.ts create mode 100644 web/components/Analytics.tsx create mode 100644 web/components/CTA.tsx create mode 100644 web/components/CtaPanel.tsx create mode 100644 web/components/FAQ.tsx create mode 100644 web/components/Footer.tsx create mode 100644 web/components/Header.tsx create mode 100644 web/components/Hero.tsx create mode 100644 web/components/JsonLd.tsx create mode 100644 web/components/Pricing.tsx create mode 100644 web/components/Process.tsx create mode 100644 web/components/ServiceBullets.tsx create mode 100644 web/components/ServiceCards.tsx create mode 100644 web/components/Testimonials.tsx create mode 100644 web/docker-compose.yml create mode 100644 web/lib/analytics.ts create mode 100644 web/lib/pricing.ts create mode 100644 web/lib/server/guard.ts create mode 100644 web/lib/services.ts create mode 100644 web/lib/site.ts create mode 100644 web/lib/validators.ts create mode 100644 web/next-env.d.ts create mode 100644 web/next.config.mjs create mode 100644 web/package.json create mode 100644 web/pnpm-lock.yaml create mode 100644 web/postcss.config.cjs create mode 100644 web/public/og.png create mode 100644 web/tailwind.config.ts create mode 100644 web/tsconfig.json create mode 100644 web/types/plausible.d.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f71c42e --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Node/Next +node_modules +.next +out +dist +.env* +!.env.example + +# Editor +.vscode +.DS_Store diff --git a/web/.eslintrc.json b/web/.eslintrc.json new file mode 100644 index 0000000..1273efa --- /dev/null +++ b/web/.eslintrc.json @@ -0,0 +1,17 @@ +{ + "root": true, + "extends": [ + "next/core-web-vitals" + ], + "rules": { + "no-console": [ + "warn", + { + "allow": [ + "warn", + "error" + ] + } + ] + } +} diff --git a/web/.nvmrc b/web/.nvmrc new file mode 100644 index 0000000..b009dfb --- /dev/null +++ b/web/.nvmrc @@ -0,0 +1 @@ +lts/* diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..a6edf69 --- /dev/null +++ b/web/Dockerfile @@ -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"] diff --git a/web/app/api/contact/route.ts b/web/app/api/contact/route.ts new file mode 100644 index 0000000..7770cff --- /dev/null +++ b/web/app/api/contact/route.ts @@ -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}); } +} diff --git a/web/app/api/free/dns-health/route.ts b/web/app/api/free/dns-health/route.ts new file mode 100644 index 0000000..e4fb67e --- /dev/null +++ b/web/app/api/free/dns-health/route.ts @@ -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}); } +} diff --git a/web/app/api/free/edge-headers/route.ts b/web/app/api/free/edge-headers/route.ts new file mode 100644 index 0000000..2a92f40 --- /dev/null +++ b/web/app/api/free/edge-headers/route.ts @@ -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={}; 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}); } +} diff --git a/web/app/api/free/email-auth/route.ts b/web/app/api/free/email-auth/route.ts new file mode 100644 index 0000000..0554356 --- /dev/null +++ b/web/app/api/free/email-auth/route.ts @@ -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}); } +} diff --git a/web/app/api/health/route.ts b/web/app/api/health/route.ts new file mode 100644 index 0000000..fb2d362 --- /dev/null +++ b/web/app/api/health/route.ts @@ -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()});} diff --git a/web/app/contact/page.tsx b/web/app/contact/page.tsx new file mode 100644 index 0000000..7e91c37 --- /dev/null +++ b/web/app/contact/page.tsx @@ -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(""); + const [status, setStatus] = useState({ 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) { + 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 ( +
+

Contact

+

+ Tell us what you want fixed or managed. We’ll confirm scope and start fast. +

+ +
+ {/* Honeypot (hidden from users & screen readers) */} +
+ + +
+ + {/* Time trap */} + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +