Add custom intervention modals

This commit is contained in:
adilallo
2026-05-01 22:05:05 -06:00
parent 58d0e33500
commit dee2dd800e
67 changed files with 3480 additions and 197 deletions
@@ -0,0 +1,358 @@
"use client";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import { CustomMethodCardWizardView } from "./CustomMethodCardWizard.view";
import type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types";
/**
* Shared 3-step add-custom-method-card flow (Figma Modal / Create — nodes
* `20066:14748`, `20094:48551`, `20066:14361`).
*/
const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
({ isOpen, onClose, onFinalize }) => {
const m = useMessages();
const t = useTranslation("common");
const w = m.create.customRule.customMethodCardWizard;
const copy = useMemo(
() => ({
step1: w.steps["1"],
step2: w.steps["2"],
step3: w.steps["3"],
footerFinalize: w.footer.finalize,
fieldModals: w.fieldModals,
}),
[w.fieldModals, w.footer.finalize, w.steps],
);
const fieldBodiesCopy = useMemo(
() => ({
requiredHint: copy.fieldModals.requiredHint,
text: copy.fieldModals.text,
badges: copy.fieldModals.badges,
upload: copy.fieldModals.upload,
proportion: copy.fieldModals.proportion,
}),
[copy.fieldModals],
);
const [wizardStep, setWizardStep] = useState<1 | 2 | 3>(1);
const [policyTitle, setPolicyTitle] = useState("");
const [policyDescription, setPolicyDescription] = useState("");
const [addFieldExpanded, setAddFieldExpanded] = useState(false);
const [fieldTypeModal, setFieldTypeModal] =
useState<AddCustomFieldType | null>(null);
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[]
>([]);
const [textBlockTitle, setTextBlockTitle] = useState("");
const [textPlaceholderBody, setTextPlaceholderBody] = useState("");
const [badgeBlockTitle, setBadgeBlockTitle] = useState("");
const [badgeOptions, setBadgeOptions] = useState<string[]>([]);
const [uploadBlockTitle, setUploadBlockTitle] = useState("");
const [uploadFileName, setUploadFileName] = useState<string | undefined>(
undefined,
);
const [proportionBlockTitle, setProportionBlockTitle] = useState("");
const [proportionDefault, setProportionDefault] = useState(50);
const fileInputRef = useRef<HTMLInputElement>(null);
const resetFieldTypeDrafts = useCallback(() => {
setTextBlockTitle("");
setTextPlaceholderBody("");
setBadgeBlockTitle("");
setBadgeOptions([]);
setUploadBlockTitle("");
setUploadFileName(undefined);
setProportionBlockTitle("");
setProportionDefault(50);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, []);
const reset = useCallback(() => {
setWizardStep(1);
setPolicyTitle("");
setPolicyDescription("");
setAddFieldExpanded(false);
setFieldTypeModal(null);
setDraftFieldBlocks([]);
resetFieldTypeDrafts();
}, [resetFieldTypeDrafts]);
useEffect(() => {
if (!isOpen) {
reset();
}
}, [isOpen, reset]);
const dismiss = useCallback(() => {
reset();
onClose();
}, [onClose, reset]);
const titleTrim = policyTitle.trim();
const descriptionTrim = policyDescription.trim();
const stepValid = useMemo(() => {
const titleOk =
titleTrim.length > 0 &&
titleTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
const descriptionOk =
descriptionTrim.length > 0 &&
descriptionTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
if (wizardStep === 1) return titleOk;
if (wizardStep === 2) return descriptionOk;
return titleOk && descriptionOk;
}, [
descriptionTrim.length,
titleTrim.length,
wizardStep,
]);
const fieldModalStepValid = useMemo(() => {
if (!fieldTypeModal) return false;
if (fieldTypeModal === "text") {
const t0 = textBlockTitle.trim();
return (
t0.length > 0 &&
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS
);
}
if (fieldTypeModal === "badges") {
const t0 = badgeBlockTitle.trim();
return (
t0.length > 0 &&
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS
);
}
if (fieldTypeModal === "upload") {
const t0 = uploadBlockTitle.trim();
return (
t0.length > 0 &&
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS
);
}
const t0 = proportionBlockTitle.trim();
return (
t0.length > 0 &&
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS &&
proportionDefault >= 1 &&
proportionDefault <= 100
);
}, [
badgeBlockTitle,
fieldTypeModal,
proportionBlockTitle,
proportionDefault,
textBlockTitle,
uploadBlockTitle,
]);
const headerTitle =
wizardStep === 1
? copy.step1.title
: wizardStep === 2
? copy.step2.title
: copy.step3.title;
const headerDescription =
wizardStep === 1
? copy.step1.description
: wizardStep === 2
? copy.step2.description
: copy.step3.description;
const fieldModalHeader = fieldTypeModal
? copy.fieldModals[fieldTypeModal]
: null;
const shellTitle = fieldModalHeader?.title ?? headerTitle;
const shellDescription = fieldModalHeader?.description ?? headerDescription;
const nextLabel = fieldTypeModal
? copy.fieldModals.addField
: wizardStep === 3
? copy.footerFinalize
: t("buttons.next");
const shellNextDisabled = fieldTypeModal
? !fieldModalStepValid
: !stepValid;
const handleShellClose = useCallback(() => {
if (fieldTypeModal) {
setFieldTypeModal(null);
return;
}
dismiss();
}, [dismiss, fieldTypeModal]);
const handleBack = useCallback(() => {
if (fieldTypeModal) {
setFieldTypeModal(null);
return;
}
if (wizardStep === 1) {
dismiss();
return;
}
setWizardStep((s) => (s === 2 ? 1 : 2));
}, [dismiss, fieldTypeModal, wizardStep]);
const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => {
resetFieldTypeDrafts();
setFieldTypeModal(ft);
}, [resetFieldTypeDrafts]);
const handleFileChosen = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
setUploadFileName(file?.name);
},
[],
);
const handleBadgeAddOption = useCallback((label: string) => {
setBadgeOptions((prev) =>
prev.includes(label) ? prev : [...prev, label],
);
}, []);
const appendFieldBlock = useCallback(() => {
if (!fieldTypeModal || !fieldModalStepValid) return;
const id = crypto.randomUUID();
let block: CustomMethodCardFieldBlock;
switch (fieldTypeModal) {
case "text":
block = {
kind: "text",
id,
blockTitle: textBlockTitle.trim(),
placeholderText: textPlaceholderBody,
};
break;
case "badges":
block = {
kind: "badges",
id,
blockTitle: badgeBlockTitle.trim(),
options: [...badgeOptions],
};
break;
case "upload":
block = {
kind: "upload",
id,
blockTitle: uploadBlockTitle.trim(),
fileName: uploadFileName,
};
break;
default:
block = {
kind: "proportion",
id,
blockTitle: proportionBlockTitle.trim(),
defaultPercent: proportionDefault,
};
}
setDraftFieldBlocks((prev) => [...prev, block]);
setFieldTypeModal(null);
}, [
badgeBlockTitle,
badgeOptions,
fieldModalStepValid,
fieldTypeModal,
proportionBlockTitle,
proportionDefault,
textBlockTitle,
textPlaceholderBody,
uploadBlockTitle,
uploadFileName,
]);
const handleNext = useCallback(() => {
if (fieldTypeModal) {
appendFieldBlock();
return;
}
if (!stepValid) return;
if (wizardStep === 3) {
onFinalize({
title: titleTrim,
description: descriptionTrim,
fieldBlocks: draftFieldBlocks,
});
dismiss();
return;
}
setWizardStep((s) => (s === 1 ? 2 : 3));
}, [
appendFieldBlock,
descriptionTrim,
dismiss,
draftFieldBlocks,
fieldTypeModal,
onFinalize,
stepValid,
titleTrim,
wizardStep,
]);
return (
<CustomMethodCardWizardView
isOpen={isOpen}
onDismiss={handleShellClose}
wizardStep={wizardStep}
title={shellTitle}
description={shellDescription}
policyTitle={policyTitle}
policyDescription={policyDescription}
addFieldExpanded={addFieldExpanded}
copy={copy}
maxChars={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
onPolicyTitleChange={setPolicyTitle}
onPolicyDescriptionChange={setPolicyDescription}
onPressAddCustomField={() => setAddFieldExpanded(true)}
onSelectFieldType={handleSelectFieldType}
fieldTypeModal={fieldTypeModal}
fieldBodiesCopy={fieldBodiesCopy}
fieldBodiesProps={{
textBlockTitle,
textPlaceholderBody,
onTextBlockTitleChange: setTextBlockTitle,
onTextPlaceholderBodyChange: setTextPlaceholderBody,
badgeBlockTitle,
badgeOptions,
onBadgeBlockTitleChange: setBadgeBlockTitle,
onBadgeAddOption: handleBadgeAddOption,
uploadBlockTitle,
onUploadBlockTitleChange: setUploadBlockTitle,
fileInputRef,
onFileChosen: handleFileChosen,
proportionBlockTitle,
proportionDefault,
onProportionBlockTitleChange: setProportionBlockTitle,
onProportionDefaultChange: setProportionDefault,
}}
nextDisabled={shellNextDisabled}
nextLabel={nextLabel}
showBackButton
onBack={handleBack}
onNext={handleNext}
stepper={!fieldTypeModal}
/>
);
},
);
CustomMethodCardWizardContainer.displayName = "CustomMethodCardWizard";
export default CustomMethodCardWizardContainer;
@@ -0,0 +1,120 @@
import type { RefObject } from "react";
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
export interface CustomMethodCardWizardFieldBodiesCopy {
requiredHint: string;
text: {
blockTitleLabel: string;
blockTitlePlaceholder: string;
placeholderLabel: string;
placeholderFieldPlaceholder: string;
};
badges: {
blockTitleLabel: string;
blockTitlePlaceholder: string;
optionsLabel: string;
addOptionLabel: string;
};
upload: {
blockTitleLabel: string;
blockTitlePlaceholder: string;
uploadFileInputAriaLabel: string;
uploadHint: string;
};
proportion: {
blockTitleLabel: string;
blockTitlePlaceholder: string;
defaultLabel: string;
decrementAriaLabel: string;
incrementAriaLabel: string;
};
}
export interface CustomMethodCardWizardCopy {
step1: { title: string; description: string; fieldPlaceholder: string };
step2: { title: string; description: string; fieldPlaceholder: string };
step3: { title: string; description: string };
footerFinalize: string;
fieldModals: {
addField: string;
requiredHint: string;
text: CustomMethodCardWizardFieldBodiesCopy["text"] & {
title: string;
description: string;
};
badges: CustomMethodCardWizardFieldBodiesCopy["badges"] & {
title: string;
description: string;
};
upload: CustomMethodCardWizardFieldBodiesCopy["upload"] & {
title: string;
description: string;
};
proportion: CustomMethodCardWizardFieldBodiesCopy["proportion"] & {
title: string;
description: string;
};
};
}
export interface CustomMethodCardWizardProps {
isOpen: boolean;
onClose: () => void;
/** Called when the user completes step 3; parent assigns id and persists state. */
onFinalize: (payload: {
title: string;
description: string;
fieldBlocks: CustomMethodCardFieldBlock[];
}) => void;
}
export interface CustomMethodCardWizardFieldBodiesViewProps {
fieldType: AddCustomFieldType;
copy: CustomMethodCardWizardFieldBodiesCopy;
textBlockTitle: string;
textPlaceholderBody: string;
onTextBlockTitleChange: (_v: string) => void;
onTextPlaceholderBodyChange: (_v: string) => void;
badgeBlockTitle: string;
badgeOptions: string[];
onBadgeBlockTitleChange: (_v: string) => void;
onBadgeAddOption: (_v: string) => void;
uploadBlockTitle: string;
onUploadBlockTitleChange: (_v: string) => void;
fileInputRef: RefObject<HTMLInputElement | null>;
onFileChosen: (e: React.ChangeEvent<HTMLInputElement>) => void;
proportionBlockTitle: string;
proportionDefault: number;
onProportionBlockTitleChange: (_v: string) => void;
onProportionDefaultChange: (_v: number) => void;
}
export interface CustomMethodCardWizardViewProps {
isOpen: boolean;
onDismiss: () => void;
wizardStep: 1 | 2 | 3;
title: string;
description: string;
policyTitle: string;
policyDescription: string;
addFieldExpanded: boolean;
copy: CustomMethodCardWizardCopy;
maxChars: number;
onPolicyTitleChange: (v: string) => void;
onPolicyDescriptionChange: (v: string) => void;
onPressAddCustomField: () => void;
onSelectFieldType: (t: AddCustomFieldType) => void;
fieldTypeModal: AddCustomFieldType | null;
fieldBodiesCopy: CustomMethodCardWizardFieldBodiesCopy;
fieldBodiesProps: Omit<
CustomMethodCardWizardFieldBodiesViewProps,
"fieldType" | "copy"
>;
nextDisabled: boolean;
nextLabel: string;
showBackButton: boolean;
onBack: () => void;
onNext: () => void;
stepper: boolean;
}
@@ -0,0 +1,95 @@
"use client";
import { memo } from "react";
import Create from "../../../../components/modals/Create";
import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea";
import AddCustomField from "../../../../components/controls/AddCustomField";
import { CustomMethodCardWizardFieldBodiesView } from "./CustomMethodCardWizardFieldBodies.view";
import type { CustomMethodCardWizardViewProps } from "./CustomMethodCardWizard.types";
function CustomMethodCardWizardViewComponent({
isOpen,
onDismiss,
wizardStep,
title,
description,
policyTitle,
policyDescription,
addFieldExpanded,
copy,
maxChars,
onPolicyTitleChange,
onPolicyDescriptionChange,
onPressAddCustomField,
onSelectFieldType,
fieldTypeModal,
fieldBodiesCopy,
fieldBodiesProps,
nextDisabled,
nextLabel,
showBackButton,
onBack,
onNext,
stepper,
}: CustomMethodCardWizardViewProps) {
return (
<Create
isOpen={isOpen}
onClose={onDismiss}
title={title}
description={description}
showBackButton={showBackButton}
showNextButton
onBack={onBack}
onNext={onNext}
nextButtonText={nextLabel}
nextButtonDisabled={nextDisabled}
currentStep={wizardStep}
totalSteps={3}
stepper={stepper}
backdropVariant="blurredYellow"
>
{fieldTypeModal ? (
<CustomMethodCardWizardFieldBodiesView
fieldType={fieldTypeModal}
copy={fieldBodiesCopy}
{...fieldBodiesProps}
/>
) : null}
{!fieldTypeModal && wizardStep === 1 ? (
<InputWithCounter
placeholder={copy.step1.fieldPlaceholder}
value={policyTitle}
onChange={onPolicyTitleChange}
maxLength={maxChars}
/>
) : null}
{!fieldTypeModal && wizardStep === 2 ? (
<TextArea
appearance="default"
formHeader={false}
placeholder={copy.step2.fieldPlaceholder}
value={policyDescription}
maxLength={maxChars}
onChange={(e) => onPolicyDescriptionChange(e.target.value)}
textHint={`${policyDescription.length}/${maxChars}`}
className="w-full"
rows={4}
/>
) : null}
{!fieldTypeModal && wizardStep === 3 ? (
<AddCustomField
active={addFieldExpanded}
onPressAdd={onPressAddCustomField}
onSelectFieldType={onSelectFieldType}
/>
) : null}
</Create>
);
}
export const CustomMethodCardWizardView = memo(
CustomMethodCardWizardViewComponent,
);
CustomMethodCardWizardView.displayName = "CustomMethodCardWizardView";
@@ -0,0 +1,161 @@
"use client";
import { memo } from "react";
import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea";
import TextInput from "../../../../components/controls/TextInput";
import Upload from "../../../../components/controls/Upload";
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import InputLabel from "../../../../components/type/InputLabel";
import ApplicableScopeField from "../ApplicableScopeField";
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types";
const TEXT_PLACEHOLDER_MAX = 8000;
function CustomMethodCardWizardFieldBodiesViewComponent({
fieldType,
copy,
textBlockTitle,
textPlaceholderBody,
onTextBlockTitleChange,
onTextPlaceholderBodyChange,
badgeBlockTitle,
badgeOptions,
onBadgeBlockTitleChange,
onBadgeAddOption,
uploadBlockTitle,
onUploadBlockTitleChange,
fileInputRef,
onFileChosen,
proportionBlockTitle,
proportionDefault,
onProportionBlockTitleChange,
onProportionDefaultChange,
}: CustomMethodCardWizardFieldBodiesViewProps) {
if (fieldType === "text") {
return (
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
<InputWithCounter
label={copy.text.blockTitleLabel}
placeholder={copy.text.blockTitlePlaceholder}
value={textBlockTitle}
onChange={onTextBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/>
<div className="flex flex-col gap-2">
<InputLabel
label={copy.text.placeholderLabel}
helpIcon
size="s"
palette="default"
/>
<TextArea
formHeader={false}
appearance="embedded"
value={textPlaceholderBody}
onChange={(e) => onTextPlaceholderBodyChange(e.target.value)}
maxLength={TEXT_PLACEHOLDER_MAX}
placeholder={copy.text.placeholderFieldPlaceholder}
textHint={`${textPlaceholderBody.length}/${TEXT_PLACEHOLDER_MAX}`}
className="w-full"
rows={3}
/>
</div>
</div>
);
}
if (fieldType === "badges") {
return (
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
<div className="flex flex-col gap-2">
<InputLabel
label={copy.badges.blockTitleLabel}
helpIcon
helperText={copy.requiredHint}
size="s"
palette="default"
/>
<TextInput
formHeader={false}
placeholder={copy.badges.blockTitlePlaceholder}
value={badgeBlockTitle}
onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon={false}
/>
</div>
<ApplicableScopeField
label={copy.badges.optionsLabel}
addLabel={copy.badges.addOptionLabel}
scopes={badgeOptions}
selectedScopes={badgeOptions}
onToggleScope={() => {
/* product: all badge options stay selected */
}}
onAddScope={onBadgeAddOption}
/>
</div>
);
}
if (fieldType === "upload") {
return (
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
<input
ref={fileInputRef}
type="file"
className="sr-only"
tabIndex={-1}
aria-label={copy.upload.uploadFileInputAriaLabel}
onChange={onFileChosen}
/>
<InputWithCounter
label={copy.upload.blockTitleLabel}
placeholder={copy.upload.blockTitlePlaceholder}
value={uploadBlockTitle}
onChange={onUploadBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/>
<Upload
hintText={copy.upload.uploadHint}
onClick={() => fileInputRef.current?.click()}
/>
</div>
);
}
return (
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
<InputWithCounter
label={copy.proportion.blockTitleLabel}
placeholder={copy.proportion.blockTitlePlaceholder}
value={proportionBlockTitle}
onChange={onProportionBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/>
<IncrementerBlock
label={copy.proportion.defaultLabel}
value={proportionDefault}
min={1}
max={100}
step={1}
onChange={onProportionDefaultChange}
formatValue={(v) => `${v}%`}
decrementAriaLabel={copy.proportion.decrementAriaLabel}
incrementAriaLabel={copy.proportion.incrementAriaLabel}
blockClassName="w-full"
/>
</div>
);
}
export const CustomMethodCardWizardFieldBodiesView = memo(
CustomMethodCardWizardFieldBodiesViewComponent,
);
CustomMethodCardWizardFieldBodiesView.displayName =
"CustomMethodCardWizardFieldBodiesView";
@@ -0,0 +1,2 @@
export { default } from "./CustomMethodCardWizard.container";
export type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types";