Ask organizer modal implemented
This commit is contained in:
@@ -11,6 +11,9 @@ SESSION_SECRET="dev-only-change-me-16chars-min"
|
|||||||
SMTP_URL=
|
SMTP_URL=
|
||||||
SMTP_FROM="Community Rule <noreply@localhost>"
|
SMTP_FROM="Community Rule <noreply@localhost>"
|
||||||
|
|
||||||
|
# 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.
|
# Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in.
|
||||||
NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
|
NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ const askOrganizerData = {
|
|||||||
title: "Still have questions?",
|
title: "Still have questions?",
|
||||||
subtitle: "Get answers from an experienced organizer",
|
subtitle: "Get answers from an experienced organizer",
|
||||||
buttonText: "Ask an organizer",
|
buttonText: "Ask an organizer",
|
||||||
buttonHref: "#contact",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export default function LearnPage() {
|
|||||||
subtitle: t("pages.learn.askOrganizer.subtitle"),
|
subtitle: t("pages.learn.askOrganizer.subtitle"),
|
||||||
description: t("pages.learn.askOrganizer.description"),
|
description: t("pages.learn.askOrganizer.description"),
|
||||||
buttonText: t("pages.learn.askOrganizer.buttonText"),
|
buttonText: t("pages.learn.askOrganizer.buttonText"),
|
||||||
buttonHref: t("pages.learn.askOrganizer.buttonHref"),
|
|
||||||
variant: "centered" as const,
|
variant: "centered" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ export default function Page() {
|
|||||||
title: t("pages.home.askOrganizer.title"),
|
title: t("pages.home.askOrganizer.title"),
|
||||||
subtitle: t("pages.home.askOrganizer.subtitle"),
|
subtitle: t("pages.home.askOrganizer.subtitle"),
|
||||||
buttonText: t("pages.home.askOrganizer.buttonText"),
|
buttonText: t("pages.home.askOrganizer.buttonText"),
|
||||||
buttonHref: t("pages.home.askOrganizer.buttonHref"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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<AskOrganizerInquiryModalProps>(
|
||||||
|
({ 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<string | null>(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<HTMLFormElement>) => {
|
||||||
|
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 (
|
||||||
|
<AskOrganizerInquiryModalView
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
email={email}
|
||||||
|
message={message}
|
||||||
|
honeypot={honeypot}
|
||||||
|
submitting={submitting}
|
||||||
|
success={success}
|
||||||
|
formError={formError}
|
||||||
|
emailError={emailError}
|
||||||
|
questionError={questionError}
|
||||||
|
onEmailChange={setEmail}
|
||||||
|
onMessageChange={setMessage}
|
||||||
|
onHoneypotChange={setHoneypot}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
AskOrganizerInquiryModalContainer.displayName = "AskOrganizerInquiryModal";
|
||||||
|
|
||||||
|
export default AskOrganizerInquiryModalContainer;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface AskOrganizerInquiryModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
@@ -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<HTMLFormElement>) => 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 ? (
|
||||||
|
<div className="w-full px-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
size="large"
|
||||||
|
className="w-full !justify-center"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{t("closeAfterSuccess")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full px-1">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form={ASK_ORGANIZER_INQUIRY_FORM_ID}
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
size="large"
|
||||||
|
className="w-full !justify-center"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{t("submitButton")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Create
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("title")}
|
||||||
|
description={t("description")}
|
||||||
|
showBackButton={false}
|
||||||
|
showNextButton={false}
|
||||||
|
stepper={false}
|
||||||
|
ariaLabel={t("ariaDialog")}
|
||||||
|
footerContent={footer}
|
||||||
|
footerClassName="!h-auto min-h-[112px] shrink-0 flex flex-col justify-end pb-8 pt-3 px-4"
|
||||||
|
>
|
||||||
|
{success ? (
|
||||||
|
<div className="flex flex-col gap-3 py-2">
|
||||||
|
<p className="font-inter text-[18px] font-semibold leading-[24px] text-[var(--color-content-default-primary)]">
|
||||||
|
{t("successTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)]">
|
||||||
|
{t("successDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
id={ASK_ORGANIZER_INQUIRY_FORM_ID}
|
||||||
|
className="relative flex flex-col gap-6 pb-2"
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
{formError ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="font-inter text-[14px] leading-[20px] text-[var(--color-border-default-negative-primary)]"
|
||||||
|
>
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
autoComplete="email"
|
||||||
|
label={t("emailLabel")}
|
||||||
|
placeholder={t("emailPlaceholder")}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => onEmailChange(e.target.value)}
|
||||||
|
error={emailError}
|
||||||
|
inputSize="medium"
|
||||||
|
showHelpIcon={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
name="message"
|
||||||
|
label={t("questionLabel")}
|
||||||
|
placeholder={t("questionPlaceholder")}
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => onMessageChange(e.target.value)}
|
||||||
|
error={questionError}
|
||||||
|
size="medium"
|
||||||
|
appearance="embedded"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute left-0 top-0 h-px w-px overflow-hidden opacity-0"
|
||||||
|
>
|
||||||
|
<label htmlFor={`${ASK_ORGANIZER_INQUIRY_FORM_ID}-${ORGANIZER_INQUIRY_HONEYPOT_FIELD}`}>
|
||||||
|
{t("honeypotLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${ASK_ORGANIZER_INQUIRY_FORM_ID}-${ORGANIZER_INQUIRY_HONEYPOT_FIELD}`}
|
||||||
|
type="text"
|
||||||
|
name={ORGANIZER_INQUIRY_HONEYPOT_FIELD}
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
value={honeypot}
|
||||||
|
onChange={(e) => onHoneypotChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Create>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./AskOrganizerInquiryModal.container";
|
||||||
|
export * from "./AskOrganizerInquiryModal.types";
|
||||||
@@ -14,6 +14,7 @@ const CreateContainer = memo<CreateProps>(
|
|||||||
headerContent,
|
headerContent,
|
||||||
children,
|
children,
|
||||||
footerContent,
|
footerContent,
|
||||||
|
footerClassName,
|
||||||
showBackButton = true,
|
showBackButton = true,
|
||||||
showNextButton = true,
|
showNextButton = true,
|
||||||
onBack,
|
onBack,
|
||||||
@@ -47,6 +48,7 @@ const CreateContainer = memo<CreateProps>(
|
|||||||
// eslint-disable-next-line react/no-children-prop
|
// eslint-disable-next-line react/no-children-prop
|
||||||
children={children}
|
children={children}
|
||||||
footerContent={footerContent}
|
footerContent={footerContent}
|
||||||
|
footerClassName={footerClassName}
|
||||||
showBackButton={showBackButton}
|
showBackButton={showBackButton}
|
||||||
showNextButton={showNextButton}
|
showNextButton={showNextButton}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface CreateProps {
|
|||||||
headerContent?: React.ReactNode;
|
headerContent?: React.ReactNode;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
footerContent?: React.ReactNode;
|
footerContent?: React.ReactNode;
|
||||||
|
/** Optional class on {@link ModalFooter} shell (e.g. taller custom footer). */
|
||||||
|
footerClassName?: string;
|
||||||
showBackButton?: boolean;
|
showBackButton?: boolean;
|
||||||
showNextButton?: boolean;
|
showNextButton?: boolean;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
@@ -51,6 +53,7 @@ export interface CreateViewProps {
|
|||||||
headerContent?: React.ReactNode;
|
headerContent?: React.ReactNode;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
footerContent?: React.ReactNode;
|
footerContent?: React.ReactNode;
|
||||||
|
footerClassName?: string;
|
||||||
showBackButton: boolean;
|
showBackButton: boolean;
|
||||||
showNextButton: boolean;
|
showNextButton: boolean;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function CreateView({
|
|||||||
headerContent,
|
headerContent,
|
||||||
children,
|
children,
|
||||||
footerContent,
|
footerContent,
|
||||||
|
footerClassName,
|
||||||
showBackButton,
|
showBackButton,
|
||||||
showNextButton,
|
showNextButton,
|
||||||
onBack,
|
onBack,
|
||||||
@@ -82,6 +83,7 @@ export function CreateView({
|
|||||||
totalSteps={totalSteps}
|
totalSteps={totalSteps}
|
||||||
stepper={stepper}
|
stepper={stepper}
|
||||||
footerContent={footerContent}
|
footerContent={footerContent}
|
||||||
|
className={footerClassName}
|
||||||
/>
|
/>
|
||||||
</CreateModalFrameView>
|
</CreateModalFrameView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo, useCallback, useState } from "react";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
import { useAnalytics } from "../../../hooks";
|
import { useAnalytics } from "../../../hooks";
|
||||||
|
import AskOrganizerInquiryModal from "../../modals/AskOrganizerInquiry";
|
||||||
import AskOrganizerView from "./AskOrganizer.view";
|
import AskOrganizerView from "./AskOrganizer.view";
|
||||||
import type {
|
import type {
|
||||||
AskOrganizerProps,
|
AskOrganizerProps,
|
||||||
@@ -45,8 +46,9 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
|||||||
const variant = variantProp;
|
const variant = variantProp;
|
||||||
const t = useTranslation();
|
const t = useTranslation();
|
||||||
const defaultButtonText = buttonText ?? t("askOrganizer.buttonText");
|
const defaultButtonText = buttonText ?? t("askOrganizer.buttonText");
|
||||||
const defaultButtonHref = buttonHref ?? t("askOrganizer.buttonHref");
|
const analyticsHref = buttonHref ?? "modal";
|
||||||
const { trackEvent, trackCustomEvent } = useAnalytics();
|
const { trackEvent, trackCustomEvent } = useAnalytics();
|
||||||
|
const [inquiryOpen, setInquiryOpen] = useState(false);
|
||||||
|
|
||||||
const resolvedVariant: AskOrganizerVariant = variant ?? "centered";
|
const resolvedVariant: AskOrganizerVariant = variant ?? "centered";
|
||||||
const styles = VARIANT_STYLES[resolvedVariant] ?? VARIANT_STYLES.centered;
|
const styles = VARIANT_STYLES[resolvedVariant] ?? VARIANT_STYLES.centered;
|
||||||
@@ -66,6 +68,31 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
|||||||
const handleContactClick = (
|
const handleContactClick = (
|
||||||
event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
|
event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
|
||||||
) => {
|
) => {
|
||||||
|
if (buttonHref) {
|
||||||
|
// Legacy link CTA: do not intercept navigation.
|
||||||
|
trackEvent({
|
||||||
|
event: "contact_button_click",
|
||||||
|
category: "engagement",
|
||||||
|
label: "ask_organizer",
|
||||||
|
component: "AskOrganizer",
|
||||||
|
variant: resolvedVariant,
|
||||||
|
});
|
||||||
|
trackCustomEvent(
|
||||||
|
"contact_button_click",
|
||||||
|
{
|
||||||
|
component: "AskOrganizer",
|
||||||
|
variant: resolvedVariant,
|
||||||
|
buttonText: defaultButtonText,
|
||||||
|
buttonHref: analyticsHref,
|
||||||
|
},
|
||||||
|
onContactClick as
|
||||||
|
| ((_data: Record<string, unknown>) => void)
|
||||||
|
| undefined,
|
||||||
|
);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
trackEvent({
|
trackEvent({
|
||||||
event: "contact_button_click",
|
event: "contact_button_click",
|
||||||
category: "engagement",
|
category: "engagement",
|
||||||
@@ -80,33 +107,39 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
|||||||
component: "AskOrganizer",
|
component: "AskOrganizer",
|
||||||
variant: resolvedVariant,
|
variant: resolvedVariant,
|
||||||
buttonText: defaultButtonText,
|
buttonText: defaultButtonText,
|
||||||
buttonHref: defaultButtonHref,
|
buttonHref: analyticsHref,
|
||||||
},
|
},
|
||||||
onContactClick as
|
onContactClick as
|
||||||
| ((_data: Record<string, unknown>) => void)
|
| ((_data: Record<string, unknown>) => void)
|
||||||
| undefined,
|
| undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Preserve existing button behavior (no preventDefault here)
|
setInquiryOpen(true);
|
||||||
// while still tracking analytics.
|
|
||||||
return event;
|
return event;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeInquiry = useCallback(() => {
|
||||||
|
setInquiryOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AskOrganizerView
|
<>
|
||||||
title={title}
|
<AskOrganizerView
|
||||||
subtitle={subtitle}
|
title={title}
|
||||||
description={description}
|
subtitle={subtitle}
|
||||||
buttonText={defaultButtonText}
|
description={description}
|
||||||
buttonHref={defaultButtonHref}
|
buttonText={defaultButtonText}
|
||||||
className={className}
|
buttonHref={buttonHref}
|
||||||
sectionPadding={sectionPadding}
|
className={className}
|
||||||
contentGap={`${contentGap} ${styles.container}`}
|
sectionPadding={sectionPadding}
|
||||||
buttonContainerClass={styles.buttonContainer}
|
contentGap={`${contentGap} ${styles.container}`}
|
||||||
variant={resolvedVariant}
|
buttonContainerClass={styles.buttonContainer}
|
||||||
labelledBy={labelledBy}
|
variant={resolvedVariant}
|
||||||
onContactClick={handleContactClick}
|
labelledBy={labelledBy}
|
||||||
/>
|
onContactClick={handleContactClick}
|
||||||
|
/>
|
||||||
|
<AskOrganizerInquiryModal isOpen={inquiryOpen} onClose={closeInquiry} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export interface AskOrganizerProps {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
|
/**
|
||||||
|
* @deprecated Modal-only flow (CR-107). Omit; kept optional for Storybook overrides.
|
||||||
|
*/
|
||||||
buttonHref?: string;
|
buttonHref?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +25,7 @@ export interface AskOrganizerProps {
|
|||||||
component: string;
|
component: string;
|
||||||
variant: string;
|
variant: string;
|
||||||
buttonText: string;
|
buttonText: string;
|
||||||
buttonHref: string;
|
buttonHref?: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
@@ -32,7 +35,7 @@ export interface AskOrganizerViewProps {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
buttonText: string;
|
buttonText: string;
|
||||||
buttonHref: string;
|
buttonHref?: string;
|
||||||
className: string;
|
className: string;
|
||||||
sectionPadding: string;
|
sectionPadding: string;
|
||||||
contentGap: string;
|
contentGap: string;
|
||||||
|
|||||||
@@ -43,13 +43,14 @@ function AskOrganizerView({
|
|||||||
{/* Button */}
|
{/* Button */}
|
||||||
<div className={buttonContainerClass}>
|
<div className={buttonContainerClass}>
|
||||||
<Button
|
<Button
|
||||||
href={buttonHref}
|
{...(buttonHref ? { href: buttonHref } : {})}
|
||||||
size="large"
|
size="large"
|
||||||
buttonType="filled"
|
buttonType="filled"
|
||||||
palette={variant === "inverse" ? "inverse" : "default"}
|
palette={variant === "inverse" ? "inverse" : "default"}
|
||||||
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
|
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
|
||||||
onClick={onContactClick}
|
onClick={onContactClick}
|
||||||
ariaLabel={ariaLabel}
|
ariaLabel={ariaLabel}
|
||||||
|
data-testid="ask-organizer-cta"
|
||||||
>
|
>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Shared between client (form JSON) and server (Zod + honeypot check).
|
||||||
|
* CR-107 Ask an organizer.
|
||||||
|
*/
|
||||||
|
export const ORGANIZER_INQUIRY_HONEYPOT_FIELD = "company" as const;
|
||||||
|
|
||||||
|
export const ASK_ORGANIZER_INQUIRY_FORM_ID = "ask-organizer-inquiry-form" as const;
|
||||||
@@ -56,6 +56,39 @@ export async function sendRuleStakeholderInviteEmail(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** CR-107: notify support/organizers when a visitor submits the Ask an organizer form. */
|
||||||
|
export async function sendOrganizerInquiryNotification(params: {
|
||||||
|
/** Destination inbox (e.g. from ORGANIZER_INQUIRY_TO). */
|
||||||
|
to: string;
|
||||||
|
fromEmail: string;
|
||||||
|
visitorEmail: string;
|
||||||
|
message: string;
|
||||||
|
requestId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { to, fromEmail, visitorEmail, message, requestId } = params;
|
||||||
|
const url = process.env.SMTP_URL;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
logger.info(
|
||||||
|
`[dev] Organizer inquiry (request ${requestId}) from ${visitorEmail} to ${to}:\n${message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("SMTP_URL is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport(url);
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: fromEmail,
|
||||||
|
to,
|
||||||
|
replyTo: visitorEmail,
|
||||||
|
subject: `Ask an organizer inquiry from ${visitorEmail}`,
|
||||||
|
text: `Request ID: ${requestId}\nFrom: ${visitorEmail}\n\n${message}\n`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendEmailChangeEmail(
|
export async function sendEmailChangeEmail(
|
||||||
to: string,
|
to: string,
|
||||||
verifyUrl: string,
|
verifyUrl: string,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { ORGANIZER_INQUIRY_HONEYPOT_FIELD } from "../../organizerInquiryConstants";
|
||||||
|
|
||||||
|
const emailSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Email is required")
|
||||||
|
.max(254)
|
||||||
|
.transform((s) => s.toLowerCase())
|
||||||
|
.pipe(z.string().email("Enter a valid email address"));
|
||||||
|
|
||||||
|
const messageSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(10, "Please enter at least 10 characters")
|
||||||
|
.max(10_000, "Message is too long");
|
||||||
|
|
||||||
|
/** Optional honeypot; non-empty after trim indicates a bot. */
|
||||||
|
const honeypotSchema = z
|
||||||
|
.union([z.string(), z.undefined()])
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (typeof v === "string" ? v.trim() : ""));
|
||||||
|
|
||||||
|
export const organizerInquiryBodySchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
message: messageSchema,
|
||||||
|
[ORGANIZER_INQUIRY_HONEYPOT_FIELD]: honeypotSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OrganizerInquiryBody = z.infer<typeof organizerInquiryBodySchema>;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_comment": "AskOrganizer component defaults (shared across pages)",
|
"_comment": "AskOrganizer component defaults (shared across pages)",
|
||||||
"ariaLabel": "Ask an organizer - Contact an organizer for help"
|
"ariaLabel": "Ask an organizer - Contact an organizer for help",
|
||||||
|
"buttonText": "Ask an organizer"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import navigation from "./navigation.json";
|
|||||||
import metadata from "./metadata.json";
|
import metadata from "./metadata.json";
|
||||||
import modalsShare from "./modals/share.json";
|
import modalsShare from "./modals/share.json";
|
||||||
import modalsPopoverExport from "./modals/popoverExport.json";
|
import modalsPopoverExport from "./modals/popoverExport.json";
|
||||||
|
import modalsAskOrganizerInquiry from "./modals/askOrganizerInquiry.json";
|
||||||
|
|
||||||
// create – stage 1: community
|
// create – stage 1: community
|
||||||
import createInformational from "./create/community/informational.json";
|
import createInformational from "./create/community/informational.json";
|
||||||
@@ -117,5 +118,6 @@ export default {
|
|||||||
modals: {
|
modals: {
|
||||||
share: modalsShare,
|
share: modalsShare,
|
||||||
popoverExport: modalsPopoverExport,
|
popoverExport: modalsPopoverExport,
|
||||||
|
askOrganizerInquiry: modalsAskOrganizerInquiry,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"_comment": "CR-107 Ask an organizer modal (Figma 22078-587823)",
|
||||||
|
"title": "Ask an Organizer",
|
||||||
|
"description": "Have a question about organizing? Send it over and an experienced organizer will get back to you.",
|
||||||
|
"emailLabel": "Email address",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"questionLabel": "Your question",
|
||||||
|
"questionPlaceholder": "What would you like to know?",
|
||||||
|
"submitButton": "Confirm Question",
|
||||||
|
"closeAfterSuccess": "Close",
|
||||||
|
"successTitle": "Thanks, we received your question",
|
||||||
|
"successDescription": "Check your inbox and an organizer will reply when they can.",
|
||||||
|
"genericError": "Something went wrong. Please try again.",
|
||||||
|
"rateLimitedError": "Too many attempts. Please wait a bit and try again.",
|
||||||
|
"ariaDialog": "Ask an organizer",
|
||||||
|
"honeypotLabel": "Company"
|
||||||
|
}
|
||||||
@@ -53,8 +53,7 @@
|
|||||||
"askOrganizer": {
|
"askOrganizer": {
|
||||||
"title": "Still have questions?",
|
"title": "Still have questions?",
|
||||||
"subtitle": "Get answers from an experienced organizer",
|
"subtitle": "Get answers from an experienced organizer",
|
||||||
"buttonText": "Ask an organizer",
|
"buttonText": "Ask an organizer"
|
||||||
"buttonHref": "#contact"
|
|
||||||
},
|
},
|
||||||
"ruleStack": {
|
"ruleStack": {
|
||||||
"title": "Popular templates",
|
"title": "Popular templates",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
"title": "Still have questions?",
|
"title": "Still have questions?",
|
||||||
"subtitle": "Get answers from an experienced organizer",
|
"subtitle": "Get answers from an experienced organizer",
|
||||||
"description": "Our community of organizers is here to help you navigate the challenges of building and maintaining effective community organizations.",
|
"description": "Our community of organizers is here to help you navigate the challenges of building and maintaining effective community organizations.",
|
||||||
"buttonText": "Ask an organizer",
|
"buttonText": "Ask an organizer"
|
||||||
"buttonHref": "/contact"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export const Default = {
|
|||||||
title: "Still have questions?",
|
title: "Still have questions?",
|
||||||
subtitle: "Get answers from an experienced organizer",
|
subtitle: "Get answers from an experienced organizer",
|
||||||
buttonText: "Ask an organizer",
|
buttonText: "Ask an organizer",
|
||||||
buttonHref: "#contact",
|
|
||||||
variant: "centered",
|
variant: "centered",
|
||||||
onContactClick: (data) => console.log("Contact clicked:", data),
|
onContactClick: (data) => console.log("Contact clicked:", data),
|
||||||
},
|
},
|
||||||
@@ -60,7 +59,6 @@ export const LeftAligned = {
|
|||||||
title: "Still have questions?",
|
title: "Still have questions?",
|
||||||
subtitle: "Get answers from an experienced organizer",
|
subtitle: "Get answers from an experienced organizer",
|
||||||
buttonText: "Ask an organizer",
|
buttonText: "Ask an organizer",
|
||||||
buttonHref: "#contact",
|
|
||||||
variant: "left-aligned",
|
variant: "left-aligned",
|
||||||
onContactClick: (data) => console.log("Contact clicked:", data),
|
onContactClick: (data) => console.log("Contact clicked:", data),
|
||||||
},
|
},
|
||||||
@@ -71,7 +69,6 @@ export const Compact = {
|
|||||||
title: "Still have questions?",
|
title: "Still have questions?",
|
||||||
subtitle: "Get answers from an experienced organizer",
|
subtitle: "Get answers from an experienced organizer",
|
||||||
buttonText: "Ask an organizer",
|
buttonText: "Ask an organizer",
|
||||||
buttonHref: "#contact",
|
|
||||||
variant: "compact",
|
variant: "compact",
|
||||||
onContactClick: (data) => console.log("Contact clicked:", data),
|
onContactClick: (data) => console.log("Contact clicked:", data),
|
||||||
},
|
},
|
||||||
@@ -82,8 +79,19 @@ export const Inverse = {
|
|||||||
title: "Still have questions?",
|
title: "Still have questions?",
|
||||||
subtitle: "Get answers from an experienced organizer",
|
subtitle: "Get answers from an experienced organizer",
|
||||||
buttonText: "Ask an organizer",
|
buttonText: "Ask an organizer",
|
||||||
buttonHref: "#contact",
|
|
||||||
variant: "inverse",
|
variant: "inverse",
|
||||||
onContactClick: (data) => console.log("Contact clicked:", data),
|
onContactClick: (data) => console.log("Contact clicked:", data),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Legacy: CTA is a link (no inquiry modal). */
|
||||||
|
export const LinkCta = {
|
||||||
|
args: {
|
||||||
|
title: "Still have questions?",
|
||||||
|
subtitle: "Get answers from an experienced organizer",
|
||||||
|
buttonText: "Ask an organizer",
|
||||||
|
buttonHref: "/contact",
|
||||||
|
variant: "centered",
|
||||||
|
onContactClick: (data) => console.log("Contact clicked:", data),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import AskOrganizer from "../../app/components/sections/AskOrganizer";
|
import AskOrganizer from "../../app/components/sections/AskOrganizer";
|
||||||
@@ -52,15 +53,24 @@ describe("AskOrganizer (behavioral tests)", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders button with default text", () => {
|
it("renders CTA button with default label", () => {
|
||||||
render(<AskOrganizer title="Test" />);
|
render(<AskOrganizer title="Test" />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", {
|
screen.getByRole("button", {
|
||||||
name: /ask an organizer/i,
|
name: /ask an organizer/i,
|
||||||
}),
|
}),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens inquiry modal when CTA is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AskOrganizer title="Test" />);
|
||||||
|
await user.click(screen.getByTestId("ask-organizer-cta"));
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("dialog", { name: /ask an organizer/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders button with custom text", () => {
|
it("renders button with custom text", () => {
|
||||||
render(
|
render(
|
||||||
<AskOrganizer title="Test" buttonText="Contact" buttonHref="/contact" />,
|
<AskOrganizer title="Test" buttonText="Contact" buttonHref="/contact" />,
|
||||||
|
|||||||
@@ -69,15 +69,13 @@ test.describe("Critical User Journeys", () => {
|
|||||||
// 8. User reads testimonial
|
// 8. User reads testimonial
|
||||||
await expect(page.locator("text=Jo Freeman")).toBeVisible();
|
await expect(page.locator("text=Jo Freeman")).toBeVisible();
|
||||||
|
|
||||||
// 9. User decides to contact organizer
|
// 9. User decides to contact organizer (opens modal)
|
||||||
const askButton = page.locator(
|
const askButton = page.getByTestId("ask-organizer-cta").first();
|
||||||
'a:has-text("Ask an organizer"), button:has-text("Ask an organizer")',
|
if ((await askButton.count()) > 0 && (await askButton.isVisible())) {
|
||||||
);
|
await askButton.click();
|
||||||
if (
|
await expect(
|
||||||
(await askButton.count()) > 0 &&
|
page.getByRole("dialog", { name: /ask an organizer/i }),
|
||||||
(await askButton.first().isVisible())
|
).toBeVisible();
|
||||||
) {
|
|
||||||
await askButton.first().click();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ describe("Page Flow Integration", () => {
|
|||||||
screen.getByText("Get answers from an experienced organizer"),
|
screen.getByText("Get answers from an experienced organizer"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: /Ask an organizer/i }),
|
screen.getByRole("button", { name: /ask an organizer/i }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,9 +198,9 @@ describe("Page Flow Integration", () => {
|
|||||||
test("ask organizer section has proper call-to-action", () => {
|
test("ask organizer section has proper call-to-action", () => {
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
const askLink = screen.getByRole("link", { name: /Ask an organizer/i });
|
const askCta = screen.getByRole("button", { name: /ask an organizer/i });
|
||||||
expect(askLink).toBeInTheDocument();
|
expect(askCta).toBeInTheDocument();
|
||||||
expect(askLink).toHaveAttribute("href", "#contact");
|
expect(askCta).not.toHaveAttribute("href");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("page maintains proper semantic structure", async () => {
|
test("page maintains proper semantic structure", async () => {
|
||||||
@@ -223,16 +223,22 @@ describe("Page Flow Integration", () => {
|
|||||||
expect(mainContent).toBeInTheDocument();
|
expect(mainContent).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("all interactive elements are accessible", () => {
|
test("all interactive elements are accessible", async () => {
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
// Check all buttons have proper roles
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByRole("button").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
const buttons = screen.getAllByRole("button");
|
const buttons = screen.getAllByRole("button");
|
||||||
buttons.forEach((button) => {
|
buttons.forEach((button) => {
|
||||||
expect(button).toBeInTheDocument();
|
expect(button).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check all links have proper roles
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByRole("link").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
const links = screen.getAllByRole("link");
|
const links = screen.getAllByRole("link");
|
||||||
links.forEach((link) => {
|
links.forEach((link) => {
|
||||||
expect(link).toBeInTheDocument();
|
expect(link).toBeInTheDocument();
|
||||||
|
|||||||
@@ -121,10 +121,11 @@ describe("User Journey Integration", () => {
|
|||||||
screen.getByText("Get answers from an experienced organizer"),
|
screen.getByText("Get answers from an experienced organizer"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
// User clicks the ask organizer button (it's actually a link, not a button)
|
const askCta = screen.getByTestId("ask-organizer-cta");
|
||||||
const askLink = screen.getByRole("link", { name: /Ask an organizer/i });
|
await user.click(askCta);
|
||||||
await user.click(askLink);
|
expect(
|
||||||
expect(askLink).toHaveAttribute("href", "#contact");
|
await screen.findByRole("dialog", { name: /ask an organizer/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("user explores the process through CardSteps", async () => {
|
test("user explores the process through CardSteps", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const sendOrganizerInquiryNotificationMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/mail", () => ({
|
||||||
|
sendOrganizerInquiryNotification: (...args: unknown[]) =>
|
||||||
|
sendOrganizerInquiryNotificationMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rateLimitKeyMock = vi.hoisted(() =>
|
||||||
|
vi.fn(() => ({ ok: true as const })),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/rateLimit", () => ({
|
||||||
|
rateLimitKey: (...args: unknown[]) => rateLimitKeyMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { POST } from "../../app/api/organizer-inquiry/route";
|
||||||
|
|
||||||
|
describe("POST /api/organizer-inquiry", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sendOrganizerInquiryNotificationMock.mockReset();
|
||||||
|
sendOrganizerInquiryNotificationMock.mockResolvedValue(undefined);
|
||||||
|
rateLimitKeyMock.mockReset();
|
||||||
|
rateLimitKeyMock.mockImplementation(() => ({ ok: true as const }));
|
||||||
|
process.env.ORGANIZER_INQUIRY_TO = "organizers@example.com";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.ORGANIZER_INQUIRY_TO;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 and sends mail for a valid payload", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
new NextRequest("https://x.test/api/organizer-inquiry", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "Visitor@Example.com",
|
||||||
|
message: "How do we run consensus meetings?",
|
||||||
|
company: "",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ ok: true });
|
||||||
|
expect(sendOrganizerInquiryNotificationMock).toHaveBeenCalledTimes(1);
|
||||||
|
const arg = sendOrganizerInquiryNotificationMock.mock.calls[0][0];
|
||||||
|
expect(arg.visitorEmail).toBe("visitor@example.com");
|
||||||
|
expect(arg.message).toBe("How do we run consensus meetings?");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 without sending mail when honeypot is filled", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
new NextRequest("https://x.test/api/organizer-inquiry", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "spam@example.com",
|
||||||
|
message: "How do we run consensus meetings?",
|
||||||
|
company: "Evil Corp",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for invalid email", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
new NextRequest("https://x.test/api/organizer-inquiry", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "not-an-email",
|
||||||
|
message: "How do we run consensus meetings?",
|
||||||
|
company: "",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 429 when rate limited", async () => {
|
||||||
|
rateLimitKeyMock.mockReturnValue({
|
||||||
|
ok: false as const,
|
||||||
|
retryAfterMs: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
new NextRequest("https://x.test/api/organizer-inquiry", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "a@b.co",
|
||||||
|
message: "How do we run consensus meetings?",
|
||||||
|
company: "",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when ORGANIZER_INQUIRY_TO is unset", async () => {
|
||||||
|
delete process.env.ORGANIZER_INQUIRY_TO;
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
new NextRequest("https://x.test/api/organizer-inquiry", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "a@b.co",
|
||||||
|
message: "How do we run consensus meetings?",
|
||||||
|
company: "",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user