Production v1
This commit is contained in:
parent
b8c514013c
commit
1f12fb739b
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
51
web/app/contact/success/page.tsx
Normal file
51
web/app/contact/success/page.tsx
Normal 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.
|
||||
We’ll 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>); }
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user