diff --git a/web/Dockerfile b/web/Dockerfile index a6edf69..574d951 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,13 +1,43 @@ -FROM node:20-alpine +# ---------- Base build stage ---------- +FROM node:20-alpine AS builder + +# Enable pnpm +RUN corepack enable WORKDIR /app -# Install dependencies early for caching -COPY package*.json ./ -RUN npm install +# Copy dependency manifests +COPY package.json pnpm-lock.yaml ./ -# Copy everything else +# Install dependencies with frozen lockfile for reproducible builds +RUN pnpm install --frozen-lockfile + +# Copy the rest of the app COPY . . -EXPOSE 3000 -CMD ["npm", "run", "dev"] +# Build the Next.js app +RUN pnpm run build + +# ---------- Production runtime stage ---------- +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3005 + +# Copy only necessary files from builder +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/pnpm-lock.yaml ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public + +# Optional: drop build-time dependencies like devDeps +# (pnpm install --prod is not needed because builder already prunes) + +EXPOSE 3005 + +# Start the production server +CMD ["pnpm", "start"] diff --git a/web/app/api/contact/route.ts b/web/app/api/contact/route.ts index 7770cff..e48a7dd 100644 --- a/web/app/api/contact/route.ts +++ b/web/app/api/contact/route.ts @@ -1,14 +1,58 @@ import { NextRequest, NextResponse } from "next/server"; export const runtime = "nodejs"; -export async function POST(req: NextRequest){ - try{ + +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}); } + + const name = (body.name || "").toString().trim(); + const email = (body.email || "").toString().trim(); + const message = (body.message || "").toString().trim(); + const type = (body.type || "").toString().trim(); + const domain = (body.domain || "").toString().trim(); + const company = (body.company || "").toString().trim(); + const concern = (body.concern || "").toString().trim(); + const hosting = (body.hosting || "").toString().trim(); + + if (!name || !email || !message) + return NextResponse.json({ ok: false, error: "Missing fields." }, { status: 400 }); + + const N8N_WEBHOOK_URL = "https://n8n.prestigepages.com/webhook/contact"; + const N8N_WEBHOOK_SECRET = process.env.N8N_WEBHOOK_SECRET; + + if (!N8N_WEBHOOK_URL) + return NextResponse.json({ ok: false, error: "Server not configured." }, { status: 500 }); + + // --- Send data to n8n --- + const n8nResponse = await fetch(N8N_WEBHOOK_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(N8N_WEBHOOK_SECRET ? { "x-secret-token": N8N_WEBHOOK_SECRET } : {}), + }, + body: JSON.stringify({ + name, + email, + message, + type, + domain, + company, + concern, + hosting, + created_at: new Date().toISOString(), + }), + }); + + if (!n8nResponse.ok) { + const text = await n8nResponse.text(); + console.error("n8n webhook error:", text); + return NextResponse.json({ ok: false, error: "n8n request failed." }, { status: 502 }); + } + + // ✅ Success + return NextResponse.json({ ok: true }); + } catch (err) { + console.error("Unexpected error:", err); + return NextResponse.json({ ok: false, error: "Unexpected." }, { status: 500 }); + } } diff --git a/web/app/contact/page.tsx b/web/app/contact/page.tsx index 3be9f42..0984eec 100644 --- a/web/app/contact/page.tsx +++ b/web/app/contact/page.tsx @@ -1,11 +1,22 @@ "use client"; +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; +import { Suspense } from "react"; import { useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { motion, AnimatePresence } from "framer-motion"; import { toast } from "sonner"; -export default function RequestPage() { +export default function ContactPage() { + return ( + Loading...}> + + + ); +} + +function RequestForm() { const router = useRouter(); const searchParams = useSearchParams(); const prefillType = searchParams.get("type") as RequestType | null; @@ -63,7 +74,9 @@ export default function RequestPage() { }, ]; - const handleChange = (e: React.ChangeEvent) => { + const handleChange = ( + e: React.ChangeEvent + ) => { const { name, value } = e.target; setForm((f) => ({ ...f, [name]: value })); }; @@ -73,16 +86,19 @@ export default function RequestPage() { setLoading(true); try { - const res = await fetch("/api/request", { + const res = await fetch("/api/contact", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "x-secret-token": process.env.NEXT_PUBLIC_FORM_SECRET || "", + }, body: JSON.stringify(form), }); if (!res.ok) throw new Error("Submission failed"); toast.success("Request submitted successfully!"); - router.push("/request/success"); + router.push("/contact/success"); } catch (err) { console.error(err); toast.error("Something went wrong. Please try again."); @@ -99,14 +115,13 @@ export default function RequestPage() { Start a Request

- Choose what you’d like to do — audits, consultations, or support. + Choose what you’d like to do — audits, consultations, or support. We’ll guide you through the right steps and get back within one business day.

- {/* Step 1 – Select Request Type */} {step === 1 && ( )} - {/* Step 2 – Request Form */} {step === 2 && ( - {/* Show domain and technical fields only for audits / consultations */} {(form.type === "audit" || form.type === "consultation") && ( <>
diff --git a/web/app/contact/success/page.tsx b/web/app/contact/success/page.tsx new file mode 100644 index 0000000..fc37b1a --- /dev/null +++ b/web/app/contact/success/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { motion } from "framer-motion"; +import Link from "next/link"; + +export default function ContactSuccessPage() { + return ( +
+ + + 🎉 + + +

+ Request Received +

+ +

+ Thanks for reaching out! Your request has been received successfully. + We’ll review it and get back to you within one business day. +

+ +
+ + Back to Home + + + + Explore Free Tools + +
+
+
+ ); +} diff --git a/web/app/free/email-check/page.tsx b/web/app/free/email-check/page.tsx deleted file mode 100644 index d8f7023..0000000 --- a/web/app/free/email-check/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -"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 (

Email Auth Check

setHp(e.target.value)} tabIndex={-1} autoComplete="off"/>
setDomain(e.target.value)} placeholder="example.com" className="rounded-xl border bg-background px-3 py-2" inputMode="url" autoCapitalize="none" autoCorrect="off"/>
{res&&(
{JSON.stringify(res,null,2)}
)}
); } diff --git a/web/docker-compose.yml b/web/docker-compose.yml index a257be1..fdf5cb1 100644 --- a/web/docker-compose.yml +++ b/web/docker-compose.yml @@ -19,6 +19,21 @@ services: networks: - devnet + vanhunen-it-prod: + container_name: vanhunen-it-prod + build: + context: . + dockerfile: Dockerfile + environment: + NODE_ENV: production + NEXT_TELEMETRY_DISABLED: 1 + ports: + - "8080:3005" + command: npm run start + restart: unless-stopped + networks: + - devnet + networks: devnet: driver: bridge