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
@@ -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>