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 WORKDIR /app
# Install dependencies early for caching # Copy dependency manifests
COPY package*.json ./ COPY package.json pnpm-lock.yaml ./
RUN npm install
# Copy everything else # Install dependencies with frozen lockfile for reproducible builds
RUN pnpm install --frozen-lockfile
# Copy the rest of the app
COPY . . COPY . .
EXPOSE 3000 # Build the Next.js app
CMD ["npm", "run", "dev"] 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"; import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs"; export const runtime = "nodejs";
export async function POST(req: NextRequest){
try{ export async function POST(req: NextRequest) {
try {
const body = await req.json(); 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 name = (body.name || "").toString().trim();
const TO=process.env.CONTACT_TO_EMAIL, FROM=process.env.CONTACT_FROM_EMAIL, KEY=process.env.RESEND_API_KEY; const email = (body.email || "").toString().trim();
if(!TO||!FROM||!KEY) return NextResponse.json({ok:false,error:"Server not configured."},{status:500}); const message = (body.message || "").toString().trim();
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}`})}); const type = (body.type || "").toString().trim();
if(!res.ok) return NextResponse.json({ok:false,error:"Email send failed."},{status:502}); const domain = (body.domain || "").toString().trim();
return NextResponse.json({ok:true}); const company = (body.company || "").toString().trim();
}catch{ return NextResponse.json({ok:false,error:"Unexpected."},{status:500}); } 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"; "use client";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
import { Suspense } from "react";
import { useState } from "react"; import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { toast } from "sonner"; 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 router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const prefillType = searchParams.get("type") as RequestType | null; 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; const { name, value } = e.target;
setForm((f) => ({ ...f, [name]: value })); setForm((f) => ({ ...f, [name]: value }));
}; };
@ -73,16 +86,19 @@ export default function RequestPage() {
setLoading(true); setLoading(true);
try { try {
const res = await fetch("/api/request", { const res = await fetch("/api/contact", {
method: "POST", 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), body: JSON.stringify(form),
}); });
if (!res.ok) throw new Error("Submission failed"); if (!res.ok) throw new Error("Submission failed");
toast.success("Request submitted successfully!"); toast.success("Request submitted successfully!");
router.push("/request/success"); router.push("/contact/success");
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error("Something went wrong. Please try again."); toast.error("Something went wrong. Please try again.");
@ -99,14 +115,13 @@ export default function RequestPage() {
Start a Request Start a Request
</h1> </h1>
<p className="mt-4 text-neutral-700 dark:text-neutral-300 text-lg"> <p className="mt-4 text-neutral-700 dark:text-neutral-300 text-lg">
Choose what youd like to do audits, consultations, or support. Choose what youd like to do audits, consultations, or support.
Well guide you through the right steps and get back within one business day. Well guide you through the right steps and get back within one business day.
</p> </p>
</div> </div>
</section> </section>
<section className="container mx-auto max-w-3xl px-6 py-12"> <section className="container mx-auto max-w-3xl px-6 py-12">
{/* Step 1 Select Request Type */}
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{step === 1 && ( {step === 1 && (
<motion.div <motion.div
@ -142,7 +157,6 @@ export default function RequestPage() {
</motion.div> </motion.div>
)} )}
{/* Step 2 Request Form */}
{step === 2 && ( {step === 2 && (
<motion.form <motion.form
key="step2" key="step2"
@ -186,7 +200,6 @@ export default function RequestPage() {
/> />
</div> </div>
{/* Show domain and technical fields only for audits / consultations */}
{(form.type === "audit" || form.type === "consultation") && ( {(form.type === "audit" || form.type === "consultation") && (
<> <>
<div> <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: networks:
- devnet - 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: networks:
devnet: devnet:
driver: bridge driver: bridge