Ask organizer modal implemented

This commit is contained in:
adilallo
2026-05-11 18:03:52 -06:00
parent b5930331c0
commit 625a8c3161
29 changed files with 724 additions and 56 deletions
+3
View File
@@ -11,6 +11,9 @@ SESSION_SECRET="dev-only-change-me-16chars-min"
SMTP_URL=
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.
NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
-1
View File
@@ -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 {
-1
View File
@@ -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,
};
-1
View File
@@ -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 (
+89
View File
@@ -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,
children,
footerContent,
footerClassName,
showBackButton = true,
showNextButton = true,
onBack,
@@ -47,6 +48,7 @@ const CreateContainer = memo<CreateProps>(
// eslint-disable-next-line react/no-children-prop
children={children}
footerContent={footerContent}
footerClassName={footerClassName}
showBackButton={showBackButton}
showNextButton={showNextButton}
onBack={onBack}
@@ -12,6 +12,8 @@ export interface CreateProps {
headerContent?: React.ReactNode;
children?: React.ReactNode;
footerContent?: React.ReactNode;
/** Optional class on {@link ModalFooter} shell (e.g. taller custom footer). */
footerClassName?: string;
showBackButton?: boolean;
showNextButton?: boolean;
onBack?: () => void;
@@ -51,6 +53,7 @@ export interface CreateViewProps {
headerContent?: React.ReactNode;
children?: React.ReactNode;
footerContent?: React.ReactNode;
footerClassName?: string;
showBackButton: boolean;
showNextButton: boolean;
onBack?: () => void;
@@ -14,6 +14,7 @@ export function CreateView({
headerContent,
children,
footerContent,
footerClassName,
showBackButton,
showNextButton,
onBack,
@@ -82,6 +83,7 @@ export function CreateView({
totalSteps={totalSteps}
stepper={stepper}
footerContent={footerContent}
className={footerClassName}
/>
</CreateModalFrameView>
);
@@ -1,8 +1,9 @@
"use client";
import { memo } from "react";
import { memo, useCallback, useState } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useAnalytics } from "../../../hooks";
import AskOrganizerInquiryModal from "../../modals/AskOrganizerInquiry";
import AskOrganizerView from "./AskOrganizer.view";
import type {
AskOrganizerProps,
@@ -45,8 +46,9 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
const variant = variantProp;
const t = useTranslation();
const defaultButtonText = buttonText ?? t("askOrganizer.buttonText");
const defaultButtonHref = buttonHref ?? t("askOrganizer.buttonHref");
const analyticsHref = buttonHref ?? "modal";
const { trackEvent, trackCustomEvent } = useAnalytics();
const [inquiryOpen, setInquiryOpen] = useState(false);
const resolvedVariant: AskOrganizerVariant = variant ?? "centered";
const styles = VARIANT_STYLES[resolvedVariant] ?? VARIANT_STYLES.centered;
@@ -66,6 +68,31 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
const handleContactClick = (
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({
event: "contact_button_click",
category: "engagement",
@@ -80,33 +107,39 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
component: "AskOrganizer",
variant: resolvedVariant,
buttonText: defaultButtonText,
buttonHref: defaultButtonHref,
buttonHref: analyticsHref,
},
onContactClick as
| ((_data: Record<string, unknown>) => void)
| undefined,
);
// Preserve existing button behavior (no preventDefault here)
// while still tracking analytics.
setInquiryOpen(true);
return event;
};
const closeInquiry = useCallback(() => {
setInquiryOpen(false);
}, []);
return (
<AskOrganizerView
title={title}
subtitle={subtitle}
description={description}
buttonText={defaultButtonText}
buttonHref={defaultButtonHref}
className={className}
sectionPadding={sectionPadding}
contentGap={`${contentGap} ${styles.container}`}
buttonContainerClass={styles.buttonContainer}
variant={resolvedVariant}
labelledBy={labelledBy}
onContactClick={handleContactClick}
/>
<>
<AskOrganizerView
title={title}
subtitle={subtitle}
description={description}
buttonText={defaultButtonText}
buttonHref={buttonHref}
className={className}
sectionPadding={sectionPadding}
contentGap={`${contentGap} ${styles.container}`}
buttonContainerClass={styles.buttonContainer}
variant={resolvedVariant}
labelledBy={labelledBy}
onContactClick={handleContactClick}
/>
<AskOrganizerInquiryModal isOpen={inquiryOpen} onClose={closeInquiry} />
</>
);
},
);
@@ -11,6 +11,9 @@ export interface AskOrganizerProps {
subtitle?: string;
description?: string;
buttonText?: string;
/**
* @deprecated Modal-only flow (CR-107). Omit; kept optional for Storybook overrides.
*/
buttonHref?: string;
className?: string;
/**
@@ -22,7 +25,7 @@ export interface AskOrganizerProps {
component: string;
variant: string;
buttonText: string;
buttonHref: string;
buttonHref?: string;
timestamp: string;
}) => void;
}
@@ -32,7 +35,7 @@ export interface AskOrganizerViewProps {
subtitle?: string;
description?: string;
buttonText: string;
buttonHref: string;
buttonHref?: string;
className: string;
sectionPadding: string;
contentGap: string;
@@ -43,13 +43,14 @@ function AskOrganizerView({
{/* Button */}
<div className={buttonContainerClass}>
<Button
href={buttonHref}
{...(buttonHref ? { href: buttonHref } : {})}
size="large"
buttonType="filled"
palette={variant === "inverse" ? "inverse" : "default"}
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
onClick={onContactClick}
ariaLabel={ariaLabel}
data-testid="ask-organizer-cta"
>
{buttonText}
</Button>
+7
View File
@@ -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;
+33
View File
@@ -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(
to: 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>;
+2 -1
View File
@@ -1,4 +1,5 @@
{
"_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"
}
+2
View File
@@ -23,6 +23,7 @@ import navigation from "./navigation.json";
import metadata from "./metadata.json";
import modalsShare from "./modals/share.json";
import modalsPopoverExport from "./modals/popoverExport.json";
import modalsAskOrganizerInquiry from "./modals/askOrganizerInquiry.json";
// create stage 1: community
import createInformational from "./create/community/informational.json";
@@ -117,5 +118,6 @@ export default {
modals: {
share: modalsShare,
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"
}
+1 -2
View File
@@ -53,8 +53,7 @@
"askOrganizer": {
"title": "Still have questions?",
"subtitle": "Get answers from an experienced organizer",
"buttonText": "Ask an organizer",
"buttonHref": "#contact"
"buttonText": "Ask an organizer"
},
"ruleStack": {
"title": "Popular templates",
+1 -2
View File
@@ -8,7 +8,6 @@
"title": "Still have questions?",
"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.",
"buttonText": "Ask an organizer",
"buttonHref": "/contact"
"buttonText": "Ask an organizer"
}
}
+12 -4
View File
@@ -49,7 +49,6 @@ export const Default = {
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),
},
@@ -60,7 +59,6 @@ export const LeftAligned = {
title: "Still have questions?",
subtitle: "Get answers from an experienced organizer",
buttonText: "Ask an organizer",
buttonHref: "#contact",
variant: "left-aligned",
onContactClick: (data) => console.log("Contact clicked:", data),
},
@@ -71,7 +69,6 @@ export const Compact = {
title: "Still have questions?",
subtitle: "Get answers from an experienced organizer",
buttonText: "Ask an organizer",
buttonHref: "#contact",
variant: "compact",
onContactClick: (data) => console.log("Contact clicked:", data),
},
@@ -82,8 +79,19 @@ export const Inverse = {
title: "Still have questions?",
subtitle: "Get answers from an experienced organizer",
buttonText: "Ask an organizer",
buttonHref: "#contact",
variant: "inverse",
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),
},
};
+12 -2
View File
@@ -1,4 +1,5 @@
import React from "react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import { describe, it, expect } from "vitest";
import AskOrganizer from "../../app/components/sections/AskOrganizer";
@@ -52,15 +53,24 @@ describe("AskOrganizer (behavioral tests)", () => {
).toBeInTheDocument();
});
it("renders button with default text", () => {
it("renders CTA button with default label", () => {
render(<AskOrganizer title="Test" />);
expect(
screen.getByRole("link", {
screen.getByRole("button", {
name: /ask an organizer/i,
}),
).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", () => {
render(
<AskOrganizer title="Test" buttonText="Contact" buttonHref="/contact" />,
+7 -9
View File
@@ -69,15 +69,13 @@ test.describe("Critical User Journeys", () => {
// 8. User reads testimonial
await expect(page.locator("text=Jo Freeman")).toBeVisible();
// 9. User decides to contact organizer
const askButton = page.locator(
'a:has-text("Ask an organizer"), button:has-text("Ask an organizer")',
);
if (
(await askButton.count()) > 0 &&
(await askButton.first().isVisible())
) {
await askButton.first().click();
// 9. User decides to contact organizer (opens modal)
const askButton = page.getByTestId("ask-organizer-cta").first();
if ((await askButton.count()) > 0 && (await askButton.isVisible())) {
await askButton.click();
await expect(
page.getByRole("dialog", { name: /ask an organizer/i }),
).toBeVisible();
}
});
+13 -7
View File
@@ -130,7 +130,7 @@ describe("Page Flow Integration", () => {
screen.getByText("Get answers from an experienced organizer"),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /Ask an organizer/i }),
screen.getByRole("button", { name: /ask an organizer/i }),
).toBeInTheDocument();
});
@@ -198,9 +198,9 @@ describe("Page Flow Integration", () => {
test("ask organizer section has proper call-to-action", () => {
render(<Page />);
const askLink = screen.getByRole("link", { name: /Ask an organizer/i });
expect(askLink).toBeInTheDocument();
expect(askLink).toHaveAttribute("href", "#contact");
const askCta = screen.getByRole("button", { name: /ask an organizer/i });
expect(askCta).toBeInTheDocument();
expect(askCta).not.toHaveAttribute("href");
});
test("page maintains proper semantic structure", async () => {
@@ -223,16 +223,22 @@ describe("Page Flow Integration", () => {
expect(mainContent).toBeInTheDocument();
});
test("all interactive elements are accessible", () => {
test("all interactive elements are accessible", async () => {
render(<Page />);
// Check all buttons have proper roles
await waitFor(() => {
expect(screen.getAllByRole("button").length).toBeGreaterThan(0);
});
const buttons = screen.getAllByRole("button");
buttons.forEach((button) => {
expect(button).toBeInTheDocument();
});
// Check all links have proper roles
await waitFor(() => {
expect(screen.getAllByRole("link").length).toBeGreaterThan(0);
});
const links = screen.getAllByRole("link");
links.forEach((link) => {
expect(link).toBeInTheDocument();
+5 -4
View File
@@ -121,10 +121,11 @@ describe("User Journey Integration", () => {
screen.getByText("Get answers from an experienced organizer"),
).toBeInTheDocument();
// User clicks the ask organizer button (it's actually a link, not a button)
const askLink = screen.getByRole("link", { name: /Ask an organizer/i });
await user.click(askLink);
expect(askLink).toHaveAttribute("href", "#contact");
const askCta = screen.getByTestId("ask-organizer-cta");
await user.click(askCta);
expect(
await screen.findByRole("dialog", { name: /ask an organizer/i }),
).toBeInTheDocument();
});
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();
});
});