Production v1

This commit is contained in:
Thimon 2025-10-25 23:55:34 +02:00
parent b8c514013c
commit 1f12fb739b
6 changed files with 179 additions and 36 deletions

View File

@ -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"]

View File

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

View File

@ -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 (
<Suspense fallback={<div className="p-8 text-center">Loading...</div>}>
<RequestForm />
</Suspense>
);
}
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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
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.");
@ -106,7 +122,6 @@ export default function RequestPage() {
</section>
<section className="container mx-auto max-w-3xl px-6 py-12">
{/* Step 1 Select Request Type */}
<AnimatePresence mode="wait">
{step === 1 && (
<motion.div
@ -142,7 +157,6 @@ export default function RequestPage() {
</motion.div>
)}
{/* Step 2 Request Form */}
{step === 2 && (
<motion.form
key="step2"
@ -186,7 +200,6 @@ export default function RequestPage() {
/>
</div>
{/* Show domain and technical fields only for audits / consultations */}
{(form.type === "audit" || form.type === "consultation") && (
<>
<div>

View File

@ -0,0 +1,51 @@
"use client";
import { motion } from "framer-motion";
import Link from "next/link";
export default function ContactSuccessPage() {
return (
<main className="relative isolate min-h-screen bg-gradient-to-b from-neutral-50 via-white to-neutral-100 dark:from-neutral-950 dark:via-neutral-900 dark:to-neutral-950 flex items-center justify-center px-6">
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="w-full max-w-lg text-center bg-white/70 dark:bg-neutral-900/70 backdrop-blur border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-sm p-10"
>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.4 }}
className="text-5xl mb-4"
>
🎉
</motion.div>
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white">
Request Received
</h1>
<p className="mt-4 text-lg text-neutral-700 dark:text-neutral-300 leading-relaxed">
Thanks for reaching out! Your request has been received successfully.
Well review it and get back to you within <strong>one business day</strong>.
</p>
<div className="mt-10 flex flex-col sm:flex-row justify-center gap-4">
<Link
href="/"
className="rounded-lg bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white font-medium px-6 py-2.5 hover:opacity-90 transition"
>
Back to Home
</Link>
<Link
href="/free"
className="rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white/80 dark:bg-neutral-950/80 text-neutral-900 dark:text-white font-medium px-6 py-2.5 hover:bg-neutral-50 dark:hover:bg-neutral-900 transition"
>
Explore Free Tools
</Link>
</div>
</motion.section>
</main>
);
}

View File

@ -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 (<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>); }

View File

@ -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