Add SMTP admin settings and contact form delivery
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { isRedirectError } from "next/dist/client/components/redirect";
|
||||
import { z } from "zod";
|
||||
|
||||
import { enforceContactRateLimit, verifyTurnstileToken } from "@/lib/contact-guard";
|
||||
import { getContactProtectionSettings } from "@/lib/app-config";
|
||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||
import { sendContactMessage } from "@/lib/mail";
|
||||
|
||||
const contactFormSchema = z.object({
|
||||
locale: z.string().trim().min(1),
|
||||
name: z.string().trim().min(2).max(120),
|
||||
email: z.string().trim().email(),
|
||||
phone: z.string().trim().max(40).optional(),
|
||||
company: z.string().trim().max(120).optional(),
|
||||
message: z.string().trim().min(10).max(5000),
|
||||
turnstileToken: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
const contactErrorMessages = {
|
||||
ar: {
|
||||
invalid: "يرجى تعبئة كل الحقول بشكل صحيح.",
|
||||
failed: "تعذر إرسال الرسالة حالياً.",
|
||||
blocked: "تم إرسال عدد كبير من الطلبات. حاول لاحقاً.",
|
||||
verification: "يرجى إكمال التحقق قبل الإرسال.",
|
||||
},
|
||||
en: {
|
||||
invalid: "Please fill all fields correctly.",
|
||||
failed: "Message could not be sent right now.",
|
||||
blocked: "Too many requests. Please try again later.",
|
||||
verification: "Please complete the verification before sending.",
|
||||
},
|
||||
de: {
|
||||
invalid: "Bitte alle Felder korrekt ausfuellen.",
|
||||
failed: "Nachricht konnte gerade nicht gesendet werden.",
|
||||
blocked: "Zu viele Anfragen. Bitte spaeter erneut versuchen.",
|
||||
verification: "Bitte die Verifizierung vor dem Senden abschliessen.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function withError(pathname: string, message: string) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("error", message);
|
||||
|
||||
return `${pathname}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function getStringValue(formData: FormData, key: string) {
|
||||
const value = formData.get(key);
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export async function submitContactFormAction(formData: FormData) {
|
||||
const locale = resolveLocale(String(formData.get("locale") ?? ""));
|
||||
const contactPath = getLocalizedPath(locale, "/contact");
|
||||
|
||||
try {
|
||||
const protectionSettings = await getContactProtectionSettings();
|
||||
const values = contactFormSchema.parse({
|
||||
locale,
|
||||
name: getStringValue(formData, "name"),
|
||||
email: getStringValue(formData, "email"),
|
||||
phone: getStringValue(formData, "phone"),
|
||||
company: getStringValue(formData, "company"),
|
||||
message: getStringValue(formData, "message"),
|
||||
turnstileToken: getStringValue(formData, "turnstileToken"),
|
||||
});
|
||||
|
||||
await verifyTurnstileToken(protectionSettings, values.turnstileToken ?? "");
|
||||
await enforceContactRateLimit(protectionSettings);
|
||||
|
||||
await sendContactMessage({
|
||||
locale,
|
||||
name: values.name,
|
||||
email: values.email,
|
||||
phone: values.phone,
|
||||
company: values.company,
|
||||
message: values.message,
|
||||
});
|
||||
|
||||
redirect(getLocalizedPath(locale, "/success"));
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
redirect(withError(contactPath, contactErrorMessages[locale].invalid));
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message === "Too many contact requests. Please try again later.") {
|
||||
redirect(withError(contactPath, contactErrorMessages[locale].blocked));
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message === "Turnstile verification is required." ||
|
||||
error.message === "Turnstile verification failed." ||
|
||||
error.message === "Turnstile verification request failed.")
|
||||
) {
|
||||
redirect(withError(contactPath, contactErrorMessages[locale].verification));
|
||||
}
|
||||
|
||||
console.error("Contact form delivery failed.", error);
|
||||
redirect(withError(contactPath, contactErrorMessages[locale].failed));
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Mail, MapPin, Phone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { Container } from "@/components/layout/container";
|
||||
import { PageHero } from "@/components/layout/page-hero";
|
||||
import { MotionFade } from "@/components/motion-fade";
|
||||
import { FlashMessage } from "@/components/root/flash-message";
|
||||
import { ContactForm } from "@/components/site/contact-form";
|
||||
import { buildLocalizedMetadata } from "@/lib/metadata";
|
||||
import { getPublicContactProtectionSettings } from "@/lib/app-config";
|
||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||
import { AppCard } from "@/components/ui/app-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import { submitContactFormAction } from "./actions";
|
||||
|
||||
type ContactPageProps = {
|
||||
params: {
|
||||
locale: string;
|
||||
};
|
||||
searchParams?: {
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
@@ -35,9 +38,15 @@ export async function generateMetadata({
|
||||
});
|
||||
}
|
||||
|
||||
export default async function ContactPage({ params: { locale } }: ContactPageProps) {
|
||||
export default async function ContactPage({
|
||||
params: { locale },
|
||||
searchParams,
|
||||
}: ContactPageProps) {
|
||||
const localeKey = resolveLocale(locale);
|
||||
const t = await getTranslations({ locale: localeKey, namespace: "contactPage" });
|
||||
const [t, protection] = await Promise.all([
|
||||
getTranslations({ locale: localeKey, namespace: "contactPage" }),
|
||||
getPublicContactProtectionSettings(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -72,29 +81,27 @@ export default async function ContactPage({ params: { locale } }: ContactPagePro
|
||||
<MotionFade delay={0.05}>
|
||||
<AppCard>
|
||||
<CardContent className="pt-6">
|
||||
<form className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
{t("name")}
|
||||
<Input type="text" placeholder={t("name")} />
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
{t("email")}
|
||||
<Input type="email" placeholder={t("email")} />
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
{t("message")}
|
||||
<Textarea rows={5} placeholder={t("message")} />
|
||||
</Label>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="button">{t("submit")}</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={getLocalizedPath(localeKey, "/success")}>{t("preview")}</Link>
|
||||
</Button>
|
||||
{searchParams?.error ? (
|
||||
<div className="mb-4">
|
||||
<FlashMessage type="error" message={searchParams.error} />
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
<ContactForm
|
||||
action={submitContactFormAction}
|
||||
locale={localeKey}
|
||||
previewHref={getLocalizedPath(localeKey, "/success")}
|
||||
protection={protection}
|
||||
copy={{
|
||||
name: t("name"),
|
||||
email: t("email"),
|
||||
phone: t("phone"),
|
||||
company: t("company"),
|
||||
message: t("message"),
|
||||
note: t("note"),
|
||||
submit: t("submit"),
|
||||
preview: t("preview"),
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
|
||||
Reference in New Issue
Block a user