diff --git a/.env.example b/.env.example index e3cdfdf..513cd81 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,9 @@ SESSION_SECRET="dev-only-change-me-16chars-min" SMTP_URL= SMTP_FROM="Community Rule " +# CR-107: inbox for Ask an organizer form submissions (requires SMTP_URL in production). +ORGANIZER_INQUIRY_TO= + # Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in. NEXT_PUBLIC_ENABLE_BACKEND_SYNC= diff --git a/app/(marketing)/blog/[slug]/page.tsx b/app/(marketing)/blog/[slug]/page.tsx index a2a7c21..b1339c3 100644 --- a/app/(marketing)/blog/[slug]/page.tsx +++ b/app/(marketing)/blog/[slug]/page.tsx @@ -28,7 +28,6 @@ const askOrganizerData = { title: "Still have questions?", subtitle: "Get answers from an experienced organizer", buttonText: "Ask an organizer", - buttonHref: "#contact", }; interface PageProps { diff --git a/app/(marketing)/learn/page.tsx b/app/(marketing)/learn/page.tsx index 1644679..49909ee 100644 --- a/app/(marketing)/learn/page.tsx +++ b/app/(marketing)/learn/page.tsx @@ -24,7 +24,6 @@ export default function LearnPage() { subtitle: t("pages.learn.askOrganizer.subtitle"), description: t("pages.learn.askOrganizer.description"), buttonText: t("pages.learn.askOrganizer.buttonText"), - buttonHref: t("pages.learn.askOrganizer.buttonHref"), variant: "centered" as const, }; diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index d6cbb64..c75d8af 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -87,7 +87,6 @@ export default function Page() { title: t("pages.home.askOrganizer.title"), subtitle: t("pages.home.askOrganizer.subtitle"), buttonText: t("pages.home.askOrganizer.buttonText"), - buttonHref: t("pages.home.askOrganizer.buttonHref"), }; return ( diff --git a/app/api/organizer-inquiry/route.ts b/app/api/organizer-inquiry/route.ts new file mode 100644 index 0000000..44632bc --- /dev/null +++ b/app/api/organizer-inquiry/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sendOrganizerInquiryNotification } from "../../../lib/server/mail"; +import { rateLimitKey } from "../../../lib/server/rateLimit"; +import { + errorJson, + rateLimited, + serverMisconfigured, +} from "../../../lib/server/responses"; +import { logRouteError } from "../../../lib/server/requestId"; +import { apiRoute } from "../../../lib/server/apiRoute"; +import { ORGANIZER_INQUIRY_HONEYPOT_FIELD } from "../../../lib/organizerInquiryConstants"; +import { organizerInquiryBodySchema } from "../../../lib/server/validation/organizerInquirySchemas"; +import { readLimitedJson } from "../../../lib/server/validation/requestBody"; +import { jsonFromZodError } from "../../../lib/server/validation/zodHttp"; + +const SCOPE = "organizer-inquiry.submit"; +const EMAIL_MIN_INTERVAL_MS = 60 * 1000; +const IP_MIN_INTERVAL_MS = 20 * 1000; + +function clientIp(request: NextRequest): string { + return ( + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + request.headers.get("x-real-ip") ?? + "unknown" + ); +} + +function organizerInquiryTo(): string | null { + const raw = process.env.ORGANIZER_INQUIRY_TO?.trim(); + return raw && raw.length > 0 ? raw : null; +} + +export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => { + const parsedBody = await readLimitedJson(request); + if (parsedBody.ok === false) { + return parsedBody.response; + } + + const validated = organizerInquiryBodySchema.safeParse(parsedBody.value); + if (!validated.success) { + return jsonFromZodError(validated.error); + } + + const { email, message } = validated.data; + const honeypot = validated.data[ORGANIZER_INQUIRY_HONEYPOT_FIELD]; + + if (honeypot.length > 0) { + // Silent success for bots — do not send mail or reveal rejection. + return NextResponse.json({ ok: true }); + } + + const ip = clientIp(request); + + const rlEmail = rateLimitKey(`organizer-inquiry-email:${email}`, EMAIL_MIN_INTERVAL_MS); + if (rlEmail.ok === false) { + return rateLimited(rlEmail.retryAfterMs); + } + + const rlIp = rateLimitKey(`organizer-inquiry-ip:${ip}`, IP_MIN_INTERVAL_MS); + if (rlIp.ok === false) { + return rateLimited(rlIp.retryAfterMs); + } + + const to = organizerInquiryTo(); + if (!to) { + return serverMisconfigured("ORGANIZER_INQUIRY_TO is not configured"); + } + + const from = process.env.SMTP_FROM ?? "noreply@localhost"; + + try { + await sendOrganizerInquiryNotification({ + to, + fromEmail: from, + visitorEmail: email, + message, + requestId, + }); + } catch (err) { + logRouteError(SCOPE, requestId, err, { phase: "sendOrganizerInquiryNotification" }); + return errorJson( + "mail_failed", + "We could not send your message. Please try again later.", + 502, + ); + } + + return NextResponse.json({ ok: true }); +}); diff --git a/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.container.tsx b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.container.tsx new file mode 100644 index 0000000..b381a20 --- /dev/null +++ b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.container.tsx @@ -0,0 +1,124 @@ +"use client"; + +/** + * Figma: Community Rule System — Modal / Ask an Organizer (22078-587823) + * File: agv0VBLiBlcnSAaiAORgPR, node 22078-587823 + */ + +import { memo, useCallback, useEffect, useState, type FormEvent } from "react"; +import { AskOrganizerInquiryModalView } from "./AskOrganizerInquiryModal.view"; +import type { AskOrganizerInquiryModalProps } from "./AskOrganizerInquiryModal.types"; +import { ORGANIZER_INQUIRY_HONEYPOT_FIELD } from "../../../../lib/organizerInquiryConstants"; +import { useTranslation } from "../../../contexts/MessagesContext"; + +const AskOrganizerInquiryModalContainer = memo( + ({ isOpen, onClose }) => { + const t = useTranslation("modals.askOrganizerInquiry"); + const [email, setEmail] = useState(""); + const [message, setMessage] = useState(""); + const [honeypot, setHoneypot] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [success, setSuccess] = useState(false); + const [formError, setFormError] = useState(null); + const [emailError, setEmailError] = useState(false); + const [questionError, setQuestionError] = useState(false); + + useEffect(() => { + if (!isOpen) { + setEmail(""); + setMessage(""); + setHoneypot(""); + setSubmitting(false); + setSuccess(false); + setFormError(null); + setEmailError(false); + setQuestionError(false); + } + }, [isOpen]); + + const onSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + setFormError(null); + setEmailError(false); + setQuestionError(false); + setSubmitting(true); + + try { + const res = await fetch("/api/organizer-inquiry", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email, + message, + [ORGANIZER_INQUIRY_HONEYPOT_FIELD]: honeypot, + }), + }); + + const data: unknown = await res.json().catch(() => null); + + if (res.ok) { + setSuccess(true); + return; + } + + if (res.status === 429) { + setFormError(t("rateLimitedError")); + return; + } + + if ( + data && + typeof data === "object" && + "error" in data && + data.error && + typeof data.error === "object" && + "message" in data.error && + typeof (data.error as { message: unknown }).message === "string" + ) { + const msg = (data.error as { message: string }).message; + const lower = msg.toLowerCase(); + if (lower.includes("email")) { + setEmailError(true); + } + if (lower.includes("character") || lower.includes("question")) { + setQuestionError(true); + } + setFormError(msg); + return; + } + + setFormError(t("genericError")); + } catch { + setFormError(t("genericError")); + } finally { + setSubmitting(false); + } + }, + [email, message, honeypot, t], + ); + + return ( + + ); + }, +); + +AskOrganizerInquiryModalContainer.displayName = "AskOrganizerInquiryModal"; + +export default AskOrganizerInquiryModalContainer; diff --git a/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.types.ts b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.types.ts new file mode 100644 index 0000000..f6abe4e --- /dev/null +++ b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.types.ts @@ -0,0 +1,4 @@ +export interface AskOrganizerInquiryModalProps { + isOpen: boolean; + onClose: () => void; +} diff --git a/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx new file mode 100644 index 0000000..95c2d27 --- /dev/null +++ b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx @@ -0,0 +1,164 @@ +"use client"; + +import type { FormEvent } from "react"; +import Create from "../Create"; +import TextInput from "../../controls/TextInput"; +import TextArea from "../../controls/TextArea"; +import Button from "../../buttons/Button"; +import { useTranslation } from "../../../contexts/MessagesContext"; +import { + ASK_ORGANIZER_INQUIRY_FORM_ID, + ORGANIZER_INQUIRY_HONEYPOT_FIELD, +} from "../../../../lib/organizerInquiryConstants"; +import type { AskOrganizerInquiryModalProps } from "./AskOrganizerInquiryModal.types"; + +export type AskOrganizerInquiryModalViewProps = AskOrganizerInquiryModalProps & { + email: string; + message: string; + honeypot: string; + submitting: boolean; + success: boolean; + formError: string | null; + emailError: boolean; + questionError: boolean; + onEmailChange: (_v: string) => void; + onMessageChange: (_v: string) => void; + onHoneypotChange: (_v: string) => void; + onSubmit: (_e: FormEvent) => void; +}; + +/** + * Figma: Community Rule System — Modal / Ask an Organizer (22078-587823) + */ +export function AskOrganizerInquiryModalView({ + isOpen, + onClose, + email, + message, + honeypot, + submitting, + success, + formError, + emailError, + questionError, + onEmailChange, + onMessageChange, + onHoneypotChange, + onSubmit, +}: AskOrganizerInquiryModalViewProps) { + const t = useTranslation("modals.askOrganizerInquiry"); + + const footer = success ? ( +
+ +
+ ) : ( +
+ +
+ ); + + return ( + + {success ? ( +
+

+ {t("successTitle")} +

+

+ {t("successDescription")} +

+
+ ) : ( +
+ {formError ? ( +

+ {formError} +

+ ) : null} + + onEmailChange(e.target.value)} + error={emailError} + inputSize="medium" + showHelpIcon={false} + /> + +