diff --git a/.cursor/rules/create-flow.mdc b/.cursor/rules/create-flow.mdc index d9ffda7..cf8d0a2 100644 --- a/.cursor/rules/create-flow.mdc +++ b/.cursor/rules/create-flow.mdc @@ -16,6 +16,9 @@ alwaysApply: false `app/(app)/create/utils/createFlowScreenRegistry.ts`. Never branch on layout kind inside a screen — pick the matching shell (`CreateFlowStepShell` / `CreateFlowTwoColumnSelectShell`). +- Keep create-flow step routing centralized in + `app/(app)/create/utils/createFlowPaths.ts` (`createFlowStepPath`, + `CREATE_ROUTES`) — do not introduce new hardcoded `/create/...` literals. - Shared create-flow pieces go in `app/(app)/create/components/` (layout shells, field composites). Generic primitives go in `app/components/`. @@ -49,8 +52,9 @@ file are a smell once they're used more than once. namespace (see `localization.mdc`). - Modal `sections` defaults are DB-shaped seed placeholders, not UI constants — expect replacement with live data. -- Modal `sections` defaults are DB-shaped seed placeholders, not UI - constants — expect replacement with live data. +- Custom-rule facet mappings (step ids, template-category aliases, selection + keys, strip keys) must be sourced from `lib/create/customRuleFacets.ts` + (`CUSTOM_RULE_FACETS`) instead of adding new ad-hoc switches/tables. ## Interaction tracking diff --git a/.env.example b/.env.example index 4e6ab5a..e3cdfdf 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,7 @@ NEXT_PUBLIC_ENABLE_BACKEND_SYNC= # Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.). # NEXT_PUBLIC_RUM_DASHBOARD_URL= + +# Writable directory for `POST /api/uploads` (community photo + custom-method attachments). +# In production (e.g. Cloudron localstorage mount), set to the mounted path. Local dev example: +# UPLOAD_ROOT="/absolute/path/to/community-rule/var/uploads" diff --git a/.gitignore b/.gitignore index 594818a..5c074bb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ npm-cache/ # testing /coverage +# Local user uploads (see UPLOAD_ROOT in .env.example) +/var/uploads + # Playwright /test-results/ /playwright-report/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97ab22d..77ee7b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,6 +40,8 @@ deployment-pipeline work. | GET | `/api/auth/magic-link/verify` | Validate token, set cookie, redirect. | | POST | `/api/auth/logout` | Clear session. | | GET / PUT | `/api/drafts/me` | Load or save the create-flow draft. | +| POST | `/api/uploads` | Authenticated multipart upload (create-flow images / PDFs); requires `UPLOAD_ROOT`. | +| GET | `/api/uploads/[id]` | Stream a previously uploaded file by opaque id (public read). | | GET / POST | `/api/rules` | List or publish rules. | | GET | `/api/templates` | List curated templates. Optional repeatable `facet.=` query params re-rank results (and may include `scores` in the JSON). See [docs/guides/template-recommendation-matrix.md](docs/guides/template-recommendation-matrix.md) §9.1. | | GET | `/api/create-flow/methods` | Facet-aware scores for custom-rule card steps: required `section` (`communication` \| `membership` \| `decisionApproaches` \| `conflictManagement`) and optional `facet.*` params (same facet groups as `/api/templates`). Returns `methods` with match metadata for re-ordering in the wizard. | diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 60a816b..642198f 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -7,15 +7,33 @@ import { useState, type ReactNode, } from "react"; -import { usePathname, useRouter } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext"; import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation"; import { useCreateFlowExit } from "./hooks/useCreateFlowExit"; import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize"; import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions"; +import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport"; import CreateFlowFooter from "../../components/navigation/CreateFlowFooter"; import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav"; -import { getNextStep, getStepIndex } from "./utils/flowSteps"; +import { + getNextStep, + getStepIndex, + parseReviewReturnSearchParam, + createFlowStepUsesSelectSplitScroll, + TEMPLATES_FACET_RECOMMEND_QUERY, + TEMPLATES_FACET_RECOMMEND_VALUE, + TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY, + TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE, +} from "./utils/flowSteps"; +import { + CREATE_FLOW_SYNC_DRAFT_QUERY, + CREATE_FLOW_SYNC_DRAFT_VALUE, + CREATE_ROUTES, + createFlowStepPath, + createFlowStepPathAfterStrippingReviewReturn, + createFlowStepPathWithSyncDraft, +} from "./utils/createFlowPaths"; import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress"; import { createFlowStepUsesCenteredTextLayout, @@ -32,7 +50,14 @@ import { clearAnonymousCreateFlowStorage, setTransferPendingFlag, } from "./utils/anonymousDraftStorage"; -import { deleteServerDraft } from "../../../lib/create/api"; +import { + createFlowStateFromPublishedRule, + isPublishedRuleHydratePatchIncomplete, + methodSectionsPinsFromPublishedHydratePatch, +} from "../../../lib/create/publishedDocumentToCreateFlowState"; +import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets"; +import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule"; +import { runCompletedStepExit } from "./utils/runCompletedStepExit"; import messages from "../../../messages/en/index"; import { CREATE_FLOW_FOOTER_BUTTON_CLASS, @@ -40,6 +65,7 @@ import { } from "./utils/createFlowFooterClassNames"; import { CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP, + methodCardFacetSectionForConfirmStep, type CustomRuleConfirmFooterStep, } from "./utils/customRuleConfirmFooterSteps"; import { getDefaultFooterLabel } from "./utils/createFlowFooterLabels"; @@ -47,7 +73,9 @@ import { useAuthModal } from "../../contexts/AuthModalContext"; import { useMessages, useTranslation } from "../../contexts/MessagesContext"; import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; import { SignedInDraftHydration } from "./SignedInDraftHydration"; +import { CreateFlowPendingAvatarFlush } from "./components/CreateFlowPendingAvatarFlush"; import Alert from "../../components/modals/Alert"; +import Share from "../../components/modals/Share"; import { CreateFlowDraftSaveBannerProvider, useCreateFlowDraftSaveBanner, @@ -109,6 +137,8 @@ function CreateFlowLayoutContent({ const tLogin = useTranslation("pages.login"); const router = useRouter(); const pathname = usePathname(); + const searchParams = useSearchParams(); + const reviewReturnTarget = parseReviewReturnSearchParam(searchParams); const { openLogin } = useAuthModal(); const skipCommunitySave = sessionResolved && Boolean(sessionUser); const { @@ -121,8 +151,14 @@ function CreateFlowLayoutContent({ } = useCreateFlowNavigation( skipCommunitySave ? { skipCommunitySave: true } : undefined, ); - const { state, clearState, updateState, resetCustomRuleSelections } = - useCreateFlow(); + const { + state, + clearState, + updateState, + resetCustomRuleSelections, + setMethodSectionsPinCommitted, + replaceState, + } = useCreateFlow(); const { draftSaveBannerMessage, setDraftSaveBannerMessage } = useCreateFlowDraftSaveBanner(); const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] = @@ -132,13 +168,55 @@ function CreateFlowLayoutContent({ >(null); const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] = useState(false); + const [completedFlowBanner, setCompletedFlowBanner] = useState<{ + key: string; + status: "positive" | "danger"; + title: string; + description?: string; + } | null>(null); + const [shareModalOpen, setShareModalOpen] = useState(false); + + const { + copyPublishedRuleLink, + mailtoPublishedRule, + sharePublishedRuleViaSignal, + sharePublishedRuleViaSlack, + sharePublishedRuleViaDiscord, + onSelectExportFormat: onCompletedExportFormat, + } = useCompletedRuleShareExport({ + setActionBanner: setCompletedFlowBanner, + }); + + const handleOpenCompletedShareModal = () => { + if (!readLastPublishedRule()) { + setCompletedFlowBanner({ + key: "completedShareNoRule", + status: "danger", + title: create.reviewAndComplete.completed.shareNoRuleTitle, + description: create.reviewAndComplete.completed.shareNoRuleDescription, + }); + return; + } + setShareModalOpen(true); + }; + + const loginReturnPath = + currentStep === "edit-rule" + ? createFlowStepPathWithSyncDraft("edit-rule") + : createFlowStepPathWithSyncDraft("final-review"); const { publishBannerMessage, setPublishBannerMessage, isPublishing, finalize: handleFinalize, - } = useCreateFlowFinalize({ state, router, openLogin }); + } = useCreateFlowFinalize({ + state, + router, + openLogin, + updateState, + loginReturnPath, + }); const { isTemplateReviewRoute, @@ -152,7 +230,7 @@ function CreateFlowLayoutContent({ pathname, state, updateState, - resetCustomRuleSelections, + replaceState, router, }); @@ -174,12 +252,11 @@ function CreateFlowLayoutContent({ // For signed-in users we also DELETE the server draft so a future visit to // /create starts fresh instead of rehydrating yesterday's work. if (currentStep === "completed") { - clearState(); - clearAnonymousCreateFlowStorage(); - if (sessionUser) { - void deleteServerDraft(); - } - router.push("/"); + runCompletedStepExit({ + clearState, + clearAnonymousCreateFlowStorage, + router, + }); return; } @@ -193,7 +270,7 @@ function CreateFlowLayoutContent({ variant: "saveProgress", nextPath: returnToTemplateReview ?? - `${pathname ?? "/create"}?syncDraft=1`, + `${pathname != null && pathname.length > 0 ? pathname : CREATE_ROUTES.createRoot}?${CREATE_FLOW_SYNC_DRAFT_QUERY}=${CREATE_FLOW_SYNC_DRAFT_VALUE}`, backdropVariant: "blurredYellow", }); return; @@ -209,7 +286,7 @@ function CreateFlowLayoutContent({ sessionUser && currentStep === "community-save" ) { - router.replace("/create/review"); + router.replace(CREATE_ROUTES.review); } }, [sessionResolved, sessionUser, currentStep, router]); @@ -221,6 +298,78 @@ function CreateFlowLayoutContent({ } }, [currentStep]); + useEffect(() => { + if (currentStep !== "edit-rule") return; + const last = readLastPublishedRule(); + if (!last) { + router.replace(CREATE_ROUTES.completed); + return; + } + const editingId = state.editingPublishedRuleId?.trim() ?? ""; + if (editingId.length > 0 && editingId !== last.id) { + router.replace(CREATE_ROUTES.completed); + return; + } + const titleOk = + typeof state.title === "string" && state.title.trim().length > 0; + const sectionsClear = (state.sections?.length ?? 0) === 0; + const patch = createFlowStateFromPublishedRule(last); + const pinPatch = methodSectionsPinsFromPublishedHydratePatch(patch); + const needsPinMerge = METHOD_FACET_API_SECTION_IDS.some( + (key) => + pinPatch[key] === true && + state.methodSectionsPinCommitted?.[key] !== true, + ); + /** + * Skip repeat merges once template `sections` are cleared **and** published + * facet selections are present. Without the selection check, TopNav **Edit** + * (`sections: []` before navigate) matched only `sectionsClear` and skipped + * the merge — method-card steps saw empty `selected*Ids` until a confirm. + * + * Still merge {@link methodSectionsPinsFromPublishedHydratePatch}: selections + * may already match draft state while compact CardStack pins stayed false + * (pins are normally set only on facet **Confirm**). + */ + if ( + titleOk && + editingId === last.id && + sectionsClear && + !isPublishedRuleHydratePatchIncomplete(state, patch) + ) { + if (needsPinMerge) { + updateState({ + methodSectionsPinCommitted: { + ...state.methodSectionsPinCommitted, + ...pinPatch, + }, + }); + } + return; + } + updateState({ + ...patch, + methodSectionsPinCommitted: { + ...state.methodSectionsPinCommitted, + ...pinPatch, + }, + }); + }, [ + currentStep, + router, + updateState, + state.editingPublishedRuleId, + state.title, + state.methodSectionsPinCommitted, + state.sections?.length, + state.customMethodCardMetaById, + ]); + + useEffect(() => { + if (currentStep !== "completed") { + setCompletedFlowBanner(null); + } + }, [currentStep]); + const handleCommunitySaveMagicLinkSubmit = useCallback(async () => { setCommunitySaveMagicLinkError(null); setCommunitySaveMagicLinkSuccess(false); @@ -260,14 +409,13 @@ function CreateFlowLayoutContent({ const isCompletedStep = currentStep === "completed"; const isRightRailStep = currentStep === "decision-approaches"; - const isFinalReviewStep = currentStep === "final-review"; + const isFinalReviewLike = + currentStep === "final-review" || currentStep === "edit-rule"; const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep); /** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */ - const isSelectSplitScrollStep = - currentStep === "community-size" || - currentStep === "community-structure" || - currentStep === "core-values" || - currentStep === "decision-approaches"; + const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll( + currentStep, + ); const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1; /** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */ @@ -275,7 +423,7 @@ function CreateFlowLayoutContent({ ? "items-stretch overflow-y-auto md:overflow-hidden" : isSelectSplitScrollStep ? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden" - : isFinalReviewStep || isCardLayoutStep || isTemplateReviewRoute + : isFinalReviewLike || isCardLayoutStep || isTemplateReviewRoute ? "items-start justify-center overflow-y-auto" : "items-start justify-center overflow-y-auto md:items-center"; @@ -289,7 +437,8 @@ function CreateFlowLayoutContent({ : "max-md:flex-col max-md:items-center"; const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`; const saveDraftOnExit = - Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX; + Boolean(sessionUser) && + (stepIdx >= SAVE_EXIT_FROM_STEP_INDEX || currentStep === "edit-rule"); const proportionBarProgress = getProportionBarProgressForCreateFlowStep( currentStep, @@ -305,13 +454,16 @@ function CreateFlowLayoutContent({ currentStep != null ? CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP.get(currentStep) : undefined; + /** Method-card steps tolerate `reviewReturn={edit-rule}` when `edit-rule ∉ FLOW_STEP_ORDER` makes `nextStep` null. Core values stay gated on linear `nextStep`. */ + const showCustomRuleFooterConfirm = + Boolean(customRuleConfirmFooter) && + (nextStep != null || + (reviewReturnTarget != null && + methodCardFacetSectionForConfirmStep(customRuleConfirmFooter.step) != + undefined)); /** - * Top banner stack rendered above the main column when any of the - * shell-level statuses are active. Each entry maps to one ``; - * we filter out empty messages so the wrapper only mounts when at - * least one banner is actually showing. Order here is the visual - * stacking order (top → bottom). + * Top banner stack above the main column; order is top → bottom. */ const topBanners: Array<{ key: string; @@ -366,6 +518,15 @@ function CreateFlowLayoutContent({ onClose: () => setCommunitySaveMagicLinkSuccess(false), } : null, + completedFlowBanner + ? { + key: `completedFlow-${completedFlowBanner.key}`, + status: completedFlowBanner.status, + title: completedFlowBanner.title, + description: completedFlowBanner.description, + onClose: () => setCompletedFlowBanner(null), + } + : null, ].filter((b): b is NonNullable => b !== null); return ( @@ -401,14 +562,43 @@ function CreateFlowLayoutContent({ + + + + setShareModalOpen(false)} + onCopyLink={() => void copyPublishedRuleLink()} + onEmailShare={mailtoPublishedRule} + onSignalShare={() => void sharePublishedRuleViaSignal()} + onSlackShare={() => void sharePublishedRuleViaSlack()} + onDiscordShare={() => void sharePublishedRuleViaDiscord()} + /> void handleOpenCompletedShareModal() : undefined + } + onSelectExportFormat={ + isCompletedStep ? onCompletedExportFormat : undefined + } onEdit={ isCompletedStep - ? () => router.push("/create/final-review") + ? () => { + const last = readLastPublishedRule(); + if (!last) return; + updateState({ + editingPublishedRuleId: last.id, + sections: [], + }); + router.push(createFlowStepPath("edit-rule")); + } : undefined } onExit={(opts) => void handleExit(opts)} @@ -425,7 +615,7 @@ function CreateFlowLayoutContent({ {!isCompletedStep && ( {footer.createFromTemplate} - ) : customRuleConfirmFooter && nextStep ? ( + ) : showCustomRuleFooterConfirm && + customRuleConfirmFooter ? ( - ) : nextStep ? ( + ) : nextStep || isFinalReviewLike ? ( + {/* eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL */} + {uploadPreviewImageAlt} + + ) : ( + { + if (!busy) uploadInputRef.current?.click(); + }} + /> + )} + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + + ); +} + +function CustomMethodCardFieldBlocksSummaryComponent({ + blocks, + onBlocksChange, +}: CustomMethodCardFieldBlocksSummaryProps) { + const m = useMessages(); + const wiz = m.create.customRule.customMethodCardWizard; + const fm = wiz.fieldModals; + const em = wiz.editModal; + const emptyValue = em.readout.emptyValue; + const noFileChosen = em.readout.noFileChosen; + const readOnly = !onBlocksChange; + + const patch = useCallback( + (next: CustomMethodCardFieldBlock[]) => { + onBlocksChange?.(next); + }, + [onBlocksChange], + ); + + return ( +
+ {blocks.map((block) => { + if (block.kind === "text") { + return ( + + patch( + mapBlockById(blocks, block.id, (b) => + b.kind === "text" + ? { ...b, placeholderText: v.slice(0, TEXT_VALUE_MAX) } + : b, + ), + ) + } + disabled={readOnly} + /> + ); + } + + if (block.kind === "badges") { + if (readOnly) { + return ( +
+ + {block.options.length > 0 ? ( +
+ {block.options.map((opt, idx) => ( + + ))} +
+ ) : ( +

+ {emptyValue} +

+ )} +
+ ); + } + return ( + + patch( + mapBlockById(blocks, block.id, (b) => + b.kind === "badges" + ? { ...b, options: b.options.filter((o) => o !== scope) } + : b, + ), + ) + } + onAddScope={(scope) => + patch( + mapBlockById(blocks, block.id, (b) => { + if (b.kind !== "badges") return b; + if (b.options.includes(scope) || b.options.length >= 50) + return b; + return { ...b, options: [...b.options, scope] }; + }), + ) + } + /> + ); + } + + if (block.kind === "upload") { + return ( +
+ {readOnly ? ( +
+ + {block.assetUrl?.trim() ? ( + // eslint-disable-next-line @next/next/no-img-element + { + ) : ( +

+ {noFileChosen} +

+ )} +
+ ) : ( + + )} +
+ ); + } + + return ( + + patch( + mapBlockById(blocks, block.id, (b) => + b.kind === "proportion" ? { ...b, defaultPercent: v } : b, + ), + ) + } + formatValue={(v) => `${v}%`} + decrementAriaLabel={fm.proportion.decrementAriaLabel} + incrementAriaLabel={fm.proportion.incrementAriaLabel} + /> + ); + })} +
+ ); +} + +const CustomMethodCardFieldBlocksSummary = memo( + CustomMethodCardFieldBlocksSummaryComponent, +); +CustomMethodCardFieldBlocksSummary.displayName = + "CustomMethodCardFieldBlocksSummary"; + +export default CustomMethodCardFieldBlocksSummary; diff --git a/app/(app)/create/components/CustomMethodCardModalBody.tsx b/app/(app)/create/components/CustomMethodCardModalBody.tsx new file mode 100644 index 0000000..e601371 --- /dev/null +++ b/app/(app)/create/components/CustomMethodCardModalBody.tsx @@ -0,0 +1,68 @@ +"use client"; + +import ContentLockup from "../../../components/type/ContentLockup"; +import { useMessages } from "../../../contexts/MessagesContext"; +import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks"; +import type { CreateFlowState } from "../types"; +import CustomMethodCardFieldBlocksSummary from "./CustomMethodCardFieldBlocksSummary"; +import CustomMethodCardPresetEditPlaceholder from "./CustomMethodCardPresetEditPlaceholder"; + +/** Body for Create modals when the card is user-authored (custom UUID). */ +export default function CustomMethodCardModalBody({ + cardId, + blocksById, + /** When set, used instead of `blocksById[cardId]` (e.g. final-review draft). */ + blocksOverride, + onFieldBlocksChange, + policyMeta, + /** + * When false, omit {@link ContentLockup} for title/description (Customize mode: + * {@link MethodCardCustomizeModalHeader} already edits them). Summary line still shows. + * @default true + */ + showPolicyContentLockupWhenNoBlocks = true, +}: { + cardId: string; + blocksById: CreateFlowState["customMethodCardFieldBlocksById"]; + blocksOverride?: CustomMethodCardFieldBlock[] | null; + onFieldBlocksChange?: (_blocks: CustomMethodCardFieldBlock[]) => void; + policyMeta?: { label: string; supportText: string }; + showPolicyContentLockupWhenNoBlocks?: boolean; +}) { + const m = useMessages(); + const blocks = blocksOverride ?? blocksById?.[cardId]; + if (blocks && blocks.length > 0) { + return ( + + ); + } + + const label = policyMeta?.label?.trim() ?? ""; + const support = policyMeta?.supportText?.trim() ?? ""; + if (label.length > 0 || support.length > 0) { + const noFieldsHint = m.create.customRule.customMethodCardWizard.editModal + .noCustomFieldsYet; + return ( +
+ {showPolicyContentLockupWhenNoBlocks ? ( + 0 ? label : undefined} + description={support.length > 0 ? support : undefined} + variant="modal" + alignment="left" + /> + ) : null} + {noFieldsHint.trim().length > 0 ? ( +

+ {noFieldsHint} +

+ ) : null} +
+ ); + } + + return ; +} diff --git a/app/(app)/create/components/CustomMethodCardPresetEditPlaceholder.tsx b/app/(app)/create/components/CustomMethodCardPresetEditPlaceholder.tsx new file mode 100644 index 0000000..6c6cf68 --- /dev/null +++ b/app/(app)/create/components/CustomMethodCardPresetEditPlaceholder.tsx @@ -0,0 +1,26 @@ +"use client"; + +/** + * Shown in method-card Create modals and final-review chip edit when the chip + * is user-authored (`customMethodCardMetaById`) — preset section editors do + * not apply until structured parity exists with wizard field blocks. + */ + +import { memo } from "react"; +import { useMessages } from "../../../contexts/MessagesContext"; + +function CustomMethodCardPresetEditPlaceholderComponent() { + const m = useMessages(); + const body = m.create.customRule.customMethodCardWizard.editModal.placeholderBody; + + return ( +

+ {body} +

+ ); +} + +CustomMethodCardPresetEditPlaceholderComponent.displayName = + "CustomMethodCardPresetEditPlaceholder"; + +export default memo(CustomMethodCardPresetEditPlaceholderComponent); diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx new file mode 100644 index 0000000..fabdee3 --- /dev/null +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx @@ -0,0 +1,433 @@ +"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 type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.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( + ({ isOpen, onClose, onFinalize, onPersistCustomUploadFile }) => { + const m = useMessages(); + const t = useTranslation("common"); + const tUpload = useTranslation("create.upload"); + const w = m.create.customRule.customMethodCardWizard; + const menuCopy = m.create.customRule.modalKebabMenu; + + const copy = useMemo( + () => ({ + step1: w.steps["1"], + step2: w.steps["2"], + step3: w.steps["3"], + step3BlocksList: w.step3BlocksList, + fieldTypeLabels: { + text: w.addCustomField.fieldTypes.text, + badges: w.addCustomField.fieldTypes.badges, + upload: w.addCustomField.fieldTypes.upload, + proportion: w.addCustomField.fieldTypes.proportion, + }, + footerFinalize: w.footer.finalize, + fieldModals: w.fieldModals, + }), + [ + w.addCustomField.fieldTypes, + w.fieldModals, + w.footer.finalize, + w.step3BlocksList, + 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(null); + const [draftFieldBlocks, setDraftFieldBlocks] = useState< + CustomMethodCardFieldBlock[] + >([]); + + const [textBlockTitle, setTextBlockTitle] = useState(""); + const [textPlaceholderBody, setTextPlaceholderBody] = useState(""); + const [badgeBlockTitle, setBadgeBlockTitle] = useState(""); + const [badgeOptions, setBadgeOptions] = useState([]); + const [uploadBlockTitle, setUploadBlockTitle] = useState(""); + const [uploadFileName, setUploadFileName] = useState( + undefined, + ); + const [uploadAssetUrl, setUploadAssetUrl] = useState( + undefined, + ); + const [uploadFieldBusy, setUploadFieldBusy] = useState(false); + const [uploadFieldError, setUploadFieldError] = useState( + null, + ); + const [proportionBlockTitle, setProportionBlockTitle] = useState(""); + const [proportionDefault, setProportionDefault] = useState(50); + + const fileInputRef = useRef(null); + + const resetFieldTypeDrafts = useCallback(() => { + setTextBlockTitle(""); + setTextPlaceholderBody(""); + setBadgeBlockTitle(""); + setBadgeOptions([]); + setUploadBlockTitle(""); + setUploadFileName(undefined); + setUploadAssetUrl(undefined); + setUploadFieldBusy(false); + setUploadFieldError(null); + 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(); + const titleOk = + t0.length > 0 && + t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS; + if (!titleOk) return false; + if (onPersistCustomUploadFile) { + return Boolean(uploadAssetUrl?.trim()); + } + return true; + } + 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, + uploadAssetUrl, + onPersistCustomUploadFile, + ]); + + 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 kebabMenuItems = useMemo(() => [], []); + + 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( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + setUploadFileName(file?.name); + setUploadAssetUrl(undefined); + setUploadFieldError(null); + if (!file || !onPersistCustomUploadFile) return; + setUploadFieldBusy(true); + try { + const { url } = await onPersistCustomUploadFile(file); + setUploadAssetUrl(url); + } catch { + setUploadFieldError(tUpload("errors.generic")); + } finally { + setUploadFieldBusy(false); + } + }, + [onPersistCustomUploadFile, tUpload], + ); + + const handleClearPendingUpload = useCallback(() => { + setUploadFileName(undefined); + setUploadAssetUrl(undefined); + setUploadFieldError(null); + setUploadFieldBusy(false); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }, []); + + 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, + ...(uploadAssetUrl?.trim() + ? { assetUrl: uploadAssetUrl.trim() } + : {}), + }; + 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, + uploadAssetUrl, + ]); + + 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 ( + 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, + onClearPendingUpload: handleClearPendingUpload, + uploadAssetPreviewUrl: uploadAssetUrl, + uploadPersisting: + Boolean(fieldTypeModal === "upload" && uploadFieldBusy), + uploadBusyHint: tUpload("uploading"), + uploadErrorMessage: + fieldTypeModal === "upload" ? uploadFieldError : null, + proportionBlockTitle, + proportionDefault, + onProportionBlockTitleChange: setProportionBlockTitle, + onProportionDefaultChange: setProportionDefault, + }} + nextDisabled={shellNextDisabled} + nextLabel={nextLabel} + showBackButton + onBack={handleBack} + onNext={handleNext} + stepper={!fieldTypeModal} + draftFieldBlocks={draftFieldBlocks} + onDraftFieldBlocksReorder={setDraftFieldBlocks} + kebabMoreOptionsAriaLabel={menuCopy.triggerAriaLabel} + kebabMenuAriaLabel={menuCopy.menuAriaLabel} + kebabMenuItems={kebabMenuItems} + /> + ); + }, +); + +CustomMethodCardWizardContainer.displayName = "CustomMethodCardWizard"; + +export default CustomMethodCardWizardContainer; diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.types.ts b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.types.ts new file mode 100644 index 0000000..ee618dc --- /dev/null +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.types.ts @@ -0,0 +1,148 @@ +import type { RefObject } from "react"; +import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types"; +import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.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; + uploadPreviewImageAlt: string; + clearPendingUploadAriaLabel: string; + clearPendingUploadTooltip: 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 }; + step3BlocksList: { + listLabel: string; + dragHandleAriaLabel: string; + }; + fieldTypeLabels: Record; + 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; + /** + * Persists custom-method upload files to `POST /api/uploads` (purpose + * `customMethodAttachment`). When omitted, upload field only stores `fileName`. + */ + onPersistCustomUploadFile?: (file: File) => Promise<{ url: string }>; +} + +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; + onFileChosen: (e: React.ChangeEvent) => void; + /** Clears chosen file, preview URL, and related errors so the user can pick again. */ + onClearPendingUpload: () => void; + /** When set after a successful upload, shows an inline image preview in the modal. */ + uploadAssetPreviewUrl?: string | null; + /** Shown under the upload control while saving to the server. */ + uploadPersisting?: boolean; + /** Replaces upload hint text while `uploadPersisting` is true. */ + uploadBusyHint?: string; + uploadErrorMessage?: string | null; + 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" + >; + draftFieldBlocks: CustomMethodCardFieldBlock[]; + onDraftFieldBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void; + nextDisabled: boolean; + nextLabel: string; + showBackButton: boolean; + onBack: () => void; + onNext: () => void; + stepper: boolean; + kebabMoreOptionsAriaLabel: string; + kebabMenuAriaLabel: string; + kebabMenuItems: ModalHeaderMenuItem[]; +} diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx new file mode 100644 index 0000000..237a3b7 --- /dev/null +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx @@ -0,0 +1,115 @@ +"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 { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.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, + draftFieldBlocks, + onDraftFieldBlocksReorder, + kebabMoreOptionsAriaLabel, + kebabMenuAriaLabel, + kebabMenuItems, +}: CustomMethodCardWizardViewProps) { + return ( + + {fieldTypeModal ? ( + + ) : null} + {!fieldTypeModal && wizardStep === 1 ? ( + + ) : null} + {!fieldTypeModal && wizardStep === 2 ? ( +