Ask organizer modal implemented
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user