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
|
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"]
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
|
@ -106,7 +122,6 @@ export default function RequestPage() {
|
||||||
</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>
|
||||||
|
|
|
||||||
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:
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user