Merge pull request 'Create flow cleanup: edit published rules, custom method cards, review polish, share/export & draft fixes' (#50) from adilallo/maintenance/CreateFlowCleanup into main
Reviewed-on: #50
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -17,6 +17,9 @@ npm-cache/
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# Local user uploads (see UPLOAD_ROOT in .env.example)
|
||||
/var/uploads
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
|
||||
@@ -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.<group>=<value>` 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. |
|
||||
|
||||
@@ -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 `<Alert>`;
|
||||
* 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<typeof b> => b !== null);
|
||||
|
||||
return (
|
||||
@@ -401,14 +562,43 @@ function CreateFlowLayoutContent({
|
||||
<Suspense fallback={null}>
|
||||
<PostLoginDraftTransfer sessionUser={sessionUser} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<CreateFlowPendingAvatarFlush
|
||||
sessionUser={sessionUser}
|
||||
sessionResolved={sessionResolved}
|
||||
/>
|
||||
</Suspense>
|
||||
<Share
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
onCopyLink={() => void copyPublishedRuleLink()}
|
||||
onEmailShare={mailtoPublishedRule}
|
||||
onSignalShare={() => void sharePublishedRuleViaSignal()}
|
||||
onSlackShare={() => void sharePublishedRuleViaSlack()}
|
||||
onDiscordShare={() => void sharePublishedRuleViaDiscord()}
|
||||
/>
|
||||
<CreateFlowTopNav
|
||||
hasShare={isCompletedStep}
|
||||
hasExport={isCompletedStep}
|
||||
hasEdit={isCompletedStep}
|
||||
saveDraftOnExit={saveDraftOnExit}
|
||||
onShare={
|
||||
isCompletedStep ? () => 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 && (
|
||||
<CreateFlowFooter
|
||||
className="shrink-0"
|
||||
progressBar={!isTemplateReviewRoute && !isFinalReviewStep}
|
||||
progressBar={!isTemplateReviewRoute && !isFinalReviewLike}
|
||||
proportionBarProgress={proportionBarProgress}
|
||||
proportionBarVariant="segmented"
|
||||
secondButton={
|
||||
@@ -533,13 +723,16 @@ function CreateFlowLayoutContent({
|
||||
// detour. Direct entries to `/templates` (no marker) and
|
||||
// home "Popular templates" clicks always start fresh by
|
||||
// wiping anonymous draft storage at click time.
|
||||
router.push("/templates?fromFlow=1");
|
||||
router.push(
|
||||
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{footer.createFromTemplate}
|
||||
</Button>
|
||||
</div>
|
||||
) : customRuleConfirmFooter && nextStep ? (
|
||||
) : showCustomRuleFooterConfirm &&
|
||||
customRuleConfirmFooter ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
@@ -550,12 +743,26 @@ function CreateFlowLayoutContent({
|
||||
}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
const cf = customRuleConfirmFooter;
|
||||
const facet = methodCardFacetSectionForConfirmStep(cf.step);
|
||||
if (facet != null && cf.selectionIds(state).length > 0) {
|
||||
setMethodSectionsPinCommitted(facet, true);
|
||||
}
|
||||
if (reviewReturnTarget) {
|
||||
router.push(
|
||||
createFlowStepPathAfterStrippingReviewReturn(
|
||||
reviewReturnTarget,
|
||||
searchParams,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer[customRuleConfirmFooter.footerMessageKey]}
|
||||
</Button>
|
||||
) : nextStep ? (
|
||||
) : nextStep || isFinalReviewLike ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
@@ -563,14 +770,14 @@ function CreateFlowLayoutContent({
|
||||
disabled={isPublishing}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
if (currentStep === "final-review") {
|
||||
if (isFinalReviewLike) {
|
||||
void handleFinalize();
|
||||
} else {
|
||||
goToNextStep();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{currentStep === "final-review"
|
||||
{isFinalReviewLike
|
||||
? isPublishing
|
||||
? messages.create.reviewAndComplete.publish
|
||||
.finalizeButtonPublishing
|
||||
@@ -584,12 +791,21 @@ function CreateFlowLayoutContent({
|
||||
? () =>
|
||||
router.push(
|
||||
templateReviewFooterBackToCreateReview
|
||||
? "/create/review"
|
||||
: "/",
|
||||
? CREATE_ROUTES.review
|
||||
: CREATE_ROUTES.root,
|
||||
)
|
||||
: previousStep
|
||||
? goToPreviousStep
|
||||
: undefined
|
||||
: reviewReturnTarget
|
||||
? () => {
|
||||
router.push(
|
||||
createFlowStepPathAfterStrippingReviewReturn(
|
||||
reviewReturnTarget,
|
||||
searchParams,
|
||||
),
|
||||
);
|
||||
}
|
||||
: previousStep
|
||||
? goToPreviousStep
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -27,8 +27,10 @@ const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||
* server draft on top would clobber unsaved keystrokes with a stale snapshot.
|
||||
*
|
||||
* Server draft becomes authoritative only when localStorage is empty — i.e.
|
||||
* fresh device, after explicit Save & Exit (which clears localStorage), or
|
||||
* after Exit-from-completed clears local state.
|
||||
* fresh device, after explicit Save & Exit (which clears localStorage),
|
||||
* after Exit-from-completed clears local state, or after
|
||||
* {@link prepareFreshCreateFlowEntry} (Create rule / new template entry) clears
|
||||
* local + deletes the server draft when sync is on.
|
||||
*
|
||||
* Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer}
|
||||
* owns that path.
|
||||
@@ -75,6 +77,12 @@ export function SignedInDraftHydration({
|
||||
return;
|
||||
}
|
||||
|
||||
const urlStep = parseCreateFlowScreenFromPathname(pathname ?? null);
|
||||
/** Owner “view published rule” shell — never merge server draft or redirect to `currentStep`. */
|
||||
if (urlStep === "completed") {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoadingHydration(true);
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Shared "Applicable Scope" field used by the `decision-approaches` and
|
||||
* `conflict-management` create flow modals. Pairs an `InputLabel` with a
|
||||
* horizontally-wrapping list of toggle-chips plus an inline "+ Add" affordance
|
||||
* that reveals a pill text input for creating new scope values.
|
||||
* Shared "Applicable Scope" field used by the `decision-approaches` create-flow
|
||||
* modal. Pairs an `InputLabel` with a horizontally-wrapping list of
|
||||
* toggle-chips plus an inline "+ Add" affordance that reveals a pill text input
|
||||
* for creating new scope values. Conflict management uses
|
||||
* `ModalTextAreaField` instead (Figma `20874:172292`).
|
||||
*/
|
||||
|
||||
import { memo, useState } from "react";
|
||||
@@ -34,6 +35,8 @@ export interface ApplicableScopeFieldProps {
|
||||
* Optional placeholder for the inline input. Defaults to `addLabel`.
|
||||
*/
|
||||
inputPlaceholder?: string;
|
||||
/** When true, scope chips and add affordance are non-interactive. */
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -45,6 +48,7 @@ function ApplicableScopeFieldComponent({
|
||||
onToggleScope,
|
||||
onAddScope,
|
||||
inputPlaceholder,
|
||||
readOnly = false,
|
||||
className = "",
|
||||
}: ApplicableScopeFieldProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
@@ -77,13 +81,13 @@ function ApplicableScopeFieldComponent({
|
||||
state={isSelected ? "selected" : "disabled"}
|
||||
palette="default"
|
||||
size="s"
|
||||
disabled={false}
|
||||
onClick={() => onToggleScope(scope)}
|
||||
disabled={readOnly}
|
||||
onClick={() => !readOnly && onToggleScope(scope)}
|
||||
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isAdding ? (
|
||||
{readOnly ? null : isAdding ? (
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
|
||||
import {
|
||||
clearPendingCommunityAvatarFile,
|
||||
readPendingCommunityAvatarFile,
|
||||
} from "../../../../lib/create/pendingCommunityAvatarUpload";
|
||||
|
||||
/**
|
||||
* After sign-in, uploads a community avatar staged in IndexedDB (anonymous pick)
|
||||
* and writes `communityAvatarUrl` on success.
|
||||
*/
|
||||
export function CreateFlowPendingAvatarFlush({
|
||||
sessionUser,
|
||||
sessionResolved,
|
||||
}: {
|
||||
sessionUser: { id: string; email: string } | null | undefined;
|
||||
sessionResolved: boolean;
|
||||
}) {
|
||||
const { updateState } = useCreateFlow();
|
||||
/** One successful flush per signed-in user id (survives React StrictMode remounts). */
|
||||
const lastFlushedUserIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionResolved || !sessionUser) return;
|
||||
if (lastFlushedUserIdRef.current === sessionUser.id) return;
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
const file = await readPendingCommunityAvatarFile();
|
||||
if (cancelled || !file) return;
|
||||
try {
|
||||
const { url } = await uploadCreateFlowFile(file, "communityAvatar");
|
||||
if (cancelled) return;
|
||||
await clearPendingCommunityAvatarFile();
|
||||
updateState({ communityAvatarUrl: url });
|
||||
lastFlushedUserIdRef.current = sessionUser.id;
|
||||
} catch {
|
||||
// Leave pending blob in place so the user can retry after fixing auth / UPLOAD_ROOT.
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionResolved, sessionUser, updateState]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Controlled field blocks for wizard-authored method cards in Create modals
|
||||
* (facet screens + final-review chip edit). When `onBlocksChange` is omitted,
|
||||
* blocks render read-only (disabled controls).
|
||||
*
|
||||
* Layout matches preset method editors ({@link CommunicationMethodEditFields},
|
||||
* {@link DecisionApproachEditFields}): {@link ModalTextAreaField},
|
||||
* {@link ApplicableScopeField} chip rows, {@link IncrementerBlock}.
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
|
||||
import Chip from "../../../components/controls/Chip";
|
||||
import IncrementerBlock from "../../../components/controls/IncrementerBlock";
|
||||
import Upload from "../../../components/controls/Upload";
|
||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||
import ApplicableScopeField from "./ApplicableScopeField";
|
||||
import InputLabel from "../../../components/type/InputLabel";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import ModalTextAreaField from "./ModalTextAreaField";
|
||||
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
|
||||
|
||||
const TEXT_VALUE_MAX = 8000;
|
||||
|
||||
export interface CustomMethodCardFieldBlocksSummaryProps {
|
||||
blocks: CustomMethodCardFieldBlock[];
|
||||
/** When set, fields update the draft via immutable block-array replacements. */
|
||||
onBlocksChange?: (_next: CustomMethodCardFieldBlock[]) => void;
|
||||
}
|
||||
|
||||
function mapBlockById(
|
||||
blocks: CustomMethodCardFieldBlock[],
|
||||
blockId: string,
|
||||
mapFn: (_b: CustomMethodCardFieldBlock) => CustomMethodCardFieldBlock,
|
||||
): CustomMethodCardFieldBlock[] {
|
||||
return blocks.map((b) => (b.id === blockId ? mapFn(b) : b));
|
||||
}
|
||||
|
||||
function CustomMethodCardUploadBlockRow({
|
||||
block,
|
||||
blocks,
|
||||
patch,
|
||||
uploadFileInputAriaLabel,
|
||||
uploadHint,
|
||||
clearPendingUploadAriaLabel,
|
||||
clearPendingUploadTooltip,
|
||||
uploadPreviewImageAlt,
|
||||
noFileChosen,
|
||||
}: {
|
||||
block: Extract<CustomMethodCardFieldBlock, { kind: "upload" }>;
|
||||
blocks: CustomMethodCardFieldBlock[];
|
||||
patch: (_next: CustomMethodCardFieldBlock[]) => void;
|
||||
uploadFileInputAriaLabel: string;
|
||||
uploadHint: string;
|
||||
clearPendingUploadAriaLabel: string;
|
||||
clearPendingUploadTooltip: string;
|
||||
uploadPreviewImageAlt: string;
|
||||
noFileChosen: string;
|
||||
}) {
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const tUpload = useTranslation("create.upload");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const displayName = block.fileName?.trim() ? block.fileName : noFileChosen;
|
||||
const assetUrlTrimmed = block.assetUrl?.trim() ?? "";
|
||||
const hasAsset = assetUrlTrimmed.length > 0;
|
||||
|
||||
const clearUpload = () =>
|
||||
patch(
|
||||
mapBlockById(blocks, block.id, (b) =>
|
||||
b.kind === "upload"
|
||||
? { ...b, fileName: undefined, assetUrl: undefined }
|
||||
: b,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<InputLabel
|
||||
label={block.blockTitle}
|
||||
helpIcon
|
||||
size="s"
|
||||
palette="default"
|
||||
/>
|
||||
{!hasAsset ? (
|
||||
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
|
||||
{displayName}
|
||||
</p>
|
||||
) : null}
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,application/pdf"
|
||||
aria-label={uploadFileInputAriaLabel}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setErrorMessage(null);
|
||||
setBusy(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const { url } = await uploadCreateFlowFile(
|
||||
file,
|
||||
"customMethodAttachment",
|
||||
);
|
||||
const name = file.name?.trim();
|
||||
patch(
|
||||
mapBlockById(blocks, block.id, (b) =>
|
||||
b.kind === "upload"
|
||||
? {
|
||||
...b,
|
||||
...(name ? { fileName: name } : {}),
|
||||
assetUrl: url,
|
||||
}
|
||||
: b,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setErrorMessage(tUpload("errors.generic"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
{hasAsset ? (
|
||||
<div className="relative inline-block max-w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearUpload}
|
||||
className="absolute right-[8px] top-[8px] z-[1] flex h-[32px] w-[32px] cursor-pointer items-center justify-center rounded-full bg-[var(--color-surface-default-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
|
||||
aria-label={clearPendingUploadAriaLabel}
|
||||
title={clearPendingUploadTooltip}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
|
||||
<img
|
||||
src={getAssetPath("assets/Icon_Close.svg")}
|
||||
alt=""
|
||||
className="h-[16px] w-[16px]"
|
||||
style={{
|
||||
filter: "brightness(0) invert(1)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL */}
|
||||
<img
|
||||
src={assetUrlTrimmed}
|
||||
alt={uploadPreviewImageAlt}
|
||||
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
active={!busy}
|
||||
hintText={busy ? tUpload("uploading") : uploadHint}
|
||||
onClick={() => {
|
||||
if (!busy) uploadInputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{errorMessage ? (
|
||||
<p
|
||||
className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
|
||||
role="alert"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
{blocks.map((block) => {
|
||||
if (block.kind === "text") {
|
||||
return (
|
||||
<ModalTextAreaField
|
||||
key={block.id}
|
||||
label={block.blockTitle}
|
||||
rows={6}
|
||||
value={block.placeholderText}
|
||||
onChange={(v) =>
|
||||
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 (
|
||||
<div key={block.id} className="flex flex-col gap-2">
|
||||
<InputLabel
|
||||
label={block.blockTitle}
|
||||
helpIcon
|
||||
size="s"
|
||||
palette="default"
|
||||
/>
|
||||
{block.options.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{block.options.map((opt, idx) => (
|
||||
<Chip
|
||||
key={`${block.id}-${idx}`}
|
||||
label={opt}
|
||||
state="selected"
|
||||
palette="default"
|
||||
size="s"
|
||||
disabled
|
||||
ariaLabel={opt}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
|
||||
{emptyValue}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ApplicableScopeField
|
||||
key={block.id}
|
||||
label={block.blockTitle}
|
||||
addLabel={fm.badges.addOptionLabel}
|
||||
scopes={block.options}
|
||||
selectedScopes={block.options}
|
||||
onToggleScope={(scope) =>
|
||||
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 (
|
||||
<div key={block.id}>
|
||||
{readOnly ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<InputLabel
|
||||
label={block.blockTitle}
|
||||
helpIcon
|
||||
size="s"
|
||||
palette="default"
|
||||
/>
|
||||
{block.assetUrl?.trim() ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={block.assetUrl.trim()}
|
||||
alt={
|
||||
block.fileName?.trim() ||
|
||||
block.blockTitle ||
|
||||
noFileChosen
|
||||
}
|
||||
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
|
||||
{noFileChosen}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<CustomMethodCardUploadBlockRow
|
||||
block={block}
|
||||
blocks={blocks}
|
||||
patch={patch}
|
||||
uploadFileInputAriaLabel={fm.upload.uploadFileInputAriaLabel}
|
||||
uploadHint={fm.upload.uploadHint}
|
||||
clearPendingUploadAriaLabel={
|
||||
fm.upload.clearPendingUploadAriaLabel
|
||||
}
|
||||
clearPendingUploadTooltip={
|
||||
fm.upload.clearPendingUploadTooltip
|
||||
}
|
||||
uploadPreviewImageAlt={fm.upload.uploadPreviewImageAlt}
|
||||
noFileChosen={noFileChosen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IncrementerBlock
|
||||
key={block.id}
|
||||
label={block.blockTitle}
|
||||
value={block.defaultPercent}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={readOnly}
|
||||
onChange={(v) =>
|
||||
patch(
|
||||
mapBlockById(blocks, block.id, (b) =>
|
||||
b.kind === "proportion" ? { ...b, defaultPercent: v } : b,
|
||||
),
|
||||
)
|
||||
}
|
||||
formatValue={(v) => `${v}%`}
|
||||
decrementAriaLabel={fm.proportion.decrementAriaLabel}
|
||||
incrementAriaLabel={fm.proportion.incrementAriaLabel}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomMethodCardFieldBlocksSummary = memo(
|
||||
CustomMethodCardFieldBlocksSummaryComponent,
|
||||
);
|
||||
CustomMethodCardFieldBlocksSummary.displayName =
|
||||
"CustomMethodCardFieldBlocksSummary";
|
||||
|
||||
export default CustomMethodCardFieldBlocksSummary;
|
||||
@@ -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 (
|
||||
<CustomMethodCardFieldBlocksSummary
|
||||
blocks={blocks}
|
||||
onBlocksChange={onFieldBlocksChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{showPolicyContentLockupWhenNoBlocks ? (
|
||||
<ContentLockup
|
||||
title={label.length > 0 ? label : undefined}
|
||||
description={support.length > 0 ? support : undefined}
|
||||
variant="modal"
|
||||
alignment="left"
|
||||
/>
|
||||
) : null}
|
||||
{noFieldsHint.trim().length > 0 ? (
|
||||
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m,15px)] leading-[var(--line-height-body-m,22px)] text-[var(--color-content-default-secondary)]">
|
||||
{noFieldsHint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <CustomMethodCardPresetEditPlaceholder />;
|
||||
}
|
||||
@@ -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 (
|
||||
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m,15px)] leading-[var(--line-height-body-m,22px)] text-[var(--color-content-default-secondary)]">
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
CustomMethodCardPresetEditPlaceholderComponent.displayName =
|
||||
"CustomMethodCardPresetEditPlaceholder";
|
||||
|
||||
export default memo(CustomMethodCardPresetEditPlaceholderComponent);
|
||||
@@ -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<CustomMethodCardWizardProps>(
|
||||
({ 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<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 [uploadAssetUrl, setUploadAssetUrl] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [uploadFieldBusy, setUploadFieldBusy] = useState(false);
|
||||
const [uploadFieldError, setUploadFieldError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [proportionBlockTitle, setProportionBlockTitle] = useState("");
|
||||
const [proportionDefault, setProportionDefault] = useState(50);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<ModalHeaderMenuItem[]>(() => [], []);
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<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,
|
||||
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;
|
||||
@@ -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<AddCustomFieldType, 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;
|
||||
/**
|
||||
* 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<HTMLInputElement | null>;
|
||||
onFileChosen: (e: React.ChangeEvent<HTMLInputElement>) => 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[];
|
||||
}
|
||||
@@ -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 (
|
||||
<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"
|
||||
kebabTriggerAriaLabel={kebabMoreOptionsAriaLabel}
|
||||
kebabMenuAriaLabel={kebabMenuAriaLabel}
|
||||
kebabMenuItems={kebabMenuItems}
|
||||
>
|
||||
{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 ? (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{draftFieldBlocks.length > 0 ? (
|
||||
<CustomMethodCardWizardBlocksListView
|
||||
blocks={draftFieldBlocks}
|
||||
fieldTypeLabels={copy.fieldTypeLabels}
|
||||
dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel}
|
||||
listLabel={copy.step3BlocksList.listLabel}
|
||||
onBlocksReorder={onDraftFieldBlocksReorder}
|
||||
/>
|
||||
) : null}
|
||||
<AddCustomField
|
||||
active={addFieldExpanded}
|
||||
onPressAdd={onPressAddCustomField}
|
||||
onSelectFieldType={onSelectFieldType}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Create>
|
||||
);
|
||||
}
|
||||
|
||||
export const CustomMethodCardWizardView = memo(
|
||||
CustomMethodCardWizardViewComponent,
|
||||
);
|
||||
CustomMethodCardWizardView.displayName = "CustomMethodCardWizardView";
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useState, type DragEvent } from "react";
|
||||
import Icon from "../../../../components/asset/icon";
|
||||
import { ADD_CUSTOM_FIELD_TYPE_ICONS } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks";
|
||||
|
||||
function DragHandleGlyph({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx={4} cy={4} r={1.25} fill="currentColor" />
|
||||
<circle cx={12} cy={4} r={1.25} fill="currentColor" />
|
||||
<circle cx={4} cy={8} r={1.25} fill="currentColor" />
|
||||
<circle cx={12} cy={8} r={1.25} fill="currentColor" />
|
||||
<circle cx={4} cy={12} r={1.25} fill="currentColor" />
|
||||
<circle cx={12} cy={12} r={1.25} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CustomMethodCardWizardBlocksListViewProps {
|
||||
blocks: CustomMethodCardFieldBlock[];
|
||||
fieldTypeLabels: Record<AddCustomFieldType, string>;
|
||||
dragHandleAriaLabel: string;
|
||||
listLabel: string;
|
||||
onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
|
||||
}
|
||||
|
||||
function CustomMethodCardWizardBlocksListViewComponent({
|
||||
blocks,
|
||||
fieldTypeLabels,
|
||||
dragHandleAriaLabel,
|
||||
listLabel,
|
||||
onBlocksReorder,
|
||||
}: CustomMethodCardWizardBlocksListViewProps) {
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null);
|
||||
|
||||
const clearDragUi = useCallback(() => {
|
||||
setDraggingIndex(null);
|
||||
setOverIndex(null);
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(index: number) => (e: DragEvent) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
setDraggingIndex(index);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((index: number) => {
|
||||
return (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setOverIndex(index);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(index: number) => (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
const from = Number.parseInt(e.dataTransfer.getData("text/plain"), 10);
|
||||
if (Number.isNaN(from)) {
|
||||
clearDragUi();
|
||||
return;
|
||||
}
|
||||
onBlocksReorder(
|
||||
reorderCustomMethodCardFieldBlocks(blocks, from, index),
|
||||
);
|
||||
clearDragUi();
|
||||
},
|
||||
[blocks, clearDragUi, onBlocksReorder],
|
||||
);
|
||||
|
||||
return (
|
||||
<ul className="flex list-none flex-col gap-2 p-0" aria-label={listLabel}>
|
||||
{blocks.map((block, index) => {
|
||||
const kind = block.kind as AddCustomFieldType;
|
||||
const typeLabel = fieldTypeLabels[kind];
|
||||
const isOver = overIndex === index && draggingIndex !== index;
|
||||
return (
|
||||
<li
|
||||
key={block.id}
|
||||
className={`flex min-h-[52px] items-stretch gap-2 rounded-[var(--measures-radius-medium,8px)] border border-[var(--color-border-default-primary)] bg-[var(--color-surface-default-secondary)] pl-1 pr-3 py-2 transition-shadow ${
|
||||
isOver
|
||||
? "ring-2 ring-[var(--color-border-invert-primary)] ring-offset-2 ring-offset-[var(--color-surface-default-primary)]"
|
||||
: ""
|
||||
} ${draggingIndex === index ? "opacity-60" : ""}`}
|
||||
onDragOver={handleDragOver(index)}
|
||||
onDrop={handleDrop(index)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={handleDragStart(index)}
|
||||
onDragEnd={clearDragUi}
|
||||
className="flex shrink-0 cursor-grab touch-manipulation items-center justify-center rounded-[var(--measures-radius-200,8px)] border-0 bg-transparent px-1 text-[var(--color-content-default-secondary)] active:cursor-grabbing focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)]"
|
||||
aria-label={dragHandleAriaLabel}
|
||||
>
|
||||
<DragHandleGlyph />
|
||||
</button>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center self-center">
|
||||
<Icon
|
||||
name={ADD_CUSTOM_FIELD_TYPE_ICONS[kind]}
|
||||
size={24}
|
||||
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
|
||||
/>
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
|
||||
<span className="truncate font-inter text-[14px] font-medium leading-[18px] text-[var(--color-content-default-primary)]">
|
||||
{block.blockTitle.trim() || typeLabel}
|
||||
</span>
|
||||
<span className="font-inter text-[12px] leading-4 text-[var(--color-content-default-secondary)]">
|
||||
{typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export const CustomMethodCardWizardBlocksListView = memo(
|
||||
CustomMethodCardWizardBlocksListViewComponent,
|
||||
);
|
||||
CustomMethodCardWizardBlocksListView.displayName =
|
||||
"CustomMethodCardWizardBlocksListView";
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { getAssetPath } from "../../../../../lib/assetUtils";
|
||||
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,
|
||||
onClearPendingUpload,
|
||||
uploadAssetPreviewUrl = null,
|
||||
uploadPersisting = false,
|
||||
uploadBusyHint,
|
||||
uploadErrorMessage = null,
|
||||
proportionBlockTitle,
|
||||
proportionDefault,
|
||||
onProportionBlockTitleChange,
|
||||
onProportionDefaultChange,
|
||||
}: CustomMethodCardWizardFieldBodiesViewProps) {
|
||||
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
|
||||
const hasUploadPreview = uploadPreviewTrimmed.length > 0;
|
||||
|
||||
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
|
||||
/>
|
||||
{hasUploadPreview ? (
|
||||
<div className="relative inline-block max-w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearPendingUpload}
|
||||
className="absolute right-[8px] top-[8px] z-[1] flex h-[32px] w-[32px] cursor-pointer items-center justify-center rounded-full bg-[var(--color-surface-default-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
|
||||
aria-label={copy.upload.clearPendingUploadAriaLabel}
|
||||
title={copy.upload.clearPendingUploadTooltip}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
|
||||
<img
|
||||
src={getAssetPath("assets/Icon_Close.svg")}
|
||||
alt=""
|
||||
className="h-[16px] w-[16px]"
|
||||
style={{
|
||||
filter: "brightness(0) invert(1)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- blob or same-origin upload URL */}
|
||||
<img
|
||||
src={uploadPreviewTrimmed}
|
||||
alt={copy.upload.uploadPreviewImageAlt}
|
||||
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
active={!uploadPersisting}
|
||||
hintText={
|
||||
uploadPersisting && uploadBusyHint
|
||||
? uploadBusyHint
|
||||
: copy.upload.uploadHint
|
||||
}
|
||||
onClick={() => {
|
||||
if (!uploadPersisting) fileInputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{uploadErrorMessage ? (
|
||||
<p
|
||||
className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
|
||||
role="alert"
|
||||
>
|
||||
{uploadErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</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";
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Edit published rule: community description with the same 200-char limit as
|
||||
* {@link CreateFlowScreenView} `community-context` step.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Create from "../../../components/modals/Create";
|
||||
import TextInput from "../../../components/controls/TextInput";
|
||||
import ContentLockup from "../../../components/type/ContentLockup";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
|
||||
/** Matches `community-context` step and `createFlowSchemas` communityContext.max(200). */
|
||||
export const COMMUNITY_CONTEXT_FIELD_MAX_LENGTH = 200;
|
||||
|
||||
export interface FinalReviewCommunityContextEditModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Current `communityContext` (trimmed for display; draft seeds from raw state in parent). */
|
||||
initialValue: string;
|
||||
onSave: (_value: string) => void;
|
||||
}
|
||||
|
||||
export function FinalReviewCommunityContextEditModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
}: FinalReviewCommunityContextEditModalProps) {
|
||||
const tModal = useTranslation(
|
||||
"create.reviewAndComplete.finalReview.communityContextEditModal",
|
||||
);
|
||||
const tField = useTranslation("create.community.communityContext");
|
||||
const tSave = useTranslation(
|
||||
"create.reviewAndComplete.finalReview.chipEditModal",
|
||||
);
|
||||
|
||||
const [draft, setDraft] = useState("");
|
||||
const initialRef = useRef("");
|
||||
const seededOpenRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
seededOpenRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (seededOpenRef.current) return;
|
||||
seededOpenRef.current = true;
|
||||
const seed = initialValue;
|
||||
setDraft(seed);
|
||||
initialRef.current = seed;
|
||||
}, [isOpen, initialValue]);
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => draft !== initialRef.current,
|
||||
[draft],
|
||||
);
|
||||
|
||||
const characterHint = tField("characterCountTemplate")
|
||||
.replace("{current}", String(draft.length))
|
||||
.replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH));
|
||||
|
||||
const handleSave = () => {
|
||||
if (!isDirty) return;
|
||||
const trimmed = draft.trimEnd();
|
||||
const capped = trimmed.slice(0, COMMUNITY_CONTEXT_FIELD_MAX_LENGTH);
|
||||
onSave(capped);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Create
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
backdropVariant="blurredYellow"
|
||||
headerContent={
|
||||
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
||||
<ContentLockup
|
||||
title={tModal("title")}
|
||||
description={tModal("description")}
|
||||
variant="modal"
|
||||
alignment="left"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
showBackButton={false}
|
||||
showNextButton
|
||||
nextButtonText={tSave("saveButton")}
|
||||
nextButtonDisabled={!isDirty}
|
||||
onNext={handleSave}
|
||||
ariaLabel={tModal("title")}
|
||||
>
|
||||
<div className="pb-2">
|
||||
<TextInput
|
||||
className="!transition-none"
|
||||
type="text"
|
||||
placeholder={tField("placeholder")}
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
}}
|
||||
inputSize="medium"
|
||||
formHeader={false}
|
||||
textHint={characterHint}
|
||||
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
|
||||
/>
|
||||
</div>
|
||||
</Create>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Editable policy title + description for method-card Create modals in Customize mode.
|
||||
* View mode continues to use {@link ContentLockup} via the `Create` modal defaults.
|
||||
*/
|
||||
|
||||
import TextInput from "../../../components/controls/TextInput";
|
||||
import ModalTextAreaField from "./ModalTextAreaField";
|
||||
|
||||
export interface MethodCardCustomizeModalHeaderProps {
|
||||
titleLabel: string;
|
||||
descriptionLabel: string;
|
||||
titleValue: string;
|
||||
descriptionValue: string;
|
||||
onTitleChange: (_value: string) => void;
|
||||
onDescriptionChange: (_value: string) => void;
|
||||
/** @default 3 */
|
||||
descriptionRows?: number;
|
||||
/** When false, only the policy title row is rendered (core values rename). */
|
||||
showDescription?: boolean;
|
||||
}
|
||||
|
||||
export default function MethodCardCustomizeModalHeader({
|
||||
titleLabel,
|
||||
descriptionLabel,
|
||||
titleValue,
|
||||
descriptionValue,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
descriptionRows = 3,
|
||||
showDescription = true,
|
||||
}: MethodCardCustomizeModalHeaderProps) {
|
||||
return (
|
||||
<div className="bg-[var(--color-surface-default-primary)] flex shrink-0 flex-col gap-4 px-[24px] py-[12px]">
|
||||
<TextInput
|
||||
label={titleLabel}
|
||||
value={titleValue}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
inputSize="medium"
|
||||
/>
|
||||
{showDescription ? (
|
||||
<ModalTextAreaField
|
||||
label={descriptionLabel}
|
||||
value={descriptionValue}
|
||||
onChange={onDescriptionChange}
|
||||
rows={descriptionRows}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ModalHeaderMenuItem } from "../../../components/modals/ModalHeader/ModalHeader.types";
|
||||
|
||||
export interface CustomRuleModalKebabMenuCopy {
|
||||
items: {
|
||||
customize: string;
|
||||
duplicate: string;
|
||||
remove: string;
|
||||
};
|
||||
saveEdits: string;
|
||||
}
|
||||
|
||||
export interface CustomRuleModalKebabHandlers {
|
||||
showCustomize?: boolean;
|
||||
onCustomize?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
showRemove?: boolean;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export function buildCustomRuleModalKebabMenu(
|
||||
copy: CustomRuleModalKebabMenuCopy,
|
||||
handlers: CustomRuleModalKebabHandlers,
|
||||
): ModalHeaderMenuItem[] {
|
||||
const items: ModalHeaderMenuItem[] = [];
|
||||
if (handlers.showCustomize && handlers.onCustomize) {
|
||||
items.push({
|
||||
id: "customize",
|
||||
label: copy.items.customize,
|
||||
leadingIcon: "custom",
|
||||
onClick: handlers.onCustomize,
|
||||
});
|
||||
}
|
||||
if (handlers.onDuplicate) {
|
||||
items.push({
|
||||
id: "duplicate",
|
||||
label: copy.items.duplicate,
|
||||
leadingIcon: "content_copy",
|
||||
onClick: handlers.onDuplicate,
|
||||
});
|
||||
}
|
||||
if (handlers.showRemove && handlers.onRemove) {
|
||||
items.push({
|
||||
id: "remove",
|
||||
label: copy.items.remove,
|
||||
leadingIcon: "warning",
|
||||
variant: "destructive",
|
||||
onClick: handlers.onRemove,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import type { CommunicationMethodDetailEntry } from "../../types";
|
||||
export interface CommunicationMethodEditFieldsProps {
|
||||
value: CommunicationMethodDetailEntry;
|
||||
onChange: (_next: CommunicationMethodDetailEntry) => void;
|
||||
/** When true, fields are not editable (view mode). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const FIELDS: ReadonlyArray<keyof CommunicationMethodDetailEntry> = [
|
||||
@@ -26,6 +28,7 @@ const FIELDS: ReadonlyArray<keyof CommunicationMethodDetailEntry> = [
|
||||
function CommunicationMethodEditFieldsComponent({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
}: CommunicationMethodEditFieldsProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.communication;
|
||||
@@ -49,6 +52,7 @@ function CommunicationMethodEditFieldsComponent({
|
||||
rows={6}
|
||||
value={value[field]}
|
||||
onChange={(v) => patch(field, v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,19 +7,43 @@
|
||||
*/
|
||||
|
||||
import { memo, useCallback } from "react";
|
||||
import { formatConflictApplicableScopeForTextarea } from "../../../../../lib/create/ruleSectionsFromMethodSelections";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import ModalTextAreaField from "../ModalTextAreaField";
|
||||
import ApplicableScopeField from "../ApplicableScopeField";
|
||||
import type { ConflictManagementDetailEntry } from "../../types";
|
||||
|
||||
function conflictScopeTextareaValue(value: ConflictManagementDetailEntry): string {
|
||||
return formatConflictApplicableScopeForTextarea(
|
||||
value.selectedApplicableScope,
|
||||
value.applicableScope,
|
||||
);
|
||||
}
|
||||
|
||||
function conflictDetailWithScopeTextarea(
|
||||
value: ConflictManagementDetailEntry,
|
||||
text: string,
|
||||
): ConflictManagementDetailEntry {
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
return {
|
||||
...value,
|
||||
applicableScope: lines,
|
||||
selectedApplicableScope: [...lines],
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConflictManagementEditFieldsProps {
|
||||
value: ConflictManagementDetailEntry;
|
||||
onChange: (_next: ConflictManagementDetailEntry) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function ConflictManagementEditFieldsComponent({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
}: ConflictManagementEditFieldsProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.conflictManagement;
|
||||
@@ -40,33 +64,27 @@ function ConflictManagementEditFieldsComponent({
|
||||
label={t.sectionHeadings.corePrinciple}
|
||||
value={value.corePrinciple}
|
||||
onChange={(v) => patch("corePrinciple", v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<ApplicableScopeField
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.applicableScope}
|
||||
addLabel={t.scopeAddButtonLabel}
|
||||
scopes={value.applicableScope}
|
||||
selectedScopes={value.selectedApplicableScope}
|
||||
onToggleScope={(scope) =>
|
||||
patch(
|
||||
"selectedApplicableScope",
|
||||
value.selectedApplicableScope.includes(scope)
|
||||
? value.selectedApplicableScope.filter((s) => s !== scope)
|
||||
: [...value.selectedApplicableScope, scope],
|
||||
)
|
||||
}
|
||||
onAddScope={(scope) =>
|
||||
patch("applicableScope", [...value.applicableScope, scope])
|
||||
}
|
||||
value={conflictScopeTextareaValue(value)}
|
||||
placeholder={t.applicableScopePlaceholder}
|
||||
onChange={(v) => onChange(conflictDetailWithScopeTextarea(value, v))}
|
||||
rows={4}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.processProtocol}
|
||||
value={value.processProtocol}
|
||||
onChange={(v) => patch("processProtocol", v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.restorationFallbacks}
|
||||
value={value.restorationFallbacks}
|
||||
onChange={(v) => patch("restorationFallbacks", v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,11 +15,14 @@ import type { CoreValueDetailEntry } from "../../types";
|
||||
export interface CoreValueEditFieldsProps {
|
||||
value: CoreValueDetailEntry;
|
||||
onChange: (_next: CoreValueDetailEntry) => void;
|
||||
/** View mode until the user taps **Customize**. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function CoreValueEditFieldsComponent({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
}: CoreValueEditFieldsProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.coreValues.detailModal;
|
||||
@@ -41,12 +44,14 @@ function CoreValueEditFieldsComponent({
|
||||
value={value.meaning}
|
||||
onChange={(v) => patch("meaning", v)}
|
||||
rows={4}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={t.signalsLabel}
|
||||
value={value.signals}
|
||||
onChange={(v) => patch("signals", v)}
|
||||
rows={4}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { DecisionApproachDetailEntry } from "../../types";
|
||||
export interface DecisionApproachEditFieldsProps {
|
||||
value: DecisionApproachDetailEntry;
|
||||
onChange: (_next: DecisionApproachDetailEntry) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const CONSENSUS_LEVEL_MIN = 0;
|
||||
@@ -26,6 +27,7 @@ const CONSENSUS_LEVEL_STEP = 5;
|
||||
function DecisionApproachEditFieldsComponent({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
}: DecisionApproachEditFieldsProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.decisionApproaches;
|
||||
@@ -46,12 +48,14 @@ function DecisionApproachEditFieldsComponent({
|
||||
label={t.sectionHeadings.corePrinciple}
|
||||
value={value.corePrinciple}
|
||||
onChange={(v) => patch("corePrinciple", v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<ApplicableScopeField
|
||||
label={t.sectionHeadings.applicableScope}
|
||||
addLabel={t.scopeAddButtonLabel}
|
||||
scopes={value.applicableScope}
|
||||
selectedScopes={value.selectedApplicableScope}
|
||||
readOnly={readOnly}
|
||||
onToggleScope={(scope) =>
|
||||
patch(
|
||||
"selectedApplicableScope",
|
||||
@@ -68,6 +72,7 @@ function DecisionApproachEditFieldsComponent({
|
||||
label={t.sectionHeadings.stepByStepInstructions}
|
||||
value={value.stepByStepInstructions}
|
||||
onChange={(v) => patch("stepByStepInstructions", v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<IncrementerBlock
|
||||
label={t.sectionHeadings.consensusLevel}
|
||||
@@ -79,11 +84,13 @@ function DecisionApproachEditFieldsComponent({
|
||||
formatValue={(v) => `${v}%`}
|
||||
decrementAriaLabel="Decrease consensus level"
|
||||
incrementAriaLabel="Increase consensus level"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.objectionsDeadlocks}
|
||||
value={value.objectionsDeadlocks}
|
||||
onChange={(v) => patch("objectionsDeadlocks", v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { MembershipMethodDetailEntry } from "../../types";
|
||||
export interface MembershipMethodEditFieldsProps {
|
||||
value: MembershipMethodDetailEntry;
|
||||
onChange: (_next: MembershipMethodDetailEntry) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const FIELDS: ReadonlyArray<keyof MembershipMethodDetailEntry> = [
|
||||
@@ -26,6 +27,7 @@ const FIELDS: ReadonlyArray<keyof MembershipMethodDetailEntry> = [
|
||||
function MembershipMethodEditFieldsComponent({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
}: MembershipMethodEditFieldsProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.membership;
|
||||
@@ -49,6 +51,7 @@ function MembershipMethodEditFieldsComponent({
|
||||
rows={6}
|
||||
value={value[field]}
|
||||
onChange={(v) => patch(field, v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type {
|
||||
CreateFlowMethodCardFacetSection,
|
||||
CreateFlowState,
|
||||
CreateFlowContextValue,
|
||||
CreateFlowStep,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
readAnonymousCreateFlowState,
|
||||
writeAnonymousCreateFlowState,
|
||||
} from "../utils/anonymousDraftStorage";
|
||||
import { stripCustomRuleSelectionFields } from "../../../../lib/create/stripCustomRuleSelectionFields";
|
||||
import {
|
||||
clearCoreValueDetailsLocalStorage,
|
||||
readCoreValueDetailsFromLocalStorage,
|
||||
@@ -137,6 +139,19 @@ export function CreateFlowProvider({
|
||||
setInteractionTouched(true);
|
||||
}, []);
|
||||
|
||||
const setMethodSectionsPinCommitted = useCallback(
|
||||
(section: CreateFlowMethodCardFacetSection, committed: boolean) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
methodSectionsPinCommitted: {
|
||||
...(prevState.methodSectionsPinCommitted ?? {}),
|
||||
[section]: committed,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateState = useCallback((updates: Partial<CreateFlowState>) => {
|
||||
setState((prevState) => {
|
||||
const merged: CreateFlowState = { ...prevState, ...updates };
|
||||
@@ -156,9 +171,12 @@ export function CreateFlowProvider({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const replaceState = useCallback((next: CreateFlowState) => {
|
||||
setState(next);
|
||||
}, []);
|
||||
const replaceState = useCallback(
|
||||
(next: CreateFlowState | ((prev: CreateFlowState) => CreateFlowState)) => {
|
||||
setState(next);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearState = useCallback(() => {
|
||||
setState({});
|
||||
@@ -167,22 +185,10 @@ export function CreateFlowProvider({
|
||||
clearCoreValueDetailsLocalStorage();
|
||||
}, []);
|
||||
|
||||
// Keys produced by the Create Custom stage screens + `buildTemplateCustomizePrefill`.
|
||||
// Kept in sync with `CreateFlowState` comments marked "Create Custom —".
|
||||
// Keys cleared here match `STRIP_CUSTOM_RULE_SELECTION_STATE_KEYS` from
|
||||
// `lib/create/customRuleFacets.ts` (CUSTOM_RULE_FACETS / CR-92).
|
||||
const resetCustomRuleSelections = useCallback(() => {
|
||||
setState((prev) => {
|
||||
const {
|
||||
selectedCoreValueIds: _a,
|
||||
coreValuesChipsSnapshot: _b,
|
||||
coreValueDetailsByChipId: _c,
|
||||
selectedCommunicationMethodIds: _d,
|
||||
selectedMembershipMethodIds: _e,
|
||||
selectedDecisionApproachIds: _f,
|
||||
selectedConflictManagementIds: _g,
|
||||
...rest
|
||||
} = prev;
|
||||
return rest;
|
||||
});
|
||||
setState((prev) => stripCustomRuleSelectionFields(prev));
|
||||
// Effect on `state.coreValueDetailsByChipId` clears its dedicated
|
||||
// localStorage key when the field goes undefined, so we don't need to
|
||||
// touch `clearCoreValueDetailsLocalStorage()` directly here.
|
||||
@@ -195,6 +201,7 @@ export function CreateFlowProvider({
|
||||
replaceState,
|
||||
clearState,
|
||||
resetCustomRuleSelections,
|
||||
setMethodSectionsPinCommitted,
|
||||
interactionTouched,
|
||||
markCreateFlowInteraction,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { readLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||
import {
|
||||
buildMailtoShareHref,
|
||||
buildSlackWebShareUrl,
|
||||
DISCORD_NATIVE_DM_HUB_URL,
|
||||
DISCORD_WEB_DM_HUB_URL,
|
||||
scheduleNativeSchemeThenFallback,
|
||||
SLACK_NATIVE_OPEN_URL,
|
||||
type NativeFallbackTimers,
|
||||
type NativeNavigateDeps,
|
||||
} from "../../../../lib/create/shareChannels";
|
||||
import {
|
||||
buildPublicRuleUrl,
|
||||
downloadStoredRuleAsPdf,
|
||||
downloadTextFile,
|
||||
exportFilenameBase,
|
||||
exportStoredRuleAsCsv,
|
||||
exportStoredRuleAsMarkdown,
|
||||
} from "../../../../lib/create/ruleExport";
|
||||
|
||||
export type CompletedFlowActionBanner = {
|
||||
key: string;
|
||||
status: "positive" | "danger";
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
function browserNativeShareNavigateDeps(win: Window): NativeNavigateDeps {
|
||||
return {
|
||||
assignLocationHref: (url: string): void => {
|
||||
// Transient <a>: same-tab custom-protocol handshake as location.href without replacing the SPA.
|
||||
const anchor = win.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.rel = "noreferrer noopener";
|
||||
anchor.style.position = "absolute";
|
||||
anchor.style.left = "-9999px";
|
||||
win.document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
},
|
||||
getVisibilityState: (): Document["visibilityState"] =>
|
||||
win.document.visibilityState,
|
||||
onVisibilityChange: (listener: () => void): void => {
|
||||
win.document.addEventListener("visibilitychange", listener);
|
||||
},
|
||||
offVisibilityChange: (listener: () => void): void => {
|
||||
win.document.removeEventListener("visibilitychange", listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function browserNativeTimers(win: Window): NativeFallbackTimers {
|
||||
return {
|
||||
setTimeout: (cb: () => void, ms: number): unknown => win.setTimeout(cb, ms),
|
||||
clearTimeout: (handle: unknown): void =>
|
||||
win.clearTimeout(
|
||||
handle as ReturnType<typeof win.setTimeout>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* After native app handoff, the page can stay `visibilityState === "visible"` while
|
||||
* focus moves to the other app. Skip clipboard fallbacks in that case to avoid
|
||||
* `NotAllowedError` noise when Slack/compose already succeeded.
|
||||
*/
|
||||
function shouldSkipShareClipboardFallback(win: Window): boolean {
|
||||
return (
|
||||
win.document.visibilityState === "hidden" || !win.document.hasFocus()
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePublishedRuleShareContext(windowObj: Window): {
|
||||
url: string;
|
||||
title: string;
|
||||
text: string;
|
||||
} | null {
|
||||
const rule = readLastPublishedRule();
|
||||
if (!rule) return null;
|
||||
const url = buildPublicRuleUrl(windowObj.location.origin, rule.id);
|
||||
const summary =
|
||||
typeof rule.summary === "string" ? rule.summary.trim() : "";
|
||||
const text = summary.length > 0 ? summary : rule.title;
|
||||
return { url, title: rule.title, text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Share / export handlers for the completed step (`readLastPublishedRule`).
|
||||
*/
|
||||
export function useCompletedRuleShareExport({
|
||||
setActionBanner,
|
||||
}: {
|
||||
setActionBanner: (_: CompletedFlowActionBanner | null) => void;
|
||||
}): {
|
||||
copyPublishedRuleLink: () => Promise<void>;
|
||||
mailtoPublishedRule: () => void;
|
||||
sharePublishedRuleViaSignal: () => Promise<void>;
|
||||
sharePublishedRuleViaSlack: () => Promise<void>;
|
||||
sharePublishedRuleViaDiscord: () => Promise<void>;
|
||||
onSelectExportFormat: (_format: "pdf" | "csv" | "markdown") => void;
|
||||
} {
|
||||
const t = useTranslation("create.reviewAndComplete.completed");
|
||||
|
||||
const bannerNoRule = useCallback(() => {
|
||||
setActionBanner({
|
||||
key: "completedShareNoRule",
|
||||
status: "danger",
|
||||
title: t("shareNoRuleTitle"),
|
||||
description: t("shareNoRuleDescription"),
|
||||
});
|
||||
}, [setActionBanner, t]);
|
||||
|
||||
const bannerCopied = useCallback(() => {
|
||||
setActionBanner({
|
||||
key: "completedShareCopied",
|
||||
status: "positive",
|
||||
title: t("shareLinkCopiedTitle"),
|
||||
description: t("shareLinkCopiedDescription"),
|
||||
});
|
||||
}, [setActionBanner, t]);
|
||||
|
||||
const bannerCopyFailed = useCallback(() => {
|
||||
setActionBanner({
|
||||
key: "completedShareCopyFailed",
|
||||
status: "danger",
|
||||
title: t("shareCopyFailedTitle"),
|
||||
description: t("shareCopyFailedDescription"),
|
||||
});
|
||||
}, [setActionBanner, t]);
|
||||
|
||||
const copyUrlToClipboard = useCallback(
|
||||
async (
|
||||
url: string,
|
||||
banner?: () => void,
|
||||
options?: { suppressFailureWhenDocumentNotFocused?: boolean },
|
||||
) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
(banner ?? bannerCopied)();
|
||||
} catch {
|
||||
if (
|
||||
options?.suppressFailureWhenDocumentNotFocused === true &&
|
||||
typeof window !== "undefined" &&
|
||||
shouldSkipShareClipboardFallback(window)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
bannerCopyFailed();
|
||||
}
|
||||
},
|
||||
[bannerCopied, bannerCopyFailed],
|
||||
);
|
||||
|
||||
const copyPublishedRuleLink = useCallback(async () => {
|
||||
if (typeof window === "undefined") return;
|
||||
const ctx = resolvePublishedRuleShareContext(window);
|
||||
if (!ctx) {
|
||||
bannerNoRule();
|
||||
return;
|
||||
}
|
||||
await copyUrlToClipboard(ctx.url);
|
||||
}, [bannerNoRule, copyUrlToClipboard]);
|
||||
|
||||
const mailtoPublishedRule = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const ctx = resolvePublishedRuleShareContext(window);
|
||||
if (!ctx) {
|
||||
bannerNoRule();
|
||||
return;
|
||||
}
|
||||
|
||||
const body = `${ctx.text}\n\n${ctx.url}`;
|
||||
window.location.href = buildMailtoShareHref({
|
||||
subject: ctx.title,
|
||||
body,
|
||||
});
|
||||
}, [bannerNoRule]);
|
||||
|
||||
const tryNavigatorShareAbortOk = useCallback(
|
||||
async (data: ShareData): Promise<boolean> => {
|
||||
if (typeof navigator.share !== "function") return false;
|
||||
const can =
|
||||
typeof navigator.canShare !== "function" || navigator.canShare(data);
|
||||
if (!can) return false;
|
||||
try {
|
||||
await navigator.share(data);
|
||||
return true;
|
||||
} catch (e) {
|
||||
const err = e as { name?: string };
|
||||
if (err?.name === "AbortError") return true;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** Prefer URL-only share data when the platform allows it (common on mobile). */
|
||||
const shareViaWebShareApiOrFalse = useCallback(
|
||||
async (ctx: { url: string; title: string; text: string }) => {
|
||||
const urlOnly: ShareData = { url: ctx.url };
|
||||
if (await tryNavigatorShareAbortOk(urlOnly)) return true;
|
||||
const full: ShareData = {
|
||||
title: ctx.title,
|
||||
text: ctx.text,
|
||||
url: ctx.url,
|
||||
};
|
||||
return tryNavigatorShareAbortOk(full);
|
||||
},
|
||||
[tryNavigatorShareAbortOk],
|
||||
);
|
||||
|
||||
const sharePublishedRuleViaSignal = useCallback(async () => {
|
||||
if (typeof window === "undefined") return;
|
||||
const ctx = resolvePublishedRuleShareContext(window);
|
||||
if (!ctx) {
|
||||
bannerNoRule();
|
||||
return;
|
||||
}
|
||||
if (await shareViaWebShareApiOrFalse(ctx)) return;
|
||||
await copyUrlToClipboard(ctx.url);
|
||||
}, [bannerNoRule, copyUrlToClipboard, shareViaWebShareApiOrFalse]);
|
||||
|
||||
const sharePublishedRuleViaSlack = useCallback(async () => {
|
||||
if (typeof window === "undefined") return;
|
||||
const ctx = resolvePublishedRuleShareContext(window);
|
||||
if (!ctx) {
|
||||
bannerNoRule();
|
||||
return;
|
||||
}
|
||||
|
||||
const runSlackWebComposeFallback = async (): Promise<void> => {
|
||||
const slackUrl = buildSlackWebShareUrl(ctx.url);
|
||||
const popup = window.open(
|
||||
slackUrl,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
if (popup) return;
|
||||
|
||||
if (shouldSkipShareClipboardFallback(window)) return;
|
||||
|
||||
if (await shareViaWebShareApiOrFalse(ctx)) return;
|
||||
|
||||
if (shouldSkipShareClipboardFallback(window)) return;
|
||||
|
||||
await copyUrlToClipboard(
|
||||
ctx.url,
|
||||
() =>
|
||||
setActionBanner({
|
||||
key: "completedShareSlackFallback",
|
||||
status: "positive",
|
||||
title: t("shareSlackFallbackTitle"),
|
||||
description: t("shareSlackFallbackDescription"),
|
||||
}),
|
||||
{ suppressFailureWhenDocumentNotFocused: true },
|
||||
);
|
||||
};
|
||||
|
||||
scheduleNativeSchemeThenFallback(
|
||||
SLACK_NATIVE_OPEN_URL,
|
||||
() => void runSlackWebComposeFallback(),
|
||||
browserNativeShareNavigateDeps(window),
|
||||
browserNativeTimers(window),
|
||||
);
|
||||
}, [
|
||||
bannerNoRule,
|
||||
copyUrlToClipboard,
|
||||
shareViaWebShareApiOrFalse,
|
||||
setActionBanner,
|
||||
t,
|
||||
]);
|
||||
|
||||
const sharePublishedRuleViaDiscord = useCallback(async () => {
|
||||
if (typeof window === "undefined") return;
|
||||
const ctx = resolvePublishedRuleShareContext(window);
|
||||
if (!ctx) {
|
||||
bannerNoRule();
|
||||
return;
|
||||
}
|
||||
|
||||
if (await shareViaWebShareApiOrFalse(ctx)) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(ctx.url);
|
||||
setActionBanner({
|
||||
key: "completedShareDiscordPaste",
|
||||
status: "positive",
|
||||
title: t("shareDiscordPasteTitle"),
|
||||
description: t("shareDiscordPasteDescription"),
|
||||
});
|
||||
} catch {
|
||||
bannerCopyFailed();
|
||||
}
|
||||
|
||||
scheduleNativeSchemeThenFallback(
|
||||
DISCORD_NATIVE_DM_HUB_URL,
|
||||
() =>
|
||||
void window.open(
|
||||
DISCORD_WEB_DM_HUB_URL,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
),
|
||||
browserNativeShareNavigateDeps(window),
|
||||
browserNativeTimers(window),
|
||||
);
|
||||
}, [
|
||||
bannerCopyFailed,
|
||||
bannerNoRule,
|
||||
shareViaWebShareApiOrFalse,
|
||||
setActionBanner,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onSelectExportFormat = useCallback(
|
||||
(format: "pdf" | "csv" | "markdown") => {
|
||||
if (typeof window === "undefined") return;
|
||||
const rule = readLastPublishedRule();
|
||||
if (!rule) {
|
||||
setActionBanner({
|
||||
key: "completedExportNoRule",
|
||||
status: "danger",
|
||||
title: t("shareNoRuleTitle"),
|
||||
description: t("shareNoRuleDescription"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const base = exportFilenameBase(rule);
|
||||
try {
|
||||
if (format === "pdf") {
|
||||
downloadStoredRuleAsPdf(rule);
|
||||
} else if (format === "csv") {
|
||||
const csv = exportStoredRuleAsCsv(rule);
|
||||
downloadTextFile(
|
||||
`${base}-community-rule.csv`,
|
||||
csv,
|
||||
"text/csv;charset=utf-8",
|
||||
);
|
||||
} else {
|
||||
const md = exportStoredRuleAsMarkdown(rule);
|
||||
downloadTextFile(
|
||||
`${base}-community-rule.md`,
|
||||
md,
|
||||
"text/markdown;charset=utf-8",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error && e.message === "exportEmptyDocument";
|
||||
setActionBanner({
|
||||
key: "completedExportFailed",
|
||||
status: "danger",
|
||||
title: msg ? t("exportEmptyDocumentTitle") : t("exportFailedTitle"),
|
||||
description: msg
|
||||
? t("exportEmptyDocumentDescription")
|
||||
: t("exportFailedDescription"),
|
||||
});
|
||||
}
|
||||
},
|
||||
[setActionBanner, t],
|
||||
);
|
||||
|
||||
return {
|
||||
copyPublishedRuleLink,
|
||||
mailtoPublishedRule,
|
||||
sharePublishedRuleViaSignal,
|
||||
sharePublishedRuleViaSlack,
|
||||
sharePublishedRuleViaDiscord,
|
||||
onSelectExportFormat,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { CreateFlowState, CreateFlowStep } from "../types";
|
||||
import { saveDraftToServer } from "../../../../lib/create/api";
|
||||
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
||||
import { saveDraftToServer, updatePublishedRule } from "../../../../lib/create/api";
|
||||
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||
import messages from "../../../../messages/en/index";
|
||||
|
||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||
@@ -44,16 +46,51 @@ export function useCreateFlowExit({
|
||||
}
|
||||
|
||||
if (saveDraft && SYNC_ENABLED) {
|
||||
const payload: CreateFlowState = {
|
||||
...state,
|
||||
...(currentStep ? { currentStep } : {}),
|
||||
};
|
||||
const result = await saveDraftToServer(payload);
|
||||
if (result.ok === true) {
|
||||
setDraftSaveBannerMessage?.(null);
|
||||
const editingId =
|
||||
typeof state.editingPublishedRuleId === "string"
|
||||
? state.editingPublishedRuleId.trim()
|
||||
: "";
|
||||
if (editingId.length > 0) {
|
||||
const payloadResult = buildPublishPayload(state);
|
||||
if (payloadResult.ok === false) {
|
||||
setDraftSaveBannerMessage?.(
|
||||
payloadResult.error === "missingCommunityName"
|
||||
? messages.create.reviewAndComplete.publish
|
||||
.missingCommunityName
|
||||
: payloadResult.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { title, summary, document } = payloadResult;
|
||||
const updateResult = await updatePublishedRule(editingId, {
|
||||
title,
|
||||
summary: summary ?? null,
|
||||
document,
|
||||
});
|
||||
if (updateResult.ok === true) {
|
||||
writeLastPublishedRule({
|
||||
id: editingId,
|
||||
title,
|
||||
summary: summary ?? null,
|
||||
document,
|
||||
});
|
||||
setDraftSaveBannerMessage?.(null);
|
||||
} else {
|
||||
setDraftSaveBannerMessage?.(updateResult.error);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setDraftSaveBannerMessage?.(result.message);
|
||||
return;
|
||||
const payload: CreateFlowState = {
|
||||
...state,
|
||||
...(currentStep ? { currentStep } : {}),
|
||||
};
|
||||
const result = await saveDraftToServer(payload);
|
||||
if (result.ok === true) {
|
||||
setDraftSaveBannerMessage?.(null);
|
||||
} else {
|
||||
setDraftSaveBannerMessage?.(result.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
||||
import { publishRule } from "../../../../lib/create/api";
|
||||
import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
|
||||
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||
import messages from "../../../../messages/en/index";
|
||||
import type { CreateFlowState } from "../types";
|
||||
import {
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||
} from "../utils/flowSteps";
|
||||
import { createFlowStepPath } from "../utils/createFlowPaths";
|
||||
|
||||
type AppRouterLike = { push: (_href: string) => void };
|
||||
|
||||
@@ -16,37 +21,26 @@ type OpenLogin = (args: {
|
||||
}) => void;
|
||||
|
||||
export type UseCreateFlowFinalizeResult = {
|
||||
/** Set when publish fails (validation, server error, or empty server message). Reset on each `finalize()` invocation. */
|
||||
publishBannerMessage: string | null;
|
||||
setPublishBannerMessage: (_message: string | null) => void;
|
||||
/** True from the moment the publish request fires until the response resolves. */
|
||||
isPublishing: boolean;
|
||||
/**
|
||||
* Build a publish payload from the current `CreateFlowState`, post it to
|
||||
* `publishRule`, and route to `/create/completed` on success.
|
||||
*
|
||||
* Failure modes:
|
||||
* - Payload validation fails → surface the localized banner message.
|
||||
* - 401 from the API → re-open the login modal targeting `/create/final-review?syncDraft=1` so the user can retry post-auth.
|
||||
* - Any other failure → show either the trimmed server message or a generic localized fallback.
|
||||
*/
|
||||
finalize: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encapsulates the Final Review → publish flow that previously lived inline
|
||||
* in `CreateFlowLayoutClient`. Keeps publish state (banner + in-flight flag)
|
||||
* co-located with the publish handler so the layout shell only has to wire
|
||||
* the resulting message into its banner stack.
|
||||
*/
|
||||
/** Final Review → publish: banner + `isPublishing`, consumed by `CreateFlowLayoutClient`. */
|
||||
export function useCreateFlowFinalize({
|
||||
state,
|
||||
router,
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath,
|
||||
}: {
|
||||
state: CreateFlowState;
|
||||
router: AppRouterLike;
|
||||
openLogin: OpenLogin;
|
||||
updateState: (_patch: Partial<CreateFlowState>) => void;
|
||||
/** Session gate return path (`?syncDraft=1`) — differs for `/create/edit-rule` vs `/create/final-review`. */
|
||||
loginReturnPath: string;
|
||||
}): UseCreateFlowFinalizeResult {
|
||||
const [publishBannerMessage, setPublishBannerMessage] = useState<
|
||||
string | null
|
||||
@@ -66,6 +60,46 @@ export function useCreateFlowFinalize({
|
||||
}
|
||||
const { title, summary, document: ruleDocument } = payloadResult;
|
||||
setIsPublishing(true);
|
||||
|
||||
const editingId =
|
||||
typeof state.editingPublishedRuleId === "string"
|
||||
? state.editingPublishedRuleId.trim()
|
||||
: "";
|
||||
|
||||
if (editingId.length > 0) {
|
||||
const updateResult = await updatePublishedRule(editingId, {
|
||||
title,
|
||||
summary: summary ?? null,
|
||||
document: ruleDocument,
|
||||
});
|
||||
setIsPublishing(false);
|
||||
if (updateResult.ok === true) {
|
||||
writeLastPublishedRule({
|
||||
id: editingId,
|
||||
title,
|
||||
summary: summary ?? null,
|
||||
document: ruleDocument,
|
||||
});
|
||||
updateState({ editingPublishedRuleId: undefined });
|
||||
router.push(createFlowStepPath("completed"));
|
||||
return;
|
||||
}
|
||||
if (updateResult.status === 401) {
|
||||
openLogin({
|
||||
variant: "default",
|
||||
nextPath: loginReturnPath,
|
||||
backdropVariant: "blurredYellow",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPublishBannerMessage(
|
||||
updateResult.error.trim() !== ""
|
||||
? updateResult.error
|
||||
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const publishResult = await publishRule({
|
||||
title,
|
||||
summary,
|
||||
@@ -79,13 +113,18 @@ export function useCreateFlowFinalize({
|
||||
summary: summary ?? null,
|
||||
document: ruleDocument,
|
||||
});
|
||||
router.push("/create/completed");
|
||||
router.push(
|
||||
createFlowStepPath("completed", {
|
||||
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (publishResult.status === 401) {
|
||||
openLogin({
|
||||
variant: "default",
|
||||
nextPath: "/create/final-review?syncDraft=1",
|
||||
nextPath: loginReturnPath,
|
||||
backdropVariant: "blurredYellow",
|
||||
});
|
||||
return;
|
||||
@@ -95,7 +134,7 @@ export function useCreateFlowFinalize({
|
||||
? publishResult.error
|
||||
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
||||
);
|
||||
}, [state, router, openLogin]);
|
||||
}, [state, router, openLogin, updateState, loginReturnPath]);
|
||||
|
||||
return {
|
||||
publishBannerMessage,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import {
|
||||
type CreateFlowNavigationOptions,
|
||||
type CreateFlowReviewReturnTarget,
|
||||
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||
buildTemplateReviewHref,
|
||||
getNextStep,
|
||||
getPreviousStep,
|
||||
@@ -46,7 +48,10 @@ export function useCreateFlowNavigation(
|
||||
currentStep: CreateFlowStep | null;
|
||||
goToNextStep: () => void;
|
||||
goToPreviousStep: () => void;
|
||||
goToStep: (_step: CreateFlowStep) => void;
|
||||
goToStep: (
|
||||
_step: CreateFlowStep,
|
||||
_navOpts?: { reviewReturn?: CreateFlowReviewReturnTarget },
|
||||
) => void;
|
||||
canGoNext: () => boolean;
|
||||
canGoBack: () => boolean;
|
||||
nextStep: CreateFlowStep | null;
|
||||
@@ -122,11 +127,21 @@ export function useCreateFlowNavigation(
|
||||
);
|
||||
|
||||
const goToStep = useCallback(
|
||||
(step: CreateFlowStep) => {
|
||||
(
|
||||
step: CreateFlowStep,
|
||||
navOpts?: { reviewReturn?: CreateFlowReviewReturnTarget },
|
||||
) => {
|
||||
blurActiveElement();
|
||||
router.push(`/create/${step}`);
|
||||
const params = new URLSearchParams(searchParams?.toString() ?? "");
|
||||
if (navOpts?.reviewReturn != null) {
|
||||
params.set(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, navOpts.reviewReturn);
|
||||
} else {
|
||||
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
|
||||
}
|
||||
const qs = params.toString();
|
||||
router.push(qs.length > 0 ? `/create/${step}?${qs}` : `/create/${step}`);
|
||||
},
|
||||
[router],
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const canGoNext = useCallback(() => nextStep !== null, [nextStep]);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
|
||||
|
||||
/**
|
||||
* Stable writer for `customMethodCardFieldBlocksById[id]` used from facet card
|
||||
* modals. Uses {@link replaceState} so merges read the latest draft (no stale
|
||||
* closure over `customMethodCardFieldBlocksById`).
|
||||
*/
|
||||
export function useCustomMethodCardFieldBlocksChange(cardId: string | null) {
|
||||
const { replaceState, markCreateFlowInteraction } = useCreateFlow();
|
||||
|
||||
return useCallback(
|
||||
(nextBlocks: CustomMethodCardFieldBlock[]) => {
|
||||
if (!cardId) return;
|
||||
markCreateFlowInteraction();
|
||||
replaceState((prev) => ({
|
||||
...prev,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(prev.customMethodCardFieldBlocksById ?? {}),
|
||||
[cardId]: nextBlocks,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[cardId, markCreateFlowInteraction, replaceState],
|
||||
);
|
||||
}
|
||||
@@ -1,74 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import facetGroups from "../../../../data/create/customRule/_facetGroups.json";
|
||||
import {
|
||||
type CreateFlowState,
|
||||
} from "../types";
|
||||
import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString";
|
||||
import type { MethodFacetApiSectionId } from "../../../../lib/create/customRuleFacets";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
/**
|
||||
* Card-deck section ids served by `/api/create-flow/methods` (CR-88 §9.2).
|
||||
* Same tuple as {@link METHOD_FACET_API_SECTION_IDS} (`CUSTOM_RULE_FACETS`, CR-92).
|
||||
*/
|
||||
export type RecommendationSection =
|
||||
| "communication"
|
||||
| "membership"
|
||||
| "decisionApproaches"
|
||||
| "conflictManagement";
|
||||
|
||||
const FACET_GROUPS = ["size", "orgType", "scale", "maturity"] as const;
|
||||
type FacetGroupId = (typeof FACET_GROUPS)[number];
|
||||
|
||||
/** Reverse map chipId → canonical facet value id, per group. */
|
||||
const CHIP_TO_VALUE_BY_GROUP: Record<FacetGroupId, Record<string, string>> = (() => {
|
||||
const out: Record<FacetGroupId, Record<string, string>> = {
|
||||
size: {},
|
||||
orgType: {},
|
||||
scale: {},
|
||||
maturity: {},
|
||||
};
|
||||
for (const group of FACET_GROUPS) {
|
||||
const block = (facetGroups as Record<string, unknown>)[group];
|
||||
if (block && typeof block === "object" && "values" in block) {
|
||||
const values = (block as { values: Record<string, { chipId: string }> })
|
||||
.values;
|
||||
for (const [valueId, entry] of Object.entries(values)) {
|
||||
out[group][entry.chipId] = valueId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
/** Chip-id state accessors per group. */
|
||||
const STATE_KEY_BY_GROUP: Record<FacetGroupId, keyof CreateFlowState> = {
|
||||
size: "selectedCommunitySizeIds",
|
||||
orgType: "selectedOrganizationTypeIds",
|
||||
scale: "selectedScaleIds",
|
||||
maturity: "selectedMaturityIds",
|
||||
};
|
||||
|
||||
function readChipIds(
|
||||
state: CreateFlowState,
|
||||
group: FacetGroupId,
|
||||
): string[] {
|
||||
const value = state[STATE_KEY_BY_GROUP[group]];
|
||||
return Array.isArray(value) ? (value as string[]) : [];
|
||||
}
|
||||
|
||||
function buildFacetQuery(state: CreateFlowState): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const group of FACET_GROUPS) {
|
||||
const valuesById = CHIP_TO_VALUE_BY_GROUP[group];
|
||||
for (const chipId of readChipIds(state, group)) {
|
||||
const valueId = valuesById[chipId];
|
||||
if (valueId) {
|
||||
params.append(`facet.${group}`, valueId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
export type RecommendationSection = MethodFacetApiSectionId;
|
||||
|
||||
export type FacetRecommendationsResult = {
|
||||
/** `true` once the network call completes (or short-circuits with no facets). */
|
||||
@@ -99,7 +40,10 @@ export function useFacetRecommendations(
|
||||
section: RecommendationSection,
|
||||
): FacetRecommendationsResult {
|
||||
const { state } = useCreateFlow();
|
||||
const queryString = useMemo(() => buildFacetQuery(state), [state]);
|
||||
const queryString = useMemo(
|
||||
() => buildFacetQueryString(state),
|
||||
[state],
|
||||
);
|
||||
const hasAnyFacets = queryString.length > 0;
|
||||
|
||||
const [result, setResult] = useState<FacetRecommendationsResult>({
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
mergeCompactCardIdsWithPinnedSelected,
|
||||
orderRankedMethodsWithPinnedSelection,
|
||||
} from "../../../../lib/create/methodCardDisplayOrder";
|
||||
import {
|
||||
deriveCompactCards,
|
||||
rankMethodsByScore,
|
||||
useFacetRecommendations,
|
||||
type RecommendationSection,
|
||||
} from "./useFacetRecommendations";
|
||||
|
||||
type MethodEntry = { id: string; label: string; supportText: string };
|
||||
|
||||
/**
|
||||
* Applies score ranking, compact-slot rules, then surfaces selected ids first in
|
||||
* `selected*Ids` order (most-recent add at index 0 via
|
||||
* {@link moveFacetSelectionIdToFront}). Selection-first applies whenever the facet
|
||||
* has any selection — not only after footer Confirm (`methodSectionsPinCommitted`).
|
||||
*/
|
||||
export function useMethodCardDeckOrdering(
|
||||
section: RecommendationSection,
|
||||
methods: readonly MethodEntry[],
|
||||
selectedIds: readonly string[],
|
||||
) {
|
||||
const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section);
|
||||
|
||||
const rankedMethods = useMemo(
|
||||
() => rankMethodsByScore(methods, scoresBySlug),
|
||||
[methods, scoresBySlug],
|
||||
);
|
||||
|
||||
const selectionShowcaseActive = selectedIds.length > 0;
|
||||
|
||||
const displayMethods = useMemo(
|
||||
() =>
|
||||
orderRankedMethodsWithPinnedSelection(
|
||||
rankedMethods,
|
||||
selectedIds,
|
||||
selectionShowcaseActive,
|
||||
),
|
||||
[rankedMethods, selectedIds, selectionShowcaseActive],
|
||||
);
|
||||
|
||||
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
|
||||
() =>
|
||||
deriveCompactCards(
|
||||
rankedMethods,
|
||||
scoresBySlug,
|
||||
hasAnyFacets,
|
||||
/* limit */ 5,
|
||||
),
|
||||
[rankedMethods, scoresBySlug, hasAnyFacets],
|
||||
);
|
||||
|
||||
const compactCardIds = useMemo(
|
||||
() =>
|
||||
mergeCompactCardIdsWithPinnedSelected(
|
||||
displayMethods.map((m) => m.id),
|
||||
baseCompactCardIds,
|
||||
selectedIds,
|
||||
selectionShowcaseActive,
|
||||
5,
|
||||
),
|
||||
[displayMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive],
|
||||
);
|
||||
|
||||
const sampleCards = useMemo(
|
||||
() =>
|
||||
displayMethods.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
supportText: entry.supportText,
|
||||
recommended: recommendedIds.has(entry.id),
|
||||
})),
|
||||
[displayMethods, recommendedIds],
|
||||
);
|
||||
|
||||
const methodById = useMemo(
|
||||
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
|
||||
[rankedMethods],
|
||||
);
|
||||
|
||||
return {
|
||||
rankedMethods,
|
||||
displayMethods,
|
||||
compactCardIds,
|
||||
recommendedIds,
|
||||
sampleCards,
|
||||
methodById,
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
buildCoreValuesPrefillFromTemplateBody,
|
||||
buildTemplateCustomizePrefill,
|
||||
} from "../../../../lib/create/applyTemplatePrefill";
|
||||
import { buildTemplateCustomizePrefill } from "../../../../lib/create/applyTemplatePrefill";
|
||||
import { loadTemplateReviewBySlug } from "../../../../lib/create/loadTemplateReviewBySlug";
|
||||
import { methodSectionsPinsForHydratedSelections } from "../../../../lib/create/publishedDocumentToCreateFlowState";
|
||||
import { stripCustomRuleSelectionFields } from "../../../../lib/create/stripCustomRuleSelectionFields";
|
||||
import messages from "../../../../messages/en/index";
|
||||
import type { CreateFlowState } from "../types";
|
||||
import type {
|
||||
CreateFlowContextValue,
|
||||
CreateFlowState,
|
||||
} from "../types";
|
||||
|
||||
type AppRouterLike = { push: (_href: string) => void };
|
||||
type UpdateState = (_patch: Partial<CreateFlowState>) => void;
|
||||
type UpdateState = CreateFlowContextValue["updateState"];
|
||||
type ReplaceStateFn = CreateFlowContextValue["replaceState"];
|
||||
|
||||
export type UseTemplateReviewActionsResult = {
|
||||
/** True iff the current pathname is a template-review route (locale/basePath tolerant). */
|
||||
@@ -30,11 +33,12 @@ export type UseTemplateReviewActionsResult = {
|
||||
*/
|
||||
handleCustomize: () => Promise<void>;
|
||||
/**
|
||||
* Use without changes: scrub any prior customize picks, seed the core-values
|
||||
* snapshot from the template's Values section, drop that section from
|
||||
* `state.sections`, and route to `/create/confirm-stakeholders` (or
|
||||
* `/create/informational` with a pin to skip past `/create/review` to
|
||||
* `/create/confirm-stakeholders` later).
|
||||
* Use without changes: scrub any prior customize picks, seed core values +
|
||||
* method-card selections from the template body (same id mapping as
|
||||
* Customize) so drilling from final-review via + shows selected cards, drop
|
||||
* the Values row from `state.sections`, and route to
|
||||
* `/create/confirm-stakeholders` (or `/create/informational` with a pin to
|
||||
* skip past `/create/review` to `/create/confirm-stakeholders` later).
|
||||
*/
|
||||
handleUseWithoutChanges: () => Promise<void>;
|
||||
};
|
||||
@@ -55,19 +59,19 @@ export type UseTemplateReviewActionsResult = {
|
||||
* setTemplateReviewApplyError,
|
||||
* handleCustomize,
|
||||
* handleUseWithoutChanges,
|
||||
* } = useTemplateReviewActions({ pathname, state, updateState, resetCustomRuleSelections, router });
|
||||
* } = useTemplateReviewActions({ pathname, state, updateState, replaceState, router });
|
||||
*/
|
||||
export function useTemplateReviewActions({
|
||||
pathname,
|
||||
state,
|
||||
updateState,
|
||||
resetCustomRuleSelections,
|
||||
replaceState,
|
||||
router,
|
||||
}: {
|
||||
pathname: string | null | undefined;
|
||||
state: CreateFlowState;
|
||||
updateState: UpdateState;
|
||||
resetCustomRuleSelections: () => void;
|
||||
replaceState: ReplaceStateFn;
|
||||
router: AppRouterLike;
|
||||
}): UseTemplateReviewActionsResult {
|
||||
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
|
||||
@@ -95,10 +99,15 @@ export function useTemplateReviewActions({
|
||||
return;
|
||||
}
|
||||
const prefill = buildTemplateCustomizePrefill(loaded.template.body);
|
||||
const pinPatch = methodSectionsPinsForHydratedSelections(prefill);
|
||||
const hasCommunityName =
|
||||
typeof state.title === "string" && state.title.trim().length > 0;
|
||||
updateState({
|
||||
...prefill,
|
||||
methodSectionsPinCommitted: {
|
||||
...state.methodSectionsPinCommitted,
|
||||
...pinPatch,
|
||||
},
|
||||
templateReviewBackSlug: undefined,
|
||||
...(hasCommunityName
|
||||
? { pendingTemplateAction: undefined }
|
||||
@@ -112,7 +121,13 @@ export function useTemplateReviewActions({
|
||||
router.push(
|
||||
hasCommunityName ? "/create/core-values" : "/create/informational",
|
||||
);
|
||||
}, [router, state.title, templateReviewSlug, updateState]);
|
||||
}, [
|
||||
router,
|
||||
state.methodSectionsPinCommitted,
|
||||
state.title,
|
||||
templateReviewSlug,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleUseWithoutChanges = useCallback(async () => {
|
||||
if (!templateReviewSlug) return;
|
||||
@@ -143,18 +158,19 @@ export function useTemplateReviewActions({
|
||||
return;
|
||||
}
|
||||
|
||||
// Using the template verbatim: scrub any prior customize picks so they
|
||||
// don't bleed into `document.coreValues` at publish time.
|
||||
resetCustomRuleSelections();
|
||||
const hasCommunityName =
|
||||
typeof state.title === "string" && state.title.trim().length > 0;
|
||||
|
||||
// Seed the core-values snapshot from the Values section so the
|
||||
// final-review chip modal can edit them (it keys edits by chip id).
|
||||
// The Values entries themselves are then dropped from `sections` to
|
||||
// avoid publishing `document.coreValues` and `document.sections.Values`
|
||||
// for the same data — matches the "Customize" path's data shape.
|
||||
const coreValuesPrefill = buildCoreValuesPrefillFromTemplateBody(doc);
|
||||
const sectionsWithoutValues =
|
||||
Object.keys(coreValuesPrefill).length > 0
|
||||
// Atomic read-modify-write: strip prior custom-rule picks and merge template
|
||||
// body in one replaceState so method ids are never lost across React batching
|
||||
// (reset + update separately could leave selections undefined in Strict Mode).
|
||||
replaceState((prev) => {
|
||||
const base = stripCustomRuleSelectionFields(prev);
|
||||
const customizePrefill = buildTemplateCustomizePrefill(doc);
|
||||
const hasValuesSeed =
|
||||
customizePrefill.selectedCoreValueIds !== undefined;
|
||||
|
||||
const sectionsWithoutValues = hasValuesSeed
|
||||
? sections.filter((s) => {
|
||||
const name = (s as { categoryName?: unknown }).categoryName;
|
||||
if (typeof name !== "string") return true;
|
||||
@@ -163,38 +179,64 @@ export function useTemplateReviewActions({
|
||||
})
|
||||
: sections;
|
||||
|
||||
const summaryRaw =
|
||||
typeof template.description === "string"
|
||||
? template.description.trim()
|
||||
: "";
|
||||
const hasCommunityName =
|
||||
typeof state.title === "string" && state.title.trim().length > 0;
|
||||
updateState({
|
||||
...coreValuesPrefill,
|
||||
sections: sectionsWithoutValues,
|
||||
...(summaryRaw.length > 0 ? { summary: summaryRaw } : {}),
|
||||
templateReviewBackSlug: templateReviewSlug,
|
||||
...(hasCommunityName
|
||||
? { pendingTemplateAction: undefined }
|
||||
: {
|
||||
pendingTemplateAction: {
|
||||
slug: templateReviewSlug,
|
||||
mode: "useWithoutChanges",
|
||||
},
|
||||
}),
|
||||
const hasCommunityName =
|
||||
typeof prev.title === "string" && prev.title.trim().length > 0;
|
||||
|
||||
const pinPatch =
|
||||
methodSectionsPinsForHydratedSelections(customizePrefill);
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(hasValuesSeed
|
||||
? {
|
||||
selectedCoreValueIds: customizePrefill.selectedCoreValueIds,
|
||||
coreValuesChipsSnapshot:
|
||||
customizePrefill.coreValuesChipsSnapshot,
|
||||
}
|
||||
: {}),
|
||||
...(customizePrefill.selectedCommunicationMethodIds !== undefined
|
||||
? {
|
||||
selectedCommunicationMethodIds:
|
||||
customizePrefill.selectedCommunicationMethodIds,
|
||||
}
|
||||
: {}),
|
||||
...(customizePrefill.selectedMembershipMethodIds !== undefined
|
||||
? {
|
||||
selectedMembershipMethodIds:
|
||||
customizePrefill.selectedMembershipMethodIds,
|
||||
}
|
||||
: {}),
|
||||
...(customizePrefill.selectedDecisionApproachIds !== undefined
|
||||
? {
|
||||
selectedDecisionApproachIds:
|
||||
customizePrefill.selectedDecisionApproachIds,
|
||||
}
|
||||
: {}),
|
||||
...(customizePrefill.selectedConflictManagementIds !== undefined
|
||||
? {
|
||||
selectedConflictManagementIds:
|
||||
customizePrefill.selectedConflictManagementIds,
|
||||
}
|
||||
: {}),
|
||||
sections: sectionsWithoutValues,
|
||||
methodSectionsPinCommitted: pinPatch,
|
||||
templateReviewBackSlug: templateReviewSlug,
|
||||
...(hasCommunityName
|
||||
? { pendingTemplateAction: undefined }
|
||||
: {
|
||||
pendingTemplateAction: {
|
||||
slug: templateReviewSlug,
|
||||
mode: "useWithoutChanges",
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
router.push(
|
||||
hasCommunityName
|
||||
? "/create/confirm-stakeholders"
|
||||
: "/create/informational",
|
||||
);
|
||||
}, [
|
||||
resetCustomRuleSelections,
|
||||
router,
|
||||
state.title,
|
||||
templateReviewSlug,
|
||||
updateState,
|
||||
]);
|
||||
}, [replaceState, router, state.title, templateReviewSlug]);
|
||||
|
||||
return {
|
||||
isTemplateReviewRoute,
|
||||
|
||||
@@ -2,20 +2,7 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import { InformationalScreen } from "./informational/InformationalScreen";
|
||||
import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen";
|
||||
import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen";
|
||||
import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen";
|
||||
import { CoreValuesSelectScreen } from "./select/CoreValuesSelectScreen";
|
||||
import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
|
||||
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
|
||||
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
|
||||
import { FinalReviewScreen } from "./review/FinalReviewScreen";
|
||||
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen";
|
||||
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen";
|
||||
import { ConflictManagementScreen } from "./card/ConflictManagementScreen";
|
||||
import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen";
|
||||
import { CompletedScreen } from "./completed/CompletedScreen";
|
||||
import { renderCreateFlowScreen } from "./createFlowScreenComponents";
|
||||
|
||||
/**
|
||||
* Maps each wizard `screenId` to its screen component.
|
||||
@@ -23,71 +10,14 @@ import { CompletedScreen } from "./completed/CompletedScreen";
|
||||
* **Folder rule (Figma):** subfolders match `CREATE_FLOW_SCREEN_REGISTRY[].layoutKind`
|
||||
* — `select/` (two-column chip flows), `card/` (compact card-stack steps), `text/`, etc.
|
||||
* The URL segment (`communication-methods`) is not the folder name; see `createFlowScreenRegistry.ts`.
|
||||
*
|
||||
* Implementation lives in {@link renderCreateFlowScreen} (`createFlowScreenComponents.tsx`)
|
||||
* so the registry metadata and this router stay easier to keep in sync (CR-92 §3).
|
||||
*/
|
||||
export function CreateFlowScreenView({
|
||||
screenId,
|
||||
}: {
|
||||
screenId: CreateFlowStep;
|
||||
}): ReactNode {
|
||||
switch (screenId) {
|
||||
case "informational":
|
||||
return <InformationalScreen />;
|
||||
case "community-name":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.community.communityName"
|
||||
stateField="title"
|
||||
maxLength={48}
|
||||
/>
|
||||
);
|
||||
case "community-structure":
|
||||
return <CommunityStructureSelectScreen />;
|
||||
case "community-context":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.community.communityContext"
|
||||
stateField="communityContext"
|
||||
maxLength={200}
|
||||
mainAlign="center"
|
||||
/>
|
||||
);
|
||||
case "community-size":
|
||||
return <CommunitySizeSelectScreen />;
|
||||
case "community-upload":
|
||||
return <CommunityUploadScreen />;
|
||||
case "community-save":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.community.communitySave"
|
||||
stateField="communitySaveEmail"
|
||||
maxLength={254}
|
||||
mainAlign="center"
|
||||
inputType="email"
|
||||
showCharacterCount={false}
|
||||
headerJustification="center"
|
||||
/>
|
||||
);
|
||||
case "review":
|
||||
return <CommunityReviewScreen />;
|
||||
case "core-values":
|
||||
return <CoreValuesSelectScreen />;
|
||||
case "communication-methods":
|
||||
return <CommunicationMethodsScreen />;
|
||||
case "membership-methods":
|
||||
return <MembershipMethodsScreen />;
|
||||
case "decision-approaches":
|
||||
return <DecisionApproachesScreen />;
|
||||
case "conflict-management":
|
||||
return <ConflictManagementScreen />;
|
||||
case "confirm-stakeholders":
|
||||
return <ConfirmStakeholdersScreen />;
|
||||
case "final-review":
|
||||
return <FinalReviewScreen />;
|
||||
case "completed":
|
||||
return <CompletedScreen />;
|
||||
default: {
|
||||
const _exhaustive: never = screenId;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
return renderCreateFlowScreen(screenId);
|
||||
}
|
||||
|
||||
@@ -8,21 +8,18 @@
|
||||
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
|
||||
* reuse `CardStack` / `CreateFlowStepShell` as needed.
|
||||
*
|
||||
* Card click opens the Figma "Add Platform" create modal (node `20246-15829`) with three
|
||||
* editable sections rendered by {@link CommunicationMethodEditFields}. The same field set is
|
||||
* reused on `/create/final-review` — see `FinalReviewChipEditModal`. Confirm persists both
|
||||
* the chip selection and any user edits as a `communicationMethodDetailsById[id]` override.
|
||||
* Card click opens the Figma create modal (node `20246-15829`) with three
|
||||
* editable sections rendered by {@link CommunicationMethodEditFields}. The primary
|
||||
* action is **Add Platform** for an unselected card; a selected card in view mode has
|
||||
* no footer primary — **Remove** is available from the kebab (same behavior as legacy
|
||||
* footer remove via {@link removeMethodCardFromFacetSelection}).
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import {
|
||||
deriveCompactCards,
|
||||
rankMethodsByScore,
|
||||
useFacetRecommendations,
|
||||
} from "../../hooks/useFacetRecommendations";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../../../components/cards/CardStack";
|
||||
import Create from "../../../../components/modals/Create";
|
||||
@@ -33,84 +30,103 @@ import {
|
||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
import { CommunicationMethodEditFields } from "../../components/methodEditFields";
|
||||
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||
import { communicationPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
||||
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
|
||||
import { communicationMethodFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId";
|
||||
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
|
||||
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
|
||||
import {
|
||||
cloneMethodCardBlocksForDuplicate,
|
||||
cloneMethodCardDetailsForDuplicate,
|
||||
duplicateMethodCardTitle,
|
||||
forkMethodCardFacetMapsForDuplicate,
|
||||
omitIdFromStringRecord,
|
||||
} from "../../../../../lib/create/duplicateMethodCardModalDraft";
|
||||
import type { CommunicationMethodDetailEntry } from "../../types";
|
||||
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
||||
|
||||
export function CommunicationMethodsScreen() {
|
||||
const m = useMessages();
|
||||
const comm = m.create.customRule.communication;
|
||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||
useCreateFlow();
|
||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||
const customizeSnapshotRef = useRef<
|
||||
MethodCardCustomizeSnapshot<CommunicationMethodDetailEntry> | null
|
||||
>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
const [pendingDraft, setPendingDraft] =
|
||||
useState<CommunicationMethodDetailEntry | null>(null);
|
||||
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
||||
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||
CustomMethodCardFieldBlock[] | null
|
||||
>(null);
|
||||
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
||||
useState<MethodCardHeaderDraft | null>(null);
|
||||
|
||||
const selectedIds = state.selectedCommunicationMethodIds ?? [];
|
||||
|
||||
const { scoresBySlug, hasAnyFacets } =
|
||||
useFacetRecommendations("communication");
|
||||
const rankedMethods = useMemo(
|
||||
() => rankMethodsByScore(comm.methods, scoresBySlug),
|
||||
[comm.methods, scoresBySlug],
|
||||
);
|
||||
|
||||
const { compactCardIds, recommendedIds } = useMemo(
|
||||
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
|
||||
[rankedMethods, scoresBySlug, hasAnyFacets],
|
||||
);
|
||||
|
||||
const sampleCards = useMemo(
|
||||
const mergedMethods = useMemo(
|
||||
() =>
|
||||
rankedMethods.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
supportText: entry.supportText,
|
||||
recommended: recommendedIds.has(entry.id),
|
||||
})),
|
||||
[rankedMethods, recommendedIds],
|
||||
mergePresetMethodsWithCustom(
|
||||
comm.methods,
|
||||
selectedIds,
|
||||
state.customMethodCardMetaById,
|
||||
),
|
||||
[comm.methods, selectedIds, state.customMethodCardMetaById],
|
||||
);
|
||||
|
||||
const methodById = useMemo(
|
||||
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
|
||||
[rankedMethods],
|
||||
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
||||
"communication",
|
||||
mergedMethods,
|
||||
selectedIds,
|
||||
);
|
||||
|
||||
const handleOpenAddWizard = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
setAddCustomWizardOpen(true);
|
||||
}, [markCreateFlowInteraction]);
|
||||
|
||||
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
|
||||
|
||||
const description = expanded ? (
|
||||
comm.page.expandedDescription
|
||||
<>
|
||||
{comm.page.expandedDescriptionBefore}
|
||||
<InlineTextButton onClick={handleOpenAddWizard}>
|
||||
{comm.page.compactDescriptionLinkLabel}
|
||||
</InlineTextButton>
|
||||
{comm.page.expandedDescriptionAfter}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{comm.page.compactDescriptionBefore}
|
||||
<InlineTextButton
|
||||
onClick={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
<InlineTextButton onClick={handleOpenAddWizard}>
|
||||
{comm.page.compactDescriptionLinkLabel}
|
||||
</InlineTextButton>
|
||||
{comm.page.compactDescriptionAfter}
|
||||
</>
|
||||
);
|
||||
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
return {
|
||||
title: method?.label ?? comm.confirmModal.title,
|
||||
description: method?.supportText ?? comm.confirmModal.description,
|
||||
nextButtonText: comm.addPlatform.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: comm.confirmModal.title,
|
||||
description: comm.confirmModal.description,
|
||||
nextButtonText: comm.confirmModal.nextButtonText,
|
||||
};
|
||||
|
||||
const seedDraft = useCallback(
|
||||
(id: string): CommunicationMethodDetailEntry => {
|
||||
const saved = state.communicationMethodDetailsById?.[id];
|
||||
@@ -125,6 +141,10 @@ export function CommunicationMethodsScreen() {
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
setPendingCardId(id);
|
||||
setPendingDraft(seedDraft(id));
|
||||
setCreateModalOpen(true);
|
||||
@@ -140,39 +160,571 @@ export function CommunicationMethodsScreen() {
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const isSelectedCardModal =
|
||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||
const fieldsLocked = !modalEditUnlocked;
|
||||
|
||||
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
|
||||
|
||||
const customFacetDetailsMatchPreset = useMemo(() => {
|
||||
if (!pendingCardId || !pendingDraft) return false;
|
||||
if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) {
|
||||
return false;
|
||||
}
|
||||
return communicationMethodFacetMatchesPreset(pendingDraft, pendingCardId);
|
||||
}, [
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const modalUsesWizardFieldBlocksBody = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
pendingCardId &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
}),
|
||||
),
|
||||
[
|
||||
customFacetDetailsMatchPreset,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
const ephemeralId = pendingEphemeralDuplicateIdRef.current;
|
||||
if (ephemeralId) {
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
replaceState((prev) => ({
|
||||
...prev,
|
||||
customMethodCardMetaById: omitIdFromStringRecord(
|
||||
prev.customMethodCardMetaById,
|
||||
ephemeralId,
|
||||
),
|
||||
communicationMethodDetailsById: omitIdFromStringRecord(
|
||||
prev.communicationMethodDetailsById,
|
||||
ephemeralId,
|
||||
),
|
||||
customMethodCardFieldBlocksById: omitIdFromStringRecord(
|
||||
prev.customMethodCardFieldBlocksById,
|
||||
ephemeralId,
|
||||
),
|
||||
}));
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
setPendingDraft(null);
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
replaceState,
|
||||
]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
if (!modalEditUnlocked) {
|
||||
return;
|
||||
}
|
||||
const snap = customizeSnapshotRef.current;
|
||||
if (!snap) {
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
snap,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPendingDraft(structuredClone(snap.pendingDraft));
|
||||
setDraftFieldBlocks(null);
|
||||
setModalEditUnlocked(false);
|
||||
customizeSnapshotRef.current = null;
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
]);
|
||||
|
||||
const handleRemoveSelectedFromModal = useCallback(() => {
|
||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
updateState(
|
||||
removeMethodCardFromFacetSelection(
|
||||
state,
|
||||
"communication",
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
pendingCardId,
|
||||
selectedIds,
|
||||
state,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleCustomize = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (!pendingDraft || !pendingCardId) {
|
||||
return;
|
||||
}
|
||||
const persistedBlocks =
|
||||
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [];
|
||||
const initialFieldBlocks =
|
||||
persistedBlocks.length > 0
|
||||
? structuredClone(persistedBlocks)
|
||||
: isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? []
|
||||
: null;
|
||||
const method = methodById.get(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const headerDraft: MethodCardHeaderDraft = {
|
||||
title: meta?.label ?? method?.label ?? comm.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
comm.confirmModal.description,
|
||||
};
|
||||
setCustomizeHeaderDraft(headerDraft);
|
||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||
pendingDraft,
|
||||
initialFieldBlocks,
|
||||
headerDraft,
|
||||
);
|
||||
setDraftFieldBlocks(initialFieldBlocks);
|
||||
setModalEditUnlocked(true);
|
||||
}, [
|
||||
comm.confirmModal.description,
|
||||
comm.confirmModal.title,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const handleDuplicateCustomCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const meta = state.customMethodCardMetaById![pendingCardId]!;
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.communicationMethodDetailsById?.[pendingCardId],
|
||||
() => communicationPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.communicationMethodDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(meta.label, suffix),
|
||||
supportText: meta.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
communicationMethodDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
markCreateFlowInteraction,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
state.communicationMethodDetailsById,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleDuplicatePrefabCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const method = methodById.get(pendingCardId);
|
||||
if (!method || !pendingDraft) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.communicationMethodDetailsById?.[pendingCardId],
|
||||
() => communicationPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.communicationMethodDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(method.label, suffix),
|
||||
supportText: method.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
communicationMethodDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
draftFieldBlocks,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.communicationMethodDetailsById,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const kebabMenuItems = useMemo(
|
||||
() =>
|
||||
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||
showCustomize: !modalEditUnlocked,
|
||||
onCustomize: handleCustomize,
|
||||
onDuplicate:
|
||||
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
||||
? undefined
|
||||
: isCustomMethodCardId(
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
)
|
||||
? handleDuplicateCustomCard
|
||||
: handleDuplicatePrefabCard,
|
||||
showRemove: isSelectedCardModal,
|
||||
onRemove: handleRemoveSelectedFromModal,
|
||||
}),
|
||||
[
|
||||
handleCustomize,
|
||||
handleDuplicateCustomCard,
|
||||
handleDuplicatePrefabCard,
|
||||
handleRemoveSelectedFromModal,
|
||||
isSelectedCardModal,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu,
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
state.editingPublishedRuleId,
|
||||
],
|
||||
);
|
||||
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const saveLabel = modalKebabMenu.saveEdits;
|
||||
return {
|
||||
title: meta?.label ?? method?.label ?? comm.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
comm.confirmModal.description,
|
||||
nextButtonText: modalEditUnlocked
|
||||
? saveLabel
|
||||
: comm.addPlatform.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: comm.confirmModal.title,
|
||||
description: comm.confirmModal.description,
|
||||
nextButtonText: comm.confirmModal.nextButtonText,
|
||||
};
|
||||
|
||||
const handleCloseAddWizard = useCallback(() => {
|
||||
setAddCustomWizardOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleCreateModalConfirm = useCallback(() => {
|
||||
if (!pendingCardId || !pendingDraft) {
|
||||
const handleFinalizeCustomCard = useCallback(
|
||||
({
|
||||
title,
|
||||
description,
|
||||
fieldBlocks,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||
}) => {
|
||||
markCreateFlowInteraction();
|
||||
const id = crypto.randomUUID();
|
||||
updateState({
|
||||
selectedCommunicationMethodIds: moveFacetSelectionIdToFront(
|
||||
selectedIds,
|
||||
id,
|
||||
),
|
||||
customMethodCardMetaById: {
|
||||
...(state.customMethodCardMetaById ?? {}),
|
||||
[id]: { label: title, supportText: description },
|
||||
},
|
||||
communicationMethodDetailsById: {
|
||||
...(state.communicationMethodDetailsById ?? {}),
|
||||
[id]: communicationPresetFor(id),
|
||||
},
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[id]: fieldBlocks,
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
markCreateFlowInteraction,
|
||||
selectedIds,
|
||||
state.communicationMethodDetailsById,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
updateState,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalPrimary = useCallback(() => {
|
||||
if (!pendingCardId) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
|
||||
if (selectedIds.includes(pendingCardId)) {
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
communicationMethodDetailsById: {
|
||||
...(state.communicationMethodDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
communicationMethodDetailsById: {
|
||||
...(state.communicationMethodDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingDraft) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
}
|
||||
updateState({
|
||||
selectedCommunicationMethodIds: selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
selectedCommunicationMethodIds: moveFacetSelectionIdToFront(
|
||||
selectedIds,
|
||||
pendingCardId,
|
||||
),
|
||||
communicationMethodDetailsById: {
|
||||
...(state.communicationMethodDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
selectedIds,
|
||||
state.communicationMethodDetailsById,
|
||||
state,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateFlowStepShell
|
||||
variant="wideGridLoosePadding"
|
||||
contentTopBelowMd="space-800"
|
||||
@@ -208,21 +760,75 @@ export function CommunicationMethodsScreen() {
|
||||
<Create
|
||||
isOpen={createModalOpen}
|
||||
onClose={handleCreateModalClose}
|
||||
onNext={handleCreateModalConfirm}
|
||||
headerContent={
|
||||
modalEditUnlocked && customizeHeaderDraft ? (
|
||||
<MethodCardCustomizeModalHeader
|
||||
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
|
||||
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
|
||||
titleValue={customizeHeaderDraft.title}
|
||||
descriptionValue={customizeHeaderDraft.description}
|
||||
onTitleChange={(title) =>
|
||||
setCustomizeHeaderDraft((prev) =>
|
||||
prev ? { ...prev, title } : null,
|
||||
)
|
||||
}
|
||||
onDescriptionChange={(description) =>
|
||||
setCustomizeHeaderDraft((prev) =>
|
||||
prev ? { ...prev, description } : null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onNext={handleCreateModalPrimary}
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={false}
|
||||
showBackButton={modalEditUnlocked}
|
||||
onBack={handleCancelCustomize}
|
||||
backButtonText={modalKebabMenu.cancelCustomize}
|
||||
showNextButton={showMethodModalPrimary}
|
||||
backdropVariant="blurredYellow"
|
||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||
kebabMenuItems={kebabMenuItems}
|
||||
>
|
||||
{pendingCardId && pendingDraft ? (
|
||||
<CommunicationMethodEditFields
|
||||
key={pendingCardId}
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
/>
|
||||
modalUsesWizardFieldBlocksBody ? (
|
||||
<CustomMethodCardModalBody
|
||||
cardId={pendingCardId}
|
||||
blocksById={state.customMethodCardFieldBlocksById}
|
||||
blocksOverride={
|
||||
modalEditUnlocked && draftFieldBlocks !== null
|
||||
? draftFieldBlocks
|
||||
: undefined
|
||||
}
|
||||
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
||||
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
|
||||
onFieldBlocksChange={
|
||||
fieldsLocked
|
||||
? undefined
|
||||
: (next) => setDraftFieldBlocks(next)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<CommunicationMethodEditFields
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
readOnly={fieldsLocked}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</Create>
|
||||
</CreateFlowStepShell>
|
||||
<CustomMethodCardWizard
|
||||
isOpen={addCustomWizardOpen}
|
||||
onClose={handleCloseAddWizard}
|
||||
onFinalize={handleFinalizeCustomCard}
|
||||
onPersistCustomUploadFile={(file) =>
|
||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,21 +6,17 @@
|
||||
*
|
||||
* Card click opens the Figma "Add Approach" create modal (node `20874-172292`)
|
||||
* with four controls rendered by {@link ConflictManagementEditFields}: Core
|
||||
* Principle, Applicable Scope (capsules), Process Protocol, and Restoration
|
||||
* Principle, Applicable Scope (text area), Process Protocol, and Restoration
|
||||
* & Fallbacks. The same field set is reused on `/create/final-review` — see
|
||||
* `FinalReviewChipEditModal`. Confirm persists both the chip selection and
|
||||
* any user edits as a `conflictManagementDetailsById[id]` override.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import {
|
||||
deriveCompactCards,
|
||||
rankMethodsByScore,
|
||||
useFacetRecommendations,
|
||||
} from "../../hooks/useFacetRecommendations";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../../../components/cards/CardStack";
|
||||
import Create from "../../../../components/modals/Create";
|
||||
@@ -31,84 +27,103 @@ import {
|
||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
import { ConflictManagementEditFields } from "../../components/methodEditFields";
|
||||
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||
import { conflictManagementPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
||||
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
|
||||
import { conflictManagementFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId";
|
||||
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
|
||||
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
|
||||
import {
|
||||
cloneMethodCardBlocksForDuplicate,
|
||||
cloneMethodCardDetailsForDuplicate,
|
||||
duplicateMethodCardTitle,
|
||||
forkMethodCardFacetMapsForDuplicate,
|
||||
omitIdFromStringRecord,
|
||||
} from "../../../../../lib/create/duplicateMethodCardModalDraft";
|
||||
import type { ConflictManagementDetailEntry } from "../../types";
|
||||
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
||||
|
||||
export function ConflictManagementScreen() {
|
||||
const m = useMessages();
|
||||
const cm = m.create.customRule.conflictManagement;
|
||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||
useCreateFlow();
|
||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||
const customizeSnapshotRef = useRef<
|
||||
MethodCardCustomizeSnapshot<ConflictManagementDetailEntry> | null
|
||||
>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
const [pendingDraft, setPendingDraft] =
|
||||
useState<ConflictManagementDetailEntry | null>(null);
|
||||
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
||||
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||
CustomMethodCardFieldBlock[] | null
|
||||
>(null);
|
||||
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
||||
useState<MethodCardHeaderDraft | null>(null);
|
||||
|
||||
const selectedIds = state.selectedConflictManagementIds ?? [];
|
||||
|
||||
const { scoresBySlug, hasAnyFacets } =
|
||||
useFacetRecommendations("conflictManagement");
|
||||
const rankedMethods = useMemo(
|
||||
() => rankMethodsByScore(cm.methods, scoresBySlug),
|
||||
[cm.methods, scoresBySlug],
|
||||
);
|
||||
|
||||
const { compactCardIds, recommendedIds } = useMemo(
|
||||
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
|
||||
[rankedMethods, scoresBySlug, hasAnyFacets],
|
||||
);
|
||||
|
||||
const sampleCards = useMemo(
|
||||
const mergedMethods = useMemo(
|
||||
() =>
|
||||
rankedMethods.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
supportText: entry.supportText,
|
||||
recommended: recommendedIds.has(entry.id),
|
||||
})),
|
||||
[rankedMethods, recommendedIds],
|
||||
mergePresetMethodsWithCustom(
|
||||
cm.methods,
|
||||
selectedIds,
|
||||
state.customMethodCardMetaById,
|
||||
),
|
||||
[cm.methods, selectedIds, state.customMethodCardMetaById],
|
||||
);
|
||||
|
||||
const methodById = useMemo(
|
||||
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
|
||||
[rankedMethods],
|
||||
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
||||
"conflictManagement",
|
||||
mergedMethods,
|
||||
selectedIds,
|
||||
);
|
||||
|
||||
const handleOpenAddWizard = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
setAddCustomWizardOpen(true);
|
||||
}, [markCreateFlowInteraction]);
|
||||
|
||||
const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
|
||||
|
||||
const description = expanded ? (
|
||||
cm.page.expandedDescription
|
||||
<>
|
||||
{cm.page.expandedDescriptionBefore}
|
||||
<InlineTextButton onClick={handleOpenAddWizard}>
|
||||
{cm.page.compactDescriptionLinkLabel}
|
||||
</InlineTextButton>
|
||||
{cm.page.expandedDescriptionAfter}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{cm.page.compactDescriptionBefore}
|
||||
<InlineTextButton
|
||||
onClick={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
<InlineTextButton onClick={handleOpenAddWizard}>
|
||||
{cm.page.compactDescriptionLinkLabel}
|
||||
</InlineTextButton>
|
||||
{cm.page.compactDescriptionAfter}
|
||||
</>
|
||||
);
|
||||
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
return {
|
||||
title: method?.label ?? cm.confirmModal.title,
|
||||
description: method?.supportText ?? cm.confirmModal.description,
|
||||
nextButtonText: cm.addApproach.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: cm.confirmModal.title,
|
||||
description: cm.confirmModal.description,
|
||||
nextButtonText: cm.confirmModal.nextButtonText,
|
||||
};
|
||||
|
||||
const seedDraft = useCallback(
|
||||
(id: string): ConflictManagementDetailEntry => {
|
||||
const saved = state.conflictManagementDetailsById?.[id];
|
||||
@@ -127,6 +142,10 @@ export function ConflictManagementScreen() {
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
setPendingCardId(id);
|
||||
setPendingDraft(seedDraft(id));
|
||||
setCreateModalOpen(true);
|
||||
@@ -142,39 +161,569 @@ export function ConflictManagementScreen() {
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const isSelectedCardModal =
|
||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||
const fieldsLocked = !modalEditUnlocked;
|
||||
|
||||
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
|
||||
|
||||
const customFacetDetailsMatchPreset = useMemo(() => {
|
||||
if (!pendingCardId || !pendingDraft) return false;
|
||||
if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) {
|
||||
return false;
|
||||
}
|
||||
return conflictManagementFacetMatchesPreset(pendingDraft, pendingCardId);
|
||||
}, [
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const modalUsesWizardFieldBlocksBody = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
pendingCardId &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
}),
|
||||
),
|
||||
[
|
||||
customFacetDetailsMatchPreset,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
const ephemeralId = pendingEphemeralDuplicateIdRef.current;
|
||||
if (ephemeralId) {
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
replaceState((prev) => ({
|
||||
...prev,
|
||||
customMethodCardMetaById: omitIdFromStringRecord(
|
||||
prev.customMethodCardMetaById,
|
||||
ephemeralId,
|
||||
),
|
||||
conflictManagementDetailsById: omitIdFromStringRecord(
|
||||
prev.conflictManagementDetailsById,
|
||||
ephemeralId,
|
||||
),
|
||||
customMethodCardFieldBlocksById: omitIdFromStringRecord(
|
||||
prev.customMethodCardFieldBlocksById,
|
||||
ephemeralId,
|
||||
),
|
||||
}));
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
setPendingDraft(null);
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
replaceState,
|
||||
]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
if (!modalEditUnlocked) {
|
||||
return;
|
||||
}
|
||||
const snap = customizeSnapshotRef.current;
|
||||
if (!snap) {
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
snap,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPendingDraft(structuredClone(snap.pendingDraft));
|
||||
setDraftFieldBlocks(null);
|
||||
setModalEditUnlocked(false);
|
||||
customizeSnapshotRef.current = null;
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
]);
|
||||
|
||||
const handleRemoveSelectedFromModal = useCallback(() => {
|
||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
updateState(
|
||||
removeMethodCardFromFacetSelection(
|
||||
state,
|
||||
"conflictManagement",
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
pendingCardId,
|
||||
selectedIds,
|
||||
state,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleCustomize = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (!pendingDraft || !pendingCardId) {
|
||||
return;
|
||||
}
|
||||
const initialFieldBlocks =
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? structuredClone(
|
||||
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
|
||||
)
|
||||
: null;
|
||||
const method = methodById.get(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const headerDraft: MethodCardHeaderDraft = {
|
||||
title: meta?.label ?? method?.label ?? cm.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
cm.confirmModal.description,
|
||||
};
|
||||
setCustomizeHeaderDraft(headerDraft);
|
||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||
pendingDraft,
|
||||
initialFieldBlocks,
|
||||
headerDraft,
|
||||
);
|
||||
setDraftFieldBlocks(initialFieldBlocks);
|
||||
setModalEditUnlocked(true);
|
||||
}, [
|
||||
cm.confirmModal.description,
|
||||
cm.confirmModal.title,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const handleDuplicateCustomCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const meta = state.customMethodCardMetaById![pendingCardId]!;
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.conflictManagementDetailsById?.[pendingCardId],
|
||||
() => conflictManagementPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.conflictManagementDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(meta.label, suffix),
|
||||
supportText: meta.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
conflictManagementDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
draftFieldBlocks,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.conflictManagementDetailsById,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleDuplicatePrefabCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const method = methodById.get(pendingCardId);
|
||||
if (!method || !pendingDraft) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.conflictManagementDetailsById?.[pendingCardId],
|
||||
() => conflictManagementPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.conflictManagementDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(method.label, suffix),
|
||||
supportText: method.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
conflictManagementDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
draftFieldBlocks,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.conflictManagementDetailsById,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const kebabMenuItems = useMemo(
|
||||
() =>
|
||||
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||
showCustomize: !modalEditUnlocked,
|
||||
onCustomize: handleCustomize,
|
||||
onDuplicate:
|
||||
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
||||
? undefined
|
||||
: isCustomMethodCardId(
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
)
|
||||
? handleDuplicateCustomCard
|
||||
: handleDuplicatePrefabCard,
|
||||
showRemove: isSelectedCardModal,
|
||||
onRemove: handleRemoveSelectedFromModal,
|
||||
}),
|
||||
[
|
||||
handleCustomize,
|
||||
handleDuplicateCustomCard,
|
||||
handleDuplicatePrefabCard,
|
||||
handleRemoveSelectedFromModal,
|
||||
isSelectedCardModal,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu,
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
state.editingPublishedRuleId,
|
||||
],
|
||||
);
|
||||
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const saveLabel = modalKebabMenu.saveEdits;
|
||||
return {
|
||||
title: meta?.label ?? method?.label ?? cm.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
cm.confirmModal.description,
|
||||
nextButtonText: modalEditUnlocked
|
||||
? saveLabel
|
||||
: cm.addApproach.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: cm.confirmModal.title,
|
||||
description: cm.confirmModal.description,
|
||||
nextButtonText: cm.confirmModal.nextButtonText,
|
||||
};
|
||||
|
||||
const handleCloseAddWizard = useCallback(() => {
|
||||
setAddCustomWizardOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleCreateModalConfirm = useCallback(() => {
|
||||
if (!pendingCardId || !pendingDraft) {
|
||||
const handleFinalizeCustomCard = useCallback(
|
||||
({
|
||||
title,
|
||||
description,
|
||||
fieldBlocks,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||
}) => {
|
||||
markCreateFlowInteraction();
|
||||
const id = crypto.randomUUID();
|
||||
updateState({
|
||||
selectedConflictManagementIds: moveFacetSelectionIdToFront(
|
||||
selectedIds,
|
||||
id,
|
||||
),
|
||||
customMethodCardMetaById: {
|
||||
...(state.customMethodCardMetaById ?? {}),
|
||||
[id]: { label: title, supportText: description },
|
||||
},
|
||||
conflictManagementDetailsById: {
|
||||
...(state.conflictManagementDetailsById ?? {}),
|
||||
[id]: conflictManagementPresetFor(id),
|
||||
},
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[id]: fieldBlocks,
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
markCreateFlowInteraction,
|
||||
selectedIds,
|
||||
state.conflictManagementDetailsById,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
updateState,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalPrimary = useCallback(() => {
|
||||
if (!pendingCardId) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
|
||||
if (selectedIds.includes(pendingCardId)) {
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
conflictManagementDetailsById: {
|
||||
...(state.conflictManagementDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
conflictManagementDetailsById: {
|
||||
...(state.conflictManagementDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingDraft) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
}
|
||||
updateState({
|
||||
selectedConflictManagementIds: selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
selectedConflictManagementIds: moveFacetSelectionIdToFront(
|
||||
selectedIds,
|
||||
pendingCardId,
|
||||
),
|
||||
conflictManagementDetailsById: {
|
||||
...(state.conflictManagementDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
selectedIds,
|
||||
state.conflictManagementDetailsById,
|
||||
state,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateFlowStepShell
|
||||
variant="wideGridLoosePadding"
|
||||
contentTopBelowMd="space-800"
|
||||
@@ -210,21 +759,75 @@ export function ConflictManagementScreen() {
|
||||
<Create
|
||||
isOpen={createModalOpen}
|
||||
onClose={handleCreateModalClose}
|
||||
onNext={handleCreateModalConfirm}
|
||||
headerContent={
|
||||
modalEditUnlocked && customizeHeaderDraft ? (
|
||||
<MethodCardCustomizeModalHeader
|
||||
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
|
||||
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
|
||||
titleValue={customizeHeaderDraft.title}
|
||||
descriptionValue={customizeHeaderDraft.description}
|
||||
onTitleChange={(title) =>
|
||||
setCustomizeHeaderDraft((prev) =>
|
||||
prev ? { ...prev, title } : null,
|
||||
)
|
||||
}
|
||||
onDescriptionChange={(description) =>
|
||||
setCustomizeHeaderDraft((prev) =>
|
||||
prev ? { ...prev, description } : null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onNext={handleCreateModalPrimary}
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={false}
|
||||
showBackButton={modalEditUnlocked}
|
||||
onBack={handleCancelCustomize}
|
||||
backButtonText={modalKebabMenu.cancelCustomize}
|
||||
showNextButton={showMethodModalPrimary}
|
||||
backdropVariant="blurredYellow"
|
||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||
kebabMenuItems={kebabMenuItems}
|
||||
>
|
||||
{pendingCardId && pendingDraft ? (
|
||||
<ConflictManagementEditFields
|
||||
key={pendingCardId}
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
/>
|
||||
modalUsesWizardFieldBlocksBody ? (
|
||||
<CustomMethodCardModalBody
|
||||
cardId={pendingCardId}
|
||||
blocksById={state.customMethodCardFieldBlocksById}
|
||||
blocksOverride={
|
||||
modalEditUnlocked && draftFieldBlocks !== null
|
||||
? draftFieldBlocks
|
||||
: undefined
|
||||
}
|
||||
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
||||
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
|
||||
onFieldBlocksChange={
|
||||
fieldsLocked
|
||||
? undefined
|
||||
: (next) => setDraftFieldBlocks(next)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ConflictManagementEditFields
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
readOnly={fieldsLocked}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</Create>
|
||||
</CreateFlowStepShell>
|
||||
<CustomMethodCardWizard
|
||||
isOpen={addCustomWizardOpen}
|
||||
onClose={handleCloseAddWizard}
|
||||
onFinalize={handleFinalizeCustomCard}
|
||||
onPersistCustomUploadFile={(file) =>
|
||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,15 +13,11 @@
|
||||
* DB-driven content.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import {
|
||||
deriveCompactCards,
|
||||
rankMethodsByScore,
|
||||
useFacetRecommendations,
|
||||
} from "../../hooks/useFacetRecommendations";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../../../components/cards/CardStack";
|
||||
import Create from "../../../../components/modals/Create";
|
||||
@@ -32,84 +28,103 @@ import {
|
||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
import { MembershipMethodEditFields } from "../../components/methodEditFields";
|
||||
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||
import { membershipPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
||||
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
|
||||
import { membershipMethodFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId";
|
||||
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
|
||||
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
|
||||
import {
|
||||
cloneMethodCardBlocksForDuplicate,
|
||||
cloneMethodCardDetailsForDuplicate,
|
||||
duplicateMethodCardTitle,
|
||||
forkMethodCardFacetMapsForDuplicate,
|
||||
omitIdFromStringRecord,
|
||||
} from "../../../../../lib/create/duplicateMethodCardModalDraft";
|
||||
import type { MembershipMethodDetailEntry } from "../../types";
|
||||
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
||||
|
||||
export function MembershipMethodsScreen() {
|
||||
const m = useMessages();
|
||||
const mem = m.create.customRule.membership;
|
||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||
useCreateFlow();
|
||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||
const customizeSnapshotRef = useRef<
|
||||
MethodCardCustomizeSnapshot<MembershipMethodDetailEntry> | null
|
||||
>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
const [pendingDraft, setPendingDraft] =
|
||||
useState<MembershipMethodDetailEntry | null>(null);
|
||||
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
||||
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||
CustomMethodCardFieldBlock[] | null
|
||||
>(null);
|
||||
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
||||
useState<MethodCardHeaderDraft | null>(null);
|
||||
|
||||
const selectedIds = state.selectedMembershipMethodIds ?? [];
|
||||
|
||||
const { scoresBySlug, hasAnyFacets } =
|
||||
useFacetRecommendations("membership");
|
||||
const rankedMethods = useMemo(
|
||||
() => rankMethodsByScore(mem.methods, scoresBySlug),
|
||||
[mem.methods, scoresBySlug],
|
||||
);
|
||||
|
||||
const { compactCardIds, recommendedIds } = useMemo(
|
||||
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
|
||||
[rankedMethods, scoresBySlug, hasAnyFacets],
|
||||
);
|
||||
|
||||
const sampleCards = useMemo(
|
||||
const mergedMethods = useMemo(
|
||||
() =>
|
||||
rankedMethods.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
supportText: entry.supportText,
|
||||
recommended: recommendedIds.has(entry.id),
|
||||
})),
|
||||
[rankedMethods, recommendedIds],
|
||||
mergePresetMethodsWithCustom(
|
||||
mem.methods,
|
||||
selectedIds,
|
||||
state.customMethodCardMetaById,
|
||||
),
|
||||
[mem.methods, selectedIds, state.customMethodCardMetaById],
|
||||
);
|
||||
|
||||
const methodById = useMemo(
|
||||
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
|
||||
[rankedMethods],
|
||||
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
||||
"membership",
|
||||
mergedMethods,
|
||||
selectedIds,
|
||||
);
|
||||
|
||||
const handleOpenAddWizard = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
setAddCustomWizardOpen(true);
|
||||
}, [markCreateFlowInteraction]);
|
||||
|
||||
const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
|
||||
|
||||
const description = expanded ? (
|
||||
mem.page.expandedDescription
|
||||
<>
|
||||
{mem.page.expandedDescriptionBefore}
|
||||
<InlineTextButton onClick={handleOpenAddWizard}>
|
||||
{mem.page.compactDescriptionLinkLabel}
|
||||
</InlineTextButton>
|
||||
{mem.page.expandedDescriptionAfter}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{mem.page.compactDescriptionBefore}
|
||||
<InlineTextButton
|
||||
onClick={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
<InlineTextButton onClick={handleOpenAddWizard}>
|
||||
{mem.page.compactDescriptionLinkLabel}
|
||||
</InlineTextButton>
|
||||
{mem.page.compactDescriptionAfter}
|
||||
</>
|
||||
);
|
||||
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
return {
|
||||
title: method?.label ?? mem.confirmModal.title,
|
||||
description: method?.supportText ?? mem.confirmModal.description,
|
||||
nextButtonText: mem.addPlatform.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: mem.confirmModal.title,
|
||||
description: mem.confirmModal.description,
|
||||
nextButtonText: mem.confirmModal.nextButtonText,
|
||||
};
|
||||
|
||||
const seedDraft = useCallback(
|
||||
(id: string): MembershipMethodDetailEntry => {
|
||||
const saved = state.membershipMethodDetailsById?.[id];
|
||||
@@ -124,6 +139,10 @@ export function MembershipMethodsScreen() {
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
setPendingCardId(id);
|
||||
setPendingDraft(seedDraft(id));
|
||||
setCreateModalOpen(true);
|
||||
@@ -139,39 +158,565 @@ export function MembershipMethodsScreen() {
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const isSelectedCardModal =
|
||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||
const fieldsLocked = !modalEditUnlocked;
|
||||
|
||||
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
|
||||
|
||||
const customFacetDetailsMatchPreset = useMemo(() => {
|
||||
if (!pendingCardId || !pendingDraft) return false;
|
||||
if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) {
|
||||
return false;
|
||||
}
|
||||
return membershipMethodFacetMatchesPreset(pendingDraft, pendingCardId);
|
||||
}, [
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const modalUsesWizardFieldBlocksBody = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
pendingCardId &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
}),
|
||||
),
|
||||
[
|
||||
customFacetDetailsMatchPreset,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
const ephemeralId = pendingEphemeralDuplicateIdRef.current;
|
||||
if (ephemeralId) {
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
replaceState((prev) => ({
|
||||
...prev,
|
||||
customMethodCardMetaById: omitIdFromStringRecord(
|
||||
prev.customMethodCardMetaById,
|
||||
ephemeralId,
|
||||
),
|
||||
membershipMethodDetailsById: omitIdFromStringRecord(
|
||||
prev.membershipMethodDetailsById,
|
||||
ephemeralId,
|
||||
),
|
||||
customMethodCardFieldBlocksById: omitIdFromStringRecord(
|
||||
prev.customMethodCardFieldBlocksById,
|
||||
ephemeralId,
|
||||
),
|
||||
}));
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
setPendingDraft(null);
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
replaceState,
|
||||
]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
if (!modalEditUnlocked) {
|
||||
return;
|
||||
}
|
||||
const snap = customizeSnapshotRef.current;
|
||||
if (!snap) {
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
snap,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPendingDraft(structuredClone(snap.pendingDraft));
|
||||
setDraftFieldBlocks(null);
|
||||
setModalEditUnlocked(false);
|
||||
customizeSnapshotRef.current = null;
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
]);
|
||||
|
||||
const handleRemoveSelectedFromModal = useCallback(() => {
|
||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
updateState(
|
||||
removeMethodCardFromFacetSelection(state, "membership", pendingCardId),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
pendingCardId,
|
||||
selectedIds,
|
||||
state,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleCustomize = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (!pendingDraft || !pendingCardId) {
|
||||
return;
|
||||
}
|
||||
const initialFieldBlocks =
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? structuredClone(
|
||||
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
|
||||
)
|
||||
: null;
|
||||
const method = methodById.get(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const headerDraft: MethodCardHeaderDraft = {
|
||||
title: meta?.label ?? method?.label ?? mem.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
mem.confirmModal.description,
|
||||
};
|
||||
setCustomizeHeaderDraft(headerDraft);
|
||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||
pendingDraft,
|
||||
initialFieldBlocks,
|
||||
headerDraft,
|
||||
);
|
||||
setDraftFieldBlocks(initialFieldBlocks);
|
||||
setModalEditUnlocked(true);
|
||||
}, [
|
||||
mem.confirmModal.description,
|
||||
mem.confirmModal.title,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const handleDuplicateCustomCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const meta = state.customMethodCardMetaById![pendingCardId]!;
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.membershipMethodDetailsById?.[pendingCardId],
|
||||
() => membershipPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.membershipMethodDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(meta.label, suffix),
|
||||
supportText: meta.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
membershipMethodDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
draftFieldBlocks,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
state.membershipMethodDetailsById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleDuplicatePrefabCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const method = methodById.get(pendingCardId);
|
||||
if (!method || !pendingDraft) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.membershipMethodDetailsById?.[pendingCardId],
|
||||
() => membershipPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.membershipMethodDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(method.label, suffix),
|
||||
supportText: method.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
membershipMethodDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
draftFieldBlocks,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
state.membershipMethodDetailsById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const kebabMenuItems = useMemo(
|
||||
() =>
|
||||
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||
showCustomize: !modalEditUnlocked,
|
||||
onCustomize: handleCustomize,
|
||||
onDuplicate:
|
||||
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
||||
? undefined
|
||||
: isCustomMethodCardId(
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
)
|
||||
? handleDuplicateCustomCard
|
||||
: handleDuplicatePrefabCard,
|
||||
showRemove: isSelectedCardModal,
|
||||
onRemove: handleRemoveSelectedFromModal,
|
||||
}),
|
||||
[
|
||||
handleCustomize,
|
||||
handleDuplicateCustomCard,
|
||||
handleDuplicatePrefabCard,
|
||||
handleRemoveSelectedFromModal,
|
||||
isSelectedCardModal,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu,
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
state.editingPublishedRuleId,
|
||||
],
|
||||
);
|
||||
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const saveLabel = modalKebabMenu.saveEdits;
|
||||
return {
|
||||
title: meta?.label ?? method?.label ?? mem.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
mem.confirmModal.description,
|
||||
nextButtonText: modalEditUnlocked
|
||||
? saveLabel
|
||||
: mem.addPlatform.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: mem.confirmModal.title,
|
||||
description: mem.confirmModal.description,
|
||||
nextButtonText: mem.confirmModal.nextButtonText,
|
||||
};
|
||||
|
||||
const handleCloseAddWizard = useCallback(() => {
|
||||
setAddCustomWizardOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleCreateModalConfirm = useCallback(() => {
|
||||
if (!pendingCardId || !pendingDraft) {
|
||||
const handleFinalizeCustomCard = useCallback(
|
||||
({
|
||||
title,
|
||||
description,
|
||||
fieldBlocks,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||
}) => {
|
||||
markCreateFlowInteraction();
|
||||
const id = crypto.randomUUID();
|
||||
updateState({
|
||||
selectedMembershipMethodIds: moveFacetSelectionIdToFront(
|
||||
selectedIds,
|
||||
id,
|
||||
),
|
||||
customMethodCardMetaById: {
|
||||
...(state.customMethodCardMetaById ?? {}),
|
||||
[id]: { label: title, supportText: description },
|
||||
},
|
||||
membershipMethodDetailsById: {
|
||||
...(state.membershipMethodDetailsById ?? {}),
|
||||
[id]: membershipPresetFor(id),
|
||||
},
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[id]: fieldBlocks,
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
markCreateFlowInteraction,
|
||||
selectedIds,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
state.membershipMethodDetailsById,
|
||||
updateState,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalPrimary = useCallback(() => {
|
||||
if (!pendingCardId) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
|
||||
if (selectedIds.includes(pendingCardId)) {
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
membershipMethodDetailsById: {
|
||||
...(state.membershipMethodDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
membershipMethodDetailsById: {
|
||||
...(state.membershipMethodDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingDraft) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
}
|
||||
updateState({
|
||||
selectedMembershipMethodIds: selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
selectedMembershipMethodIds: moveFacetSelectionIdToFront(
|
||||
selectedIds,
|
||||
pendingCardId,
|
||||
),
|
||||
membershipMethodDetailsById: {
|
||||
...(state.membershipMethodDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
selectedIds,
|
||||
state.membershipMethodDetailsById,
|
||||
state,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateFlowStepShell
|
||||
variant="wideGridLoosePadding"
|
||||
contentTopBelowMd="space-800"
|
||||
@@ -207,21 +752,75 @@ export function MembershipMethodsScreen() {
|
||||
<Create
|
||||
isOpen={createModalOpen}
|
||||
onClose={handleCreateModalClose}
|
||||
onNext={handleCreateModalConfirm}
|
||||
headerContent={
|
||||
modalEditUnlocked && customizeHeaderDraft ? (
|
||||
<MethodCardCustomizeModalHeader
|
||||
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
|
||||
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
|
||||
titleValue={customizeHeaderDraft.title}
|
||||
descriptionValue={customizeHeaderDraft.description}
|
||||
onTitleChange={(title) =>
|
||||
setCustomizeHeaderDraft((prev) =>
|
||||
prev ? { ...prev, title } : null,
|
||||
)
|
||||
}
|
||||
onDescriptionChange={(description) =>
|
||||
setCustomizeHeaderDraft((prev) =>
|
||||
prev ? { ...prev, description } : null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onNext={handleCreateModalPrimary}
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={false}
|
||||
showBackButton={modalEditUnlocked}
|
||||
onBack={handleCancelCustomize}
|
||||
backButtonText={modalKebabMenu.cancelCustomize}
|
||||
showNextButton={showMethodModalPrimary}
|
||||
backdropVariant="blurredYellow"
|
||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||
kebabMenuItems={kebabMenuItems}
|
||||
>
|
||||
{pendingCardId && pendingDraft ? (
|
||||
<MembershipMethodEditFields
|
||||
key={pendingCardId}
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
/>
|
||||
modalUsesWizardFieldBlocksBody ? (
|
||||
<CustomMethodCardModalBody
|
||||
cardId={pendingCardId}
|
||||
blocksById={state.customMethodCardFieldBlocksById}
|
||||
blocksOverride={
|
||||
modalEditUnlocked && draftFieldBlocks !== null
|
||||
? draftFieldBlocks
|
||||
: undefined
|
||||
}
|
||||
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
||||
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
|
||||
onFieldBlocksChange={
|
||||
fieldsLocked
|
||||
? undefined
|
||||
: (next) => setDraftFieldBlocks(next)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MembershipMethodEditFields
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
readOnly={fieldsLocked}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</Create>
|
||||
</CreateFlowStepShell>
|
||||
<CustomMethodCardWizard
|
||||
isOpen={addCustomWizardOpen}
|
||||
onClose={handleCloseAddWizard}
|
||||
onFinalize={handleFinalizeCustomCard}
|
||||
onPersistCustomUploadFile={(file) =>
|
||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { CommunityRuleSection } from "../../../../components/type/Community
|
||||
import Alert from "../../../../components/modals/Alert";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { fetchPublishedRuleDetail } from "../../../../../lib/create/api";
|
||||
import { parseDocumentSectionsForDisplay } from "../../../../../lib/create/buildPublishPayload";
|
||||
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../../lib/create/publishedDocumentToDisplaySections";
|
||||
import {
|
||||
readLastPublishedRule,
|
||||
writeLastPublishedRule,
|
||||
@@ -18,6 +18,22 @@ import {
|
||||
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
||||
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
import {
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||
} from "../../utils/flowSteps";
|
||||
|
||||
function emptyCompletedDocumentState(): {
|
||||
headerTitle: string;
|
||||
headerDescription: string | undefined;
|
||||
documentSections: CommunityRuleSection[];
|
||||
} {
|
||||
return {
|
||||
headerTitle: "",
|
||||
headerDescription: undefined,
|
||||
documentSections: [],
|
||||
};
|
||||
}
|
||||
|
||||
function initialCompletedUi(
|
||||
ruleIdFromUrl: string | null,
|
||||
@@ -27,28 +43,16 @@ function initialCompletedUi(
|
||||
documentSections: CommunityRuleSection[];
|
||||
} {
|
||||
if (ruleIdFromUrl) {
|
||||
return {
|
||||
headerTitle: "",
|
||||
headerDescription: undefined,
|
||||
documentSections: [],
|
||||
};
|
||||
return emptyCompletedDocumentState();
|
||||
}
|
||||
if (typeof sessionStorage === "undefined") {
|
||||
return {
|
||||
headerTitle: "",
|
||||
headerDescription: undefined,
|
||||
documentSections: [],
|
||||
};
|
||||
return emptyCompletedDocumentState();
|
||||
}
|
||||
const stored = readLastPublishedRule();
|
||||
if (!stored) {
|
||||
return {
|
||||
headerTitle: "",
|
||||
headerDescription: undefined,
|
||||
documentSections: [],
|
||||
};
|
||||
return emptyCompletedDocumentState();
|
||||
}
|
||||
const parsed = parseDocumentSectionsForDisplay(stored.document);
|
||||
const parsed = parsePublishedDocumentForCommunityRuleDisplay(stored.document);
|
||||
if (parsed.length === 0) {
|
||||
return {
|
||||
headerTitle: "",
|
||||
@@ -69,12 +73,17 @@ export function CompletedScreen() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const ruleIdParam = searchParams.get("ruleId");
|
||||
const celebrateInUrl =
|
||||
searchParams.get(CREATE_FLOW_COMPLETED_CELEBRATE_QUERY) ===
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const m = useMessages();
|
||||
const completed = m.create.reviewAndComplete.completed;
|
||||
|
||||
const initial = initialCompletedUi(ruleIdParam);
|
||||
const [toastDismissed, setToastDismissed] = useState(false);
|
||||
/** Latch: toast copy should survive `router.replace` after we strip `?celebrate=1`. */
|
||||
const [showCelebrateToast] = useState(celebrateInUrl);
|
||||
const [headerTitle, setHeaderTitle] = useState(initial.headerTitle);
|
||||
const [headerDescription, setHeaderDescription] = useState<
|
||||
string | undefined
|
||||
@@ -105,7 +114,7 @@ export function CompletedScreen() {
|
||||
summary: detail.rule.summary,
|
||||
document: doc,
|
||||
});
|
||||
const parsed = parseDocumentSectionsForDisplay(doc);
|
||||
const parsed = parsePublishedDocumentForCommunityRuleDisplay(doc);
|
||||
if (parsed.length === 0) {
|
||||
router.replace(`/rules/${encodeURIComponent(ruleIdParam)}`);
|
||||
return;
|
||||
@@ -126,7 +135,12 @@ export function CompletedScreen() {
|
||||
};
|
||||
}, [ruleIdParam, router]);
|
||||
|
||||
const toast = !toastDismissed ? (
|
||||
useEffect(() => {
|
||||
if (!celebrateInUrl) return;
|
||||
router.replace("/create/completed");
|
||||
}, [celebrateInUrl, router]);
|
||||
|
||||
const toast = showCelebrateToast && !toastDismissed ? (
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 z-10 w-full"
|
||||
role="status"
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Step → screen component map (Linear CR-92 §3). Keeps {@link CreateFlowScreenView}
|
||||
* thin; pair with {@link CREATE_FLOW_SCREEN_REGISTRY} metadata in tests/docs so
|
||||
* new steps do not drift.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import { InformationalScreen } from "./informational/InformationalScreen";
|
||||
import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen";
|
||||
import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen";
|
||||
import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen";
|
||||
import { CoreValuesSelectScreen } from "./select/CoreValuesSelectScreen";
|
||||
import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
|
||||
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
|
||||
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
|
||||
import { FinalReviewScreen } from "./review/FinalReviewScreen";
|
||||
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen";
|
||||
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen";
|
||||
import { ConflictManagementScreen } from "./card/ConflictManagementScreen";
|
||||
import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen";
|
||||
import { CompletedScreen } from "./completed/CompletedScreen";
|
||||
|
||||
export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
|
||||
switch (screenId) {
|
||||
case "informational":
|
||||
return <InformationalScreen />;
|
||||
case "community-name":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.community.communityName"
|
||||
stateField="title"
|
||||
maxLength={48}
|
||||
/>
|
||||
);
|
||||
case "community-structure":
|
||||
return <CommunityStructureSelectScreen />;
|
||||
case "community-context":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.community.communityContext"
|
||||
stateField="communityContext"
|
||||
maxLength={200}
|
||||
mainAlign="center"
|
||||
/>
|
||||
);
|
||||
case "community-size":
|
||||
return <CommunitySizeSelectScreen />;
|
||||
case "community-upload":
|
||||
return <CommunityUploadScreen />;
|
||||
case "community-save":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.community.communitySave"
|
||||
stateField="communitySaveEmail"
|
||||
maxLength={254}
|
||||
mainAlign="center"
|
||||
inputType="email"
|
||||
showCharacterCount={false}
|
||||
headerJustification="center"
|
||||
/>
|
||||
);
|
||||
case "review":
|
||||
return <CommunityReviewScreen />;
|
||||
case "core-values":
|
||||
return <CoreValuesSelectScreen />;
|
||||
case "communication-methods":
|
||||
return <CommunicationMethodsScreen />;
|
||||
case "membership-methods":
|
||||
return <MembershipMethodsScreen />;
|
||||
case "decision-approaches":
|
||||
return <DecisionApproachesScreen />;
|
||||
case "conflict-management":
|
||||
return <ConflictManagementScreen />;
|
||||
case "confirm-stakeholders":
|
||||
return <ConfirmStakeholdersScreen />;
|
||||
case "final-review":
|
||||
return <FinalReviewScreen />;
|
||||
case "edit-rule":
|
||||
return <FinalReviewScreen variant="editPublished" />;
|
||||
case "completed":
|
||||
return <CompletedScreen />;
|
||||
default: {
|
||||
const _exhaustive: never = screenId;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,19 +16,8 @@ import {
|
||||
getAssetPath,
|
||||
vectorMarkPath,
|
||||
} from "../../../../../lib/assetUtils";
|
||||
|
||||
/**
|
||||
* Targets for a `pendingTemplateAction` redirect. Customize resumes the
|
||||
* custom-rule stage with chips already prefilled; useWithoutChanges jumps to
|
||||
* the review-and-complete stage since the template body is already in state.
|
||||
*/
|
||||
const PENDING_TEMPLATE_REDIRECT_TARGET: Record<
|
||||
"customize" | "useWithoutChanges",
|
||||
string
|
||||
> = {
|
||||
customize: "/create/core-values",
|
||||
useWithoutChanges: "/create/confirm-stakeholders",
|
||||
};
|
||||
import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState";
|
||||
import { createFlowStepPath } from "../../utils/createFlowPaths";
|
||||
|
||||
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
|
||||
export function CommunityReviewScreen() {
|
||||
@@ -55,12 +44,32 @@ export function CommunityReviewScreen() {
|
||||
if (firedRedirectRef.current) return;
|
||||
const pending = state.pendingTemplateAction;
|
||||
if (!pending) return;
|
||||
const target = PENDING_TEMPLATE_REDIRECT_TARGET[pending.mode];
|
||||
if (!target) return;
|
||||
const target =
|
||||
pending.mode === "customize"
|
||||
? createFlowStepPath("core-values")
|
||||
: createFlowStepPath("confirm-stakeholders");
|
||||
firedRedirectRef.current = true;
|
||||
updateState({ pendingTemplateAction: undefined });
|
||||
const pinMerge =
|
||||
pending.mode === "customize"
|
||||
? {
|
||||
methodSectionsPinCommitted: {
|
||||
...state.methodSectionsPinCommitted,
|
||||
...methodSectionsPinsForHydratedSelections(state),
|
||||
},
|
||||
}
|
||||
: {};
|
||||
updateState({ pendingTemplateAction: undefined, ...pinMerge });
|
||||
router.replace(target);
|
||||
}, [router, state.pendingTemplateAction, updateState]);
|
||||
}, [
|
||||
router,
|
||||
state.pendingTemplateAction,
|
||||
state.methodSectionsPinCommitted,
|
||||
state.selectedCommunicationMethodIds,
|
||||
state.selectedMembershipMethodIds,
|
||||
state.selectedDecisionApproachIds,
|
||||
state.selectedConflictManagementIds,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const cardTitle =
|
||||
typeof state.title === "string" && state.title.trim().length > 0
|
||||
@@ -77,6 +86,12 @@ export function CommunityReviewScreen() {
|
||||
? state.communityContext.trim()
|
||||
: undefined;
|
||||
|
||||
const avatarUrl =
|
||||
typeof state.communityAvatarUrl === "string" &&
|
||||
state.communityAvatarUrl.trim().length > 0
|
||||
? state.communityAvatarUrl.trim()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="wideGridLoosePadding"
|
||||
@@ -100,7 +115,9 @@ export function CommunityReviewScreen() {
|
||||
size={lgUp ? "L" : "M"}
|
||||
expanded={false}
|
||||
backgroundColor="bg-[var(--color-teal-teal50,#c9fef9)]"
|
||||
logoUrl={getAssetPath(vectorMarkPath("mutual-aid"))}
|
||||
logoUrl={
|
||||
avatarUrl ?? getAssetPath(vectorMarkPath("mutual-aid"))
|
||||
}
|
||||
logoAlt={cardTitle}
|
||||
className="rounded-[24px]"
|
||||
/>
|
||||
|
||||
@@ -15,17 +15,31 @@ import {
|
||||
type FinalReviewCategoryRowDetailed,
|
||||
} from "../../../../../lib/create/buildFinalReviewCategories";
|
||||
import { applyFinalReviewChipEditPatch } from "../../../../../lib/create/applyFinalReviewChipEditPatch";
|
||||
import type { TemplateChipDetail } from "../../../../../lib/create/templateReviewMapping";
|
||||
import type {
|
||||
TemplateChipDetail,
|
||||
TemplateFacetGroupKey,
|
||||
} from "../../../../../lib/create/templateReviewMapping";
|
||||
import {
|
||||
FinalReviewChipEditModal,
|
||||
type FinalReviewChipEditPatch,
|
||||
type FinalReviewChipEditTarget,
|
||||
} from "../../components/FinalReviewChipEditModal";
|
||||
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
|
||||
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
|
||||
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
|
||||
import {
|
||||
getAssetPath,
|
||||
vectorMarkPath,
|
||||
} from "../../../../../lib/assetUtils";
|
||||
|
||||
const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [
|
||||
"coreValues",
|
||||
"communication",
|
||||
"membership",
|
||||
"decisionApproaches",
|
||||
"conflictManagement",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* `finalReview.json.categories` ships a demo ordering + localized names
|
||||
* (Values / Communication / Membership / Decision-making / Conflict
|
||||
@@ -68,8 +82,13 @@ function readFallbackCategoryRows(
|
||||
};
|
||||
}
|
||||
|
||||
export function FinalReviewScreen() {
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
export function FinalReviewScreen({
|
||||
variant = "default",
|
||||
}: {
|
||||
variant?: "default" | "editPublished";
|
||||
} = {}) {
|
||||
const { state, updateState, replaceState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const { goToStep } = useCreateFlowNavigation();
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const t = useTranslation("create.reviewAndComplete.finalReview");
|
||||
const m = useMessages();
|
||||
@@ -77,11 +96,11 @@ export function FinalReviewScreen() {
|
||||
/**
|
||||
* Two modals coexist on this screen:
|
||||
*
|
||||
* - {@link FinalReviewChipEditModal} — editable Save-button version used
|
||||
* whenever the chip resolves to a stable `overrideKey` (core-value
|
||||
* chip id, or a method preset id). Writes through to
|
||||
* `{group}DetailsById` state fields on Save; close-without-save is a
|
||||
* no-op so any typed edits are discarded.
|
||||
* - {@link FinalReviewChipEditModal} — core values + method chips: kebab
|
||||
* Customize / Remove; values also offer Duplicate under the five-chip cap.
|
||||
* Save respects the same unlock/dirty rules as the facet create modals;
|
||||
* writes `{group}DetailsById`, snapshot label (values), `customMethodCardMetaById`,
|
||||
* and field blocks on Save.
|
||||
* - {@link TemplateChipDetailModal} — read-only fallback for chips we
|
||||
* can't map to an override key (e.g. template body entries on the
|
||||
* "Use without changes" path where no preset matches the title).
|
||||
@@ -93,6 +112,8 @@ export function FinalReviewScreen() {
|
||||
useState<FinalReviewChipEditTarget | null>(null);
|
||||
const [activeReadOnlyDetail, setActiveReadOnlyDetail] =
|
||||
useState<TemplateChipDetail | null>(null);
|
||||
const [communityContextModalOpen, setCommunityContextModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleSave = useCallback(
|
||||
(patch: FinalReviewChipEditPatch) => {
|
||||
@@ -102,20 +123,30 @@ export function FinalReviewScreen() {
|
||||
[markCreateFlowInteraction, updateState, state],
|
||||
);
|
||||
|
||||
const { categories: finalReviewCategories, chipLookup } = useMemo(() => {
|
||||
const { categories: finalReviewCategories } = useMemo(() => {
|
||||
const { names, rows: fallbackRows } = readFallbackCategoryRows(
|
||||
m.create.reviewAndComplete.finalReview.categories,
|
||||
);
|
||||
const derived = buildFinalReviewCategoryRowsDetailed(state, names);
|
||||
const rowsToRender: readonly FinalReviewCategoryRowDetailed[] =
|
||||
derived.length > 0 ? derived : fallbackRows;
|
||||
const usingFallbackRows = derived.length === 0;
|
||||
|
||||
const lookup = new Map<
|
||||
string,
|
||||
{ target: FinalReviewChipEditTarget | null; readOnly: TemplateChipDetail }
|
||||
>();
|
||||
|
||||
const cats: Category[] = rowsToRender.map((row) => {
|
||||
const cats: Category[] = rowsToRender.map((row, rowIndex) => {
|
||||
const effectiveGroupKey: TemplateFacetGroupKey | null =
|
||||
row.groupKey ??
|
||||
(usingFallbackRows && rowIndex < FACET_FALLBACK_ORDER.length
|
||||
? FACET_FALLBACK_ORDER[rowIndex]
|
||||
: null);
|
||||
|
||||
const reviewReturn =
|
||||
variant === "editPublished" ? ("edit-rule" as const) : ("final-review" as const);
|
||||
|
||||
const chipOptions = row.entries.map((entry, idx) => {
|
||||
const chipId = `${row.name}-${idx}`;
|
||||
const readOnly: TemplateChipDetail = {
|
||||
@@ -143,6 +174,7 @@ export function FinalReviewScreen() {
|
||||
return {
|
||||
name: row.name,
|
||||
chipOptions,
|
||||
addButton: effectiveGroupKey != null,
|
||||
onChipClick: (_categoryName: string, chipId: string) => {
|
||||
const hit = lookup.get(chipId);
|
||||
if (!hit) return;
|
||||
@@ -153,15 +185,25 @@ export function FinalReviewScreen() {
|
||||
setActiveReadOnlyDetail(hit.readOnly);
|
||||
}
|
||||
},
|
||||
onAddClick:
|
||||
effectiveGroupKey != null
|
||||
? () => {
|
||||
markCreateFlowInteraction();
|
||||
goToStep(createFlowStepForFacetGroup(effectiveGroupKey), {
|
||||
reviewReturn,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
return { categories: cats, chipLookup: lookup };
|
||||
return { categories: cats };
|
||||
}, [
|
||||
m.create.reviewAndComplete.finalReview.categories,
|
||||
state,
|
||||
markCreateFlowInteraction,
|
||||
goToStep,
|
||||
variant,
|
||||
]);
|
||||
void chipLookup;
|
||||
|
||||
const ruleCardTitle = useMemo(() => {
|
||||
const raw = typeof state.title === "string" ? state.title.trim() : "";
|
||||
@@ -170,8 +212,7 @@ export function FinalReviewScreen() {
|
||||
|
||||
/**
|
||||
* Match {@link CommunityReviewScreen}: the card body is the free-text
|
||||
* `community-context` field only — not `summary` (template / one-line
|
||||
* rule summary can carry template-review copy).
|
||||
* `community-context` field only — not `summary`.
|
||||
*/
|
||||
const ruleCardDescription = useMemo(() => {
|
||||
const raw =
|
||||
@@ -181,14 +222,37 @@ export function FinalReviewScreen() {
|
||||
return raw.length > 0 ? raw : undefined;
|
||||
}, [state.communityContext]);
|
||||
|
||||
const rawCommunityContextForModal =
|
||||
typeof state.communityContext === "string" ? state.communityContext : "";
|
||||
|
||||
const descriptionEmptyHint =
|
||||
variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined;
|
||||
|
||||
return (
|
||||
<CreateFlowLockupCardStepShell
|
||||
lockupTitle={t("title")}
|
||||
lockupDescription={t("description")}
|
||||
lockupTitle={
|
||||
variant === "editPublished" ? t("editPublishedTitle") : t("title")
|
||||
}
|
||||
lockupDescription={
|
||||
variant === "editPublished"
|
||||
? t("editPublishedDescription")
|
||||
: t("description")
|
||||
}
|
||||
>
|
||||
<Rule
|
||||
title={ruleCardTitle}
|
||||
description={ruleCardDescription}
|
||||
onDescriptionClick={
|
||||
variant === "editPublished"
|
||||
? () => setCommunityContextModalOpen(true)
|
||||
: undefined
|
||||
}
|
||||
descriptionEmptyHint={descriptionEmptyHint}
|
||||
descriptionEditAriaLabel={
|
||||
variant === "editPublished"
|
||||
? t("communityContextEditModal.ariaEditDescription")
|
||||
: undefined
|
||||
}
|
||||
size={mdUp ? "L" : "M"}
|
||||
expanded={true}
|
||||
backgroundColor="bg-[#c9fef9]"
|
||||
@@ -204,12 +268,26 @@ export function FinalReviewScreen() {
|
||||
target={activeEditTarget}
|
||||
state={state}
|
||||
onSave={handleSave}
|
||||
replaceState={replaceState}
|
||||
onInteract={markCreateFlowInteraction}
|
||||
onEditTargetChange={setActiveEditTarget}
|
||||
/>
|
||||
<TemplateChipDetailModal
|
||||
isOpen={activeReadOnlyDetail !== null}
|
||||
onClose={() => setActiveReadOnlyDetail(null)}
|
||||
detail={activeReadOnlyDetail}
|
||||
/>
|
||||
{variant === "editPublished" ? (
|
||||
<FinalReviewCommunityContextEditModal
|
||||
isOpen={communityContextModalOpen}
|
||||
onClose={() => setCommunityContextModalOpen(false)}
|
||||
initialValue={rawCommunityContextForModal}
|
||||
onSave={(value) => {
|
||||
markCreateFlowInteraction();
|
||||
updateState({ communityContext: value, summary: value });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</CreateFlowLockupCardStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,32 +16,60 @@
|
||||
* replaced with DB-driven content.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import CardStack from "../../../../components/cards/CardStack";
|
||||
import HeaderLockup from "../../../../components/type/HeaderLockup";
|
||||
import Create from "../../../../components/modals/Create";
|
||||
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||
import InfoMessageBox from "../../../../components/controls/InfoMessageBox";
|
||||
import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMessageBox/InfoMessageBox.types";
|
||||
import type { CardStackItem } from "../../../../components/cards/CardStack/CardStack.types";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import {
|
||||
deriveCompactCards,
|
||||
rankMethodsByScore,
|
||||
useFacetRecommendations,
|
||||
} from "../../hooks/useFacetRecommendations";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
import { DecisionApproachEditFields } from "../../components/methodEditFields";
|
||||
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
||||
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
|
||||
import { decisionApproachFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId";
|
||||
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
|
||||
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
|
||||
import {
|
||||
cloneMethodCardBlocksForDuplicate,
|
||||
cloneMethodCardDetailsForDuplicate,
|
||||
duplicateMethodCardTitle,
|
||||
forkMethodCardFacetMapsForDuplicate,
|
||||
omitIdFromStringRecord,
|
||||
} from "../../../../../lib/create/duplicateMethodCardModalDraft";
|
||||
import type { DecisionApproachDetailEntry } from "../../types";
|
||||
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
||||
|
||||
export function DecisionApproachesScreen() {
|
||||
const m = useMessages();
|
||||
const da = m.create.customRule.decisionApproaches;
|
||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||
useCreateFlow();
|
||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||
const customizeSnapshotRef = useRef<
|
||||
MethodCardCustomizeSnapshot<DecisionApproachDetailEntry> | null
|
||||
>(null);
|
||||
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
@@ -50,6 +78,13 @@ export function DecisionApproachesScreen() {
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
const [pendingDraft, setPendingDraft] =
|
||||
useState<DecisionApproachDetailEntry | null>(null);
|
||||
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
||||
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||
CustomMethodCardFieldBlock[] | null
|
||||
>(null);
|
||||
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
||||
useState<MethodCardHeaderDraft | null>(null);
|
||||
|
||||
const selectedIds = state.selectedDecisionApproachIds ?? [];
|
||||
|
||||
@@ -62,43 +97,31 @@ export function DecisionApproachesScreen() {
|
||||
[da.messageBox.items],
|
||||
);
|
||||
|
||||
const { scoresBySlug, hasAnyFacets } =
|
||||
useFacetRecommendations("decisionApproaches");
|
||||
const rankedMethods = useMemo(
|
||||
() => rankMethodsByScore(da.methods, scoresBySlug),
|
||||
[da.methods, scoresBySlug],
|
||||
);
|
||||
|
||||
const { compactCardIds, recommendedIds } = useMemo(
|
||||
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
|
||||
[rankedMethods, scoresBySlug, hasAnyFacets],
|
||||
);
|
||||
|
||||
const sampleCards: CardStackItem[] = useMemo(
|
||||
const mergedMethods = useMemo(
|
||||
() =>
|
||||
rankedMethods.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
supportText: entry.supportText,
|
||||
recommended: recommendedIds.has(entry.id),
|
||||
})),
|
||||
[rankedMethods, recommendedIds],
|
||||
mergePresetMethodsWithCustom(
|
||||
da.methods,
|
||||
selectedIds,
|
||||
state.customMethodCardMetaById,
|
||||
),
|
||||
[da.methods, selectedIds, state.customMethodCardMetaById],
|
||||
);
|
||||
|
||||
const methodById = useMemo(
|
||||
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
|
||||
[rankedMethods],
|
||||
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
||||
"decisionApproaches",
|
||||
mergedMethods,
|
||||
selectedIds,
|
||||
);
|
||||
|
||||
const handleOpenAddWizard = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
setAddCustomWizardOpen(true);
|
||||
}, [markCreateFlowInteraction]);
|
||||
|
||||
const sidebarDescription = (
|
||||
<>
|
||||
{da.sidebar.descriptionBefore}
|
||||
<InlineTextButton
|
||||
onClick={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
<InlineTextButton onClick={handleOpenAddWizard}>
|
||||
{da.sidebar.descriptionLinkLabel}
|
||||
</InlineTextButton>
|
||||
{da.sidebar.descriptionAfter}
|
||||
@@ -133,6 +156,10 @@ export function DecisionApproachesScreen() {
|
||||
const handleCardSelect = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
setPendingCardId(id);
|
||||
setPendingDraft(seedDraft(id));
|
||||
setCreateModalOpen(true);
|
||||
@@ -148,50 +175,564 @@ export function DecisionApproachesScreen() {
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const isSelectedCardModal =
|
||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||
const fieldsLocked = !modalEditUnlocked;
|
||||
|
||||
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
|
||||
|
||||
const customFacetDetailsMatchPreset = useMemo(() => {
|
||||
if (!pendingCardId || !pendingDraft) return false;
|
||||
if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) {
|
||||
return false;
|
||||
}
|
||||
return decisionApproachFacetMatchesPreset(pendingDraft, pendingCardId);
|
||||
}, [
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const modalUsesWizardFieldBlocksBody = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
pendingCardId &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
}),
|
||||
),
|
||||
[
|
||||
customFacetDetailsMatchPreset,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
const ephemeralId = pendingEphemeralDuplicateIdRef.current;
|
||||
if (ephemeralId) {
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
replaceState((prev) => ({
|
||||
...prev,
|
||||
customMethodCardMetaById: omitIdFromStringRecord(
|
||||
prev.customMethodCardMetaById,
|
||||
ephemeralId,
|
||||
),
|
||||
decisionApproachDetailsById: omitIdFromStringRecord(
|
||||
prev.decisionApproachDetailsById,
|
||||
ephemeralId,
|
||||
),
|
||||
customMethodCardFieldBlocksById: omitIdFromStringRecord(
|
||||
prev.customMethodCardFieldBlocksById,
|
||||
ephemeralId,
|
||||
),
|
||||
}));
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
setPendingDraft(null);
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
replaceState,
|
||||
]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
if (!modalEditUnlocked) {
|
||||
return;
|
||||
}
|
||||
const snap = customizeSnapshotRef.current;
|
||||
if (!snap) {
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
snap,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPendingDraft(structuredClone(snap.pendingDraft));
|
||||
setDraftFieldBlocks(null);
|
||||
setModalEditUnlocked(false);
|
||||
customizeSnapshotRef.current = null;
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
]);
|
||||
|
||||
const handleRemoveSelectedFromModal = useCallback(() => {
|
||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
updateState(
|
||||
removeMethodCardFromFacetSelection(
|
||||
state,
|
||||
"decisionApproaches",
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
pendingCardId,
|
||||
selectedIds,
|
||||
state,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleCustomize = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (!pendingDraft || !pendingCardId) {
|
||||
return;
|
||||
}
|
||||
const initialFieldBlocks =
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? structuredClone(
|
||||
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
|
||||
)
|
||||
: null;
|
||||
const method = methodById.get(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const headerDraft: MethodCardHeaderDraft = {
|
||||
title: meta?.label ?? method?.label ?? da.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
da.confirmModal.description,
|
||||
};
|
||||
setCustomizeHeaderDraft(headerDraft);
|
||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||
pendingDraft,
|
||||
initialFieldBlocks,
|
||||
headerDraft,
|
||||
);
|
||||
setDraftFieldBlocks(initialFieldBlocks);
|
||||
setModalEditUnlocked(true);
|
||||
}, [
|
||||
da.confirmModal.description,
|
||||
da.confirmModal.title,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const handleDuplicateCustomCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const meta = state.customMethodCardMetaById![pendingCardId]!;
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.decisionApproachDetailsById?.[pendingCardId],
|
||||
() => decisionApproachPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.decisionApproachDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(meta.label, suffix),
|
||||
supportText: meta.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
decisionApproachDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
draftFieldBlocks,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
state.decisionApproachDetailsById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleDuplicatePrefabCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const method = methodById.get(pendingCardId);
|
||||
if (!method || !pendingDraft) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.decisionApproachDetailsById?.[pendingCardId],
|
||||
() => decisionApproachPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.decisionApproachDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(method.label, suffix),
|
||||
supportText: method.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
decisionApproachDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
draftFieldBlocks,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
state.decisionApproachDetailsById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const kebabMenuItems = useMemo(
|
||||
() =>
|
||||
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||
showCustomize: !modalEditUnlocked,
|
||||
onCustomize: handleCustomize,
|
||||
onDuplicate:
|
||||
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
||||
? undefined
|
||||
: isCustomMethodCardId(
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
)
|
||||
? handleDuplicateCustomCard
|
||||
: handleDuplicatePrefabCard,
|
||||
showRemove: isSelectedCardModal,
|
||||
onRemove: handleRemoveSelectedFromModal,
|
||||
}),
|
||||
[
|
||||
handleCustomize,
|
||||
handleDuplicateCustomCard,
|
||||
handleDuplicatePrefabCard,
|
||||
handleRemoveSelectedFromModal,
|
||||
isSelectedCardModal,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu,
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
state.editingPublishedRuleId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded((prev) => !prev);
|
||||
}, [markCreateFlowInteraction]);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
setPendingDraft(null);
|
||||
const handleCloseAddWizard = useCallback(() => {
|
||||
setAddCustomWizardOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleCreateModalConfirm = useCallback(() => {
|
||||
if (!pendingCardId || !pendingDraft) {
|
||||
const handleFinalizeCustomCard = useCallback(
|
||||
({
|
||||
title,
|
||||
description,
|
||||
fieldBlocks,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||
}) => {
|
||||
markCreateFlowInteraction();
|
||||
const id = crypto.randomUUID();
|
||||
updateState({
|
||||
selectedDecisionApproachIds: moveFacetSelectionIdToFront(
|
||||
selectedIds,
|
||||
id,
|
||||
),
|
||||
customMethodCardMetaById: {
|
||||
...(state.customMethodCardMetaById ?? {}),
|
||||
[id]: { label: title, supportText: description },
|
||||
},
|
||||
decisionApproachDetailsById: {
|
||||
...(state.decisionApproachDetailsById ?? {}),
|
||||
[id]: decisionApproachPresetFor(id),
|
||||
},
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[id]: fieldBlocks,
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
markCreateFlowInteraction,
|
||||
selectedIds,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
state.decisionApproachDetailsById,
|
||||
updateState,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalPrimary = useCallback(() => {
|
||||
if (!pendingCardId) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
|
||||
if (selectedIds.includes(pendingCardId)) {
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
decisionApproachDetailsById: {
|
||||
...(state.decisionApproachDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
decisionApproachDetailsById: {
|
||||
...(state.decisionApproachDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingDraft) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
}
|
||||
updateState({
|
||||
selectedDecisionApproachIds: selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
selectedDecisionApproachIds: moveFacetSelectionIdToFront(
|
||||
selectedIds,
|
||||
pendingCardId,
|
||||
),
|
||||
decisionApproachDetailsById: {
|
||||
...(state.decisionApproachDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
selectedIds,
|
||||
state.decisionApproachDetailsById,
|
||||
state,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const saveLabel = modalKebabMenu.saveEdits;
|
||||
return {
|
||||
title: method?.label ?? da.confirmModal.title,
|
||||
description: method?.supportText ?? da.confirmModal.description,
|
||||
nextButtonText: da.addApproach.nextButtonText,
|
||||
title: meta?.label ?? method?.label ?? da.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
da.confirmModal.description,
|
||||
nextButtonText: modalEditUnlocked
|
||||
? saveLabel
|
||||
: da.addApproach.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
@@ -201,6 +742,7 @@ export function DecisionApproachesScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateFlowTwoColumnSelectShell
|
||||
contentTopBelowMd="space-800"
|
||||
lgVerticalAlign="start"
|
||||
@@ -232,7 +774,19 @@ export function DecisionApproachesScreen() {
|
||||
toggleLabel={da.cardStack.toggleSeeAll}
|
||||
showLessLabel={da.cardStack.toggleShowLess}
|
||||
title=""
|
||||
description=""
|
||||
description={
|
||||
expanded ? (
|
||||
<>
|
||||
{da.cardStack.expandedStackDescriptionBefore}
|
||||
<InlineTextButton onClick={handleOpenAddWizard}>
|
||||
{da.sidebar.descriptionLinkLabel}
|
||||
</InlineTextButton>
|
||||
{da.cardStack.expandedStackDescriptionAfter}
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)
|
||||
}
|
||||
layout="singleStack"
|
||||
compactRecommendedLimit={5}
|
||||
compactCardIds={compactCardIds}
|
||||
@@ -244,21 +798,75 @@ export function DecisionApproachesScreen() {
|
||||
<Create
|
||||
isOpen={createModalOpen}
|
||||
onClose={handleCreateModalClose}
|
||||
onNext={handleCreateModalConfirm}
|
||||
headerContent={
|
||||
modalEditUnlocked && customizeHeaderDraft ? (
|
||||
<MethodCardCustomizeModalHeader
|
||||
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
|
||||
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
|
||||
titleValue={customizeHeaderDraft.title}
|
||||
descriptionValue={customizeHeaderDraft.description}
|
||||
onTitleChange={(title) =>
|
||||
setCustomizeHeaderDraft((prev) =>
|
||||
prev ? { ...prev, title } : null,
|
||||
)
|
||||
}
|
||||
onDescriptionChange={(description) =>
|
||||
setCustomizeHeaderDraft((prev) =>
|
||||
prev ? { ...prev, description } : null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onNext={handleCreateModalPrimary}
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={false}
|
||||
showBackButton={modalEditUnlocked}
|
||||
onBack={handleCancelCustomize}
|
||||
backButtonText={modalKebabMenu.cancelCustomize}
|
||||
showNextButton={showMethodModalPrimary}
|
||||
backdropVariant="blurredYellow"
|
||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||
kebabMenuItems={kebabMenuItems}
|
||||
>
|
||||
{pendingCardId && pendingDraft ? (
|
||||
<DecisionApproachEditFields
|
||||
key={pendingCardId}
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
/>
|
||||
modalUsesWizardFieldBlocksBody ? (
|
||||
<CustomMethodCardModalBody
|
||||
cardId={pendingCardId}
|
||||
blocksById={state.customMethodCardFieldBlocksById}
|
||||
blocksOverride={
|
||||
modalEditUnlocked && draftFieldBlocks !== null
|
||||
? draftFieldBlocks
|
||||
: undefined
|
||||
}
|
||||
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
||||
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
|
||||
onFieldBlocksChange={
|
||||
fieldsLocked
|
||||
? undefined
|
||||
: (next) => setDraftFieldBlocks(next)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DecisionApproachEditFields
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
readOnly={fieldsLocked}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</Create>
|
||||
</CreateFlowTwoColumnSelectShell>
|
||||
<CustomMethodCardWizard
|
||||
isOpen={addCustomWizardOpen}
|
||||
onClose={handleCloseAddWizard}
|
||||
onFinalize={handleFinalizeCustomCard}
|
||||
onPersistCustomUploadFile={(file) =>
|
||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import Create from "../../../../components/modals/Create";
|
||||
import ContentLockup from "../../../../components/type/ContentLockup";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import type {
|
||||
CommunityStructureChipSnapshotRow,
|
||||
@@ -14,8 +15,23 @@ import type {
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
import { CoreValueEditFields } from "../../components/methodEditFields";
|
||||
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
import {
|
||||
duplicateCoreValueChipInDraft,
|
||||
MAX_SELECTED_CORE_VALUES,
|
||||
removeCoreValueChipFromDraft,
|
||||
} from "../../../../../lib/create/coreValueChipFacet";
|
||||
import { omitIdFromStringRecord } from "../../../../../lib/create/duplicateMethodCardModalDraft";
|
||||
|
||||
const MAX_CORE_VALUES = 5;
|
||||
const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
|
||||
|
||||
/**
|
||||
* Why three sessions, not two:
|
||||
@@ -57,31 +73,6 @@ function normalizeCoreValuePresets(
|
||||
});
|
||||
}
|
||||
|
||||
function chipRowsFromPresets(presets: readonly CoreValuePreset[]): ChipOption[] {
|
||||
return presets.map((row, i) => ({
|
||||
id: String(i + 1),
|
||||
label: row.label,
|
||||
state: "unselected" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
function applySavedSelection(
|
||||
options: ChipOption[],
|
||||
saved: string[] | undefined,
|
||||
): ChipOption[] {
|
||||
const selected = new Set(saved ?? []);
|
||||
return options.map((opt) =>
|
||||
opt.state === "custom"
|
||||
? opt
|
||||
: {
|
||||
...opt,
|
||||
state: selected.has(opt.id)
|
||||
? ("selected" as const)
|
||||
: ("unselected" as const),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
||||
return options
|
||||
.filter((o) => o.state === "selected")
|
||||
@@ -98,41 +89,31 @@ function chipOptionsToSnapshotRows(
|
||||
}));
|
||||
}
|
||||
|
||||
function snapshotRowsToChipOptions(
|
||||
rows: CommunityStructureChipSnapshotRow[] | undefined,
|
||||
): ChipOption[] | null {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return null;
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
...(r.state !== undefined
|
||||
? { state: r.state as ChipOption["state"] }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
const EMPTY_DETAIL: CoreValueDetailEntry = { meaning: "", signals: "" };
|
||||
|
||||
/** Create Custom — Core Values (Figma `20264:68378`). Up to five selections; preset list + custom chips. */
|
||||
export function CoreValuesSelectScreen() {
|
||||
const m = useMessages();
|
||||
const cv = m.create.customRule.coreValues;
|
||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||
const presets = useMemo(
|
||||
() => normalizeCoreValuePresets(cv.values as CoreValuePresetJson[]),
|
||||
[cv.values],
|
||||
);
|
||||
|
||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||
const { markCreateFlowInteraction, updateState, replaceState, state } =
|
||||
useCreateFlow();
|
||||
|
||||
const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(
|
||||
() => {
|
||||
const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot);
|
||||
if (fromSnap) return fromSnap;
|
||||
return applySavedSelection(
|
||||
chipRowsFromPresets(presets),
|
||||
state.selectedCoreValueIds,
|
||||
);
|
||||
},
|
||||
const coreCustomizeSnapshotRef =
|
||||
useRef<MethodCardCustomizeSnapshot<CoreValueDetailEntry> | null>(null);
|
||||
const pendingEphemeralCoreDuplicateRef = useRef<string | null>(null);
|
||||
|
||||
const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(() =>
|
||||
buildCoreValueChipOptionsFromDraft(
|
||||
presets,
|
||||
state.coreValuesChipsSnapshot,
|
||||
state.selectedCoreValueIds,
|
||||
),
|
||||
);
|
||||
|
||||
const [activeModalChipId, setActiveModalChipId] = useState<string | null>(
|
||||
@@ -140,17 +121,23 @@ export function CoreValuesSelectScreen() {
|
||||
);
|
||||
const [modalSession, setModalSession] = useState<ModalSession | null>(null);
|
||||
const [draft, setDraft] = useState<CoreValueDetailEntry>(EMPTY_DETAIL);
|
||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
||||
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
||||
useState<MethodCardHeaderDraft | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot);
|
||||
if (fromSnap) {
|
||||
setCoreValueOptions(fromSnap);
|
||||
return;
|
||||
}
|
||||
setCoreValueOptions((prev) =>
|
||||
applySavedSelection(prev, state.selectedCoreValueIds),
|
||||
setCoreValueOptions(
|
||||
buildCoreValueChipOptionsFromDraft(
|
||||
presets,
|
||||
state.coreValuesChipsSnapshot,
|
||||
state.selectedCoreValueIds,
|
||||
),
|
||||
);
|
||||
}, [state.coreValuesChipsSnapshot, state.selectedCoreValueIds]);
|
||||
}, [
|
||||
presets,
|
||||
state.coreValuesChipsSnapshot,
|
||||
state.selectedCoreValueIds,
|
||||
]);
|
||||
|
||||
/** Sync chips to create-flow draft. Never call `updateState` from inside a `setCoreValueOptions` updater — defer with `queueMicrotask`. */
|
||||
const syncCoreValuesToDraft = useCallback(
|
||||
@@ -195,10 +182,18 @@ export function CoreValuesSelectScreen() {
|
||||
);
|
||||
|
||||
const openModal = useCallback(
|
||||
(chipId: string, session: ModalSession, valueLabel: string) => {
|
||||
setDraft(getInitialTexts(chipId, valueLabel));
|
||||
(
|
||||
chipId: string,
|
||||
session: ModalSession,
|
||||
valueLabel: string,
|
||||
seedDetail?: CoreValueDetailEntry,
|
||||
) => {
|
||||
setDraft(seedDetail ?? getInitialTexts(chipId, valueLabel));
|
||||
setActiveModalChipId(chipId);
|
||||
setModalSession(session);
|
||||
setModalEditUnlocked(false);
|
||||
setCustomizeHeaderDraft(null);
|
||||
coreCustomizeSnapshotRef.current = null;
|
||||
markCreateFlowInteraction();
|
||||
},
|
||||
[getInitialTexts, markCreateFlowInteraction],
|
||||
@@ -212,46 +207,347 @@ export function CoreValuesSelectScreen() {
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleModalDismiss = useCallback(() => {
|
||||
if (activeModalChipId && modalSession === "pending") {
|
||||
const resetCustomizeSession = useCallback(() => {
|
||||
coreCustomizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, []);
|
||||
|
||||
const finalizeModalDismiss = useCallback(() => {
|
||||
pendingEphemeralCoreDuplicateRef.current = null;
|
||||
resetCustomizeSession();
|
||||
setActiveModalChipId(null);
|
||||
setModalSession(null);
|
||||
}, [resetCustomizeSession]);
|
||||
|
||||
const handleCustomize = useCallback(() => {
|
||||
if (!activeModalChipId) return;
|
||||
const chipLabelNow =
|
||||
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
|
||||
if (!chipLabelNow) return;
|
||||
markCreateFlowInteraction();
|
||||
const headerDraft: MethodCardHeaderDraft = {
|
||||
title: chipLabelNow,
|
||||
description: "",
|
||||
};
|
||||
coreCustomizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||
draft,
|
||||
null,
|
||||
headerDraft,
|
||||
);
|
||||
setCustomizeHeaderDraft(headerDraft);
|
||||
setModalEditUnlocked(true);
|
||||
}, [activeModalChipId, coreValueOptions, draft, markCreateFlowInteraction]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
if (!modalEditUnlocked) return;
|
||||
const snap = coreCustomizeSnapshotRef.current;
|
||||
if (!snap) {
|
||||
resetCustomizeSession();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(snap, draft, null, customizeHeaderDraft) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setDraft(structuredClone(snap.pendingDraft));
|
||||
resetCustomizeSession();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
resetCustomizeSession,
|
||||
]);
|
||||
|
||||
const syncLabelFromCustomizeHeaderToOptions = useCallback(() => {
|
||||
if (!activeModalChipId || !customizeHeaderDraft) return coreValueOptions;
|
||||
const trimmed = customizeHeaderDraft.title.trim();
|
||||
if (!trimmed) return coreValueOptions;
|
||||
return coreValueOptions.map((opt) =>
|
||||
opt.id === activeModalChipId ? { ...opt, label: trimmed } : opt,
|
||||
);
|
||||
}, [activeModalChipId, customizeHeaderDraft, coreValueOptions]);
|
||||
|
||||
const handleDuplicateCoreChip = useCallback(() => {
|
||||
if (!activeModalChipId || !modalSession) return;
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
coreCustomizeSnapshotRef.current,
|
||||
draft,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const priorEphemeral = pendingEphemeralCoreDuplicateRef.current;
|
||||
let outcome: ReturnType<typeof duplicateCoreValueChipInDraft> | null = null;
|
||||
replaceState((prev) => {
|
||||
const base =
|
||||
priorEphemeral != null
|
||||
? { ...prev, ...removeCoreValueChipFromDraft(prev, priorEphemeral) }
|
||||
: prev;
|
||||
const res = duplicateCoreValueChipInDraft(
|
||||
base,
|
||||
activeModalChipId,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
);
|
||||
if (!res) {
|
||||
return base;
|
||||
}
|
||||
outcome = res;
|
||||
return { ...base, ...res.patch };
|
||||
});
|
||||
if (!outcome) {
|
||||
return;
|
||||
}
|
||||
resetCustomizeSession();
|
||||
pendingEphemeralCoreDuplicateRef.current = outcome.newId;
|
||||
openModal(
|
||||
outcome.newId,
|
||||
"editing",
|
||||
outcome.newLabel,
|
||||
structuredClone(draft),
|
||||
);
|
||||
}, [
|
||||
activeModalChipId,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
modalSession,
|
||||
openModal,
|
||||
replaceState,
|
||||
resetCustomizeSession,
|
||||
]);
|
||||
|
||||
const handleRemoveFromKebab = useCallback(() => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
coreCustomizeSnapshotRef.current,
|
||||
draft,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
|
||||
const ep = pendingEphemeralCoreDuplicateRef.current;
|
||||
if (ep && activeModalChipId === ep) {
|
||||
replaceState((prev) => ({
|
||||
...prev,
|
||||
...removeCoreValueChipFromDraft(prev, ep),
|
||||
}));
|
||||
finalizeModalDismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalSession === "pending") {
|
||||
const next = coreValueOptions.map((opt) =>
|
||||
opt.id === activeModalChipId
|
||||
? { ...opt, state: "unselected" as const }
|
||||
: opt,
|
||||
);
|
||||
persistCoreValues(next);
|
||||
} else if (activeModalChipId && modalSession === "customPending") {
|
||||
// Custom chip never confirmed via Add Value — drop it from both
|
||||
// the local options and the create-flow draft so refresh / back
|
||||
// navigation doesn't resurrect a phantom chip.
|
||||
} else if (modalSession === "customPending") {
|
||||
const next = coreValueOptions.filter((opt) => opt.id !== activeModalChipId);
|
||||
persistCoreValues(next);
|
||||
} else if (modalSession === "editing" && activeModalChipId) {
|
||||
const nextFiltered = coreValueOptions.filter(
|
||||
(opt) => opt.id !== activeModalChipId,
|
||||
);
|
||||
markCreateFlowInteraction();
|
||||
replaceState((prev) => ({
|
||||
...prev,
|
||||
selectedCoreValueIds: selectedIdsFromOptions(nextFiltered),
|
||||
coreValuesChipsSnapshot:
|
||||
chipOptionsToSnapshotRows(nextFiltered),
|
||||
coreValueDetailsByChipId:
|
||||
omitIdFromStringRecord(prev.coreValueDetailsByChipId, activeModalChipId),
|
||||
}));
|
||||
setCoreValueOptions(nextFiltered);
|
||||
}
|
||||
finalizeModalDismiss();
|
||||
}, [
|
||||
activeModalChipId,
|
||||
coreValueOptions,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
finalizeModalDismiss,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
modalSession,
|
||||
persistCoreValues,
|
||||
replaceState,
|
||||
modalSession,
|
||||
persistCoreValues,
|
||||
]);
|
||||
|
||||
const handleModalDismiss = useCallback(() => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
coreCustomizeSnapshotRef.current,
|
||||
draft,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ep = pendingEphemeralCoreDuplicateRef.current;
|
||||
if (ep) {
|
||||
replaceState((prev) => ({
|
||||
...prev,
|
||||
...removeCoreValueChipFromDraft(prev, ep),
|
||||
}));
|
||||
}
|
||||
|
||||
if (modalSession === "pending" && activeModalChipId) {
|
||||
const next = coreValueOptions.map((opt) =>
|
||||
opt.id === activeModalChipId
|
||||
? { ...opt, state: "unselected" as const }
|
||||
: opt,
|
||||
);
|
||||
persistCoreValues(next);
|
||||
} else if (modalSession === "customPending" && activeModalChipId) {
|
||||
const next = coreValueOptions.filter(
|
||||
(opt) => opt.id !== activeModalChipId,
|
||||
);
|
||||
persistCoreValues(next);
|
||||
}
|
||||
setActiveModalChipId(null);
|
||||
setModalSession(null);
|
||||
}, [activeModalChipId, modalSession, coreValueOptions, persistCoreValues]);
|
||||
|
||||
const handleModalConfirm = useCallback(() => {
|
||||
if (!activeModalChipId) return;
|
||||
markCreateFlowInteraction();
|
||||
updateState({
|
||||
coreValueDetailsByChipId: {
|
||||
...(state.coreValueDetailsByChipId ?? {}),
|
||||
[activeModalChipId]: draft,
|
||||
},
|
||||
});
|
||||
setActiveModalChipId(null);
|
||||
setModalSession(null);
|
||||
finalizeModalDismiss();
|
||||
}, [
|
||||
activeModalChipId,
|
||||
coreValueOptions,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
finalizeModalDismiss,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
modalSession,
|
||||
persistCoreValues,
|
||||
replaceState,
|
||||
]);
|
||||
|
||||
const coreCustomizeSaveDisabled = useMemo(() => {
|
||||
if (!modalEditUnlocked) return false;
|
||||
const snap = coreCustomizeSnapshotRef.current;
|
||||
if (!snap) return true;
|
||||
return !isMethodCardCustomizeSessionDirty(
|
||||
snap,
|
||||
draft,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
}, [customizeHeaderDraft, draft, modalEditUnlocked]);
|
||||
|
||||
const handleModalConfirm = useCallback(() => {
|
||||
if (!activeModalChipId || !modalSession) return;
|
||||
|
||||
if (modalEditUnlocked && customizeHeaderDraft) {
|
||||
if (coreCustomizeSaveDisabled) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
pendingEphemeralCoreDuplicateRef.current = null;
|
||||
const nextOpts = syncLabelFromCustomizeHeaderToOptions();
|
||||
persistCoreValues(nextOpts);
|
||||
updateState({
|
||||
coreValueDetailsByChipId: {
|
||||
...(state.coreValueDetailsByChipId ?? {}),
|
||||
[activeModalChipId]: draft,
|
||||
},
|
||||
});
|
||||
resetCustomizeSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalSession === "pending" || modalSession === "customPending") {
|
||||
markCreateFlowInteraction();
|
||||
pendingEphemeralCoreDuplicateRef.current = null;
|
||||
updateState({
|
||||
coreValueDetailsByChipId: {
|
||||
...(state.coreValueDetailsByChipId ?? {}),
|
||||
[activeModalChipId]: draft,
|
||||
},
|
||||
});
|
||||
resetCustomizeSession();
|
||||
setActiveModalChipId(null);
|
||||
setModalSession(null);
|
||||
}
|
||||
}, [
|
||||
activeModalChipId,
|
||||
coreCustomizeSaveDisabled,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalSession,
|
||||
persistCoreValues,
|
||||
resetCustomizeSession,
|
||||
state.coreValueDetailsByChipId,
|
||||
syncLabelFromCustomizeHeaderToOptions,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const modalChipLabel =
|
||||
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
|
||||
|
||||
const modalFieldsLocked =
|
||||
!modalEditUnlocked &&
|
||||
Boolean(
|
||||
modalSession === "pending" ||
|
||||
modalSession === "customPending" ||
|
||||
modalSession === "editing",
|
||||
);
|
||||
|
||||
const showFooterPrimary =
|
||||
modalEditUnlocked ||
|
||||
modalSession === "pending" ||
|
||||
modalSession === "customPending";
|
||||
|
||||
const kebabMenuItems = useMemo(() => {
|
||||
if (!modalSession || !activeModalChipId) return [];
|
||||
const selectedCount = coreValueOptions.filter(
|
||||
(o) => o.state === "selected",
|
||||
).length;
|
||||
return buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||
showCustomize: !modalEditUnlocked,
|
||||
onCustomize: handleCustomize,
|
||||
onDuplicate:
|
||||
modalSession !== "editing" || selectedCount >= MAX_CORE_VALUES
|
||||
? undefined
|
||||
: handleDuplicateCoreChip,
|
||||
showRemove: true,
|
||||
onRemove: handleRemoveFromKebab,
|
||||
});
|
||||
}, [
|
||||
activeModalChipId,
|
||||
coreValueOptions,
|
||||
handleCustomize,
|
||||
handleDuplicateCoreChip,
|
||||
handleRemoveFromKebab,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu,
|
||||
modalSession,
|
||||
]);
|
||||
const handleChipClick = (chipId: string) => {
|
||||
const target = coreValueOptions.find((o) => o.id === chipId);
|
||||
if (!target || target.state === "custom") return;
|
||||
@@ -261,12 +557,7 @@ export function CoreValuesSelectScreen() {
|
||||
).length;
|
||||
|
||||
if (target.state === "selected") {
|
||||
const next: ChipOption[] = coreValueOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? { ...opt, state: "unselected" as const }
|
||||
: opt,
|
||||
);
|
||||
persistCoreValues(next);
|
||||
openModal(chipId, "editing", target.label);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -332,9 +623,6 @@ export function CoreValuesSelectScreen() {
|
||||
},
|
||||
};
|
||||
|
||||
const modalChipLabel =
|
||||
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
|
||||
|
||||
const description = (
|
||||
<>
|
||||
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
|
||||
@@ -385,22 +673,54 @@ export function CoreValuesSelectScreen() {
|
||||
onClose={handleModalDismiss}
|
||||
backdropVariant="blurredYellow"
|
||||
headerContent={
|
||||
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
||||
<ContentLockup
|
||||
title={modalChipLabel}
|
||||
description={detailModal.subtitle}
|
||||
variant="modal"
|
||||
alignment="left"
|
||||
modalEditUnlocked && customizeHeaderDraft ? (
|
||||
<MethodCardCustomizeModalHeader
|
||||
titleLabel={detailModal.customizeValueNameLabel}
|
||||
descriptionLabel=""
|
||||
titleValue={customizeHeaderDraft.title}
|
||||
descriptionValue=""
|
||||
onTitleChange={(title) =>
|
||||
setCustomizeHeaderDraft((prev) =>
|
||||
prev ? { ...prev, title } : null,
|
||||
)
|
||||
}
|
||||
onDescriptionChange={() => {}}
|
||||
showDescription={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
||||
<ContentLockup
|
||||
title={modalChipLabel}
|
||||
description={detailModal.subtitle}
|
||||
variant="modal"
|
||||
alignment="left"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
showBackButton={modalEditUnlocked}
|
||||
onBack={handleCancelCustomize}
|
||||
backButtonText={modalKebabMenu.cancelCustomize}
|
||||
showNextButton={showFooterPrimary}
|
||||
nextButtonDisabled={
|
||||
modalEditUnlocked && coreCustomizeSaveDisabled
|
||||
}
|
||||
showBackButton={false}
|
||||
showNextButton
|
||||
onNext={handleModalConfirm}
|
||||
nextButtonText={detailModal.addValueButton}
|
||||
nextButtonText={
|
||||
modalEditUnlocked ? modalKebabMenu.saveEdits : detailModal.addValueButton
|
||||
}
|
||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||
kebabMenuItems={
|
||||
kebabMenuItems.length > 0 ? kebabMenuItems : undefined
|
||||
}
|
||||
ariaLabel={modalChipLabel || "Core value details"}
|
||||
>
|
||||
<CoreValueEditFields value={draft} onChange={handleDraftChange} />
|
||||
<CoreValueEditFields
|
||||
readOnly={modalFieldsLocked}
|
||||
value={draft}
|
||||
onChange={handleDraftChange}
|
||||
/>
|
||||
</Create>
|
||||
)}
|
||||
</CreateFlowTwoColumnSelectShell>
|
||||
|
||||
@@ -1,21 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
} from "react";
|
||||
import Upload from "../../../../components/controls/Upload";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||
import { fetchAuthSession } from "../../../../../lib/create/api";
|
||||
import { getAssetPath } from "../../../../../lib/assetUtils";
|
||||
import {
|
||||
UploadToServerError,
|
||||
uploadCreateFlowFile,
|
||||
} from "../../../../../lib/create/uploadToServer";
|
||||
import {
|
||||
clearPendingCommunityAvatarFile,
|
||||
storePendingCommunityAvatarFile,
|
||||
} from "../../../../../lib/create/pendingCommunityAvatarUpload";
|
||||
|
||||
/** Create Community — Figma Flow — Upload `20094:41524`. */
|
||||
export function CommunityUploadScreen() {
|
||||
const m = useMessages();
|
||||
const u = m.create.community.communityUpload;
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const { markCreateFlowInteraction, state, updateState } = useCreateFlow();
|
||||
const tUpload = useTranslation("create.upload");
|
||||
|
||||
const handleUploadClick = () => {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [signedIn, setSignedIn] = useState<boolean | null>(null);
|
||||
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchAuthSession().then(({ user }) => {
|
||||
if (!cancelled) setSignedIn(Boolean(user));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (localPreviewUrl) URL.revokeObjectURL(localPreviewUrl);
|
||||
},
|
||||
[localPreviewUrl],
|
||||
);
|
||||
|
||||
const resolveUploadError = useCallback(
|
||||
(err: unknown) => {
|
||||
if (err instanceof UploadToServerError) {
|
||||
if (err.status === 413) return tUpload("errors.tooLarge");
|
||||
if (err.status === 401) return tUpload("errors.unauthorized");
|
||||
if (err.code === "server_misconfigured") {
|
||||
return tUpload("errors.misconfigured");
|
||||
}
|
||||
}
|
||||
return tUpload("errors.generic");
|
||||
},
|
||||
[tUpload],
|
||||
);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
markCreateFlowInteraction();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (signedIn) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { url } = await uploadCreateFlowFile(file, "communityAvatar");
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
updateState({ communityAvatarUrl: url });
|
||||
} catch (err) {
|
||||
setErrorMessage(resolveUploadError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (signedIn === false) {
|
||||
try {
|
||||
await storePendingCommunityAvatarFile(file);
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return URL.createObjectURL(file);
|
||||
});
|
||||
} catch {
|
||||
setErrorMessage(tUpload("errors.generic"));
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
markCreateFlowInteraction,
|
||||
resolveUploadError,
|
||||
signedIn,
|
||||
tUpload,
|
||||
updateState,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClearPendingUpload = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
};
|
||||
setErrorMessage(null);
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
if (
|
||||
typeof state.communityAvatarUrl === "string" &&
|
||||
state.communityAvatarUrl.trim().length > 0
|
||||
) {
|
||||
updateState({ communityAvatarUrl: undefined });
|
||||
}
|
||||
// Clear any anonymous staged blob so the post-sign-in flush won't resurrect it.
|
||||
void clearPendingCommunityAvatarFile();
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]);
|
||||
|
||||
const displaySrc =
|
||||
typeof state.communityAvatarUrl === "string" &&
|
||||
state.communityAvatarUrl.trim().length > 0
|
||||
? state.communityAvatarUrl.trim()
|
||||
: localPreviewUrl;
|
||||
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
@@ -32,13 +156,65 @@ export function CommunityUploadScreen() {
|
||||
justification="center"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Upload
|
||||
active={true}
|
||||
showHelpIcon={false}
|
||||
hintText={u.hintText}
|
||||
onClick={handleUploadClick}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
aria-label={u.hintText}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
{hasPreview ? (
|
||||
<div className="relative inline-block max-w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearPendingUpload}
|
||||
className="absolute right-[8px] top-[8px] z-[1] flex h-[32px] w-[32px] cursor-pointer items-center justify-center rounded-full bg-[var(--color-surface-default-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
|
||||
aria-label={u.clearPendingUploadAriaLabel}
|
||||
title={u.clearPendingUploadTooltip}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
|
||||
<img
|
||||
src={getAssetPath("assets/Icon_Close.svg")}
|
||||
alt=""
|
||||
className="h-[16px] w-[16px]"
|
||||
style={{
|
||||
filter: "brightness(0) invert(1)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- user/device file or same-origin upload URL */}
|
||||
<img
|
||||
src={displaySrc ?? ""}
|
||||
alt={u.previewAlt}
|
||||
className="max-h-[200px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
active={!busy}
|
||||
showHelpIcon={false}
|
||||
hintText={busy ? u.uploadingLabel : u.hintText}
|
||||
onClick={() => {
|
||||
if (!busy) fileInputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{signedIn === false ? (
|
||||
<p className="max-w-[474px] text-center font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-tertiary)]">
|
||||
{u.signInToUploadNote}
|
||||
</p>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<p
|
||||
className="max-w-[474px] text-center font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
|
||||
role="alert"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
* including step types, state management, and context interfaces.
|
||||
*/
|
||||
|
||||
import type { CustomMethodCardFieldBlock } from "../../../lib/create/customMethodCardFieldBlocks";
|
||||
import type { MethodFacetApiSectionId } from "../../../lib/create/customRuleFacets";
|
||||
|
||||
/**
|
||||
* Valid step IDs for the create rule flow (URL segment after `/create/`).
|
||||
* Create Community order matches Figma; `review` closes that stage per design.
|
||||
@@ -25,6 +28,8 @@ export type CreateFlowStep =
|
||||
| "conflict-management"
|
||||
| "confirm-stakeholders"
|
||||
| "final-review"
|
||||
/** Branch-only URL: same UI as final-review; editing an already-published rule from completed. */
|
||||
| "edit-rule"
|
||||
| "completed";
|
||||
|
||||
/** String keys used by generic text-field steps for `CreateFlowState`. */
|
||||
@@ -34,6 +39,12 @@ export type CreateFlowTextStateField =
|
||||
| "communityContext"
|
||||
| "communitySaveEmail";
|
||||
|
||||
/**
|
||||
* Facet-backed method card stacks (`GET /api/create-flow/methods?section=`).
|
||||
* Canonical ids: {@link METHOD_FACET_API_SECTION_IDS} in `lib/create/customRuleFacets.ts`.
|
||||
*/
|
||||
export type CreateFlowMethodCardFacetSection = MethodFacetApiSectionId;
|
||||
|
||||
/**
|
||||
* Serialized chip row for `community-structure` (preset + custom labels).
|
||||
* Stored in drafts so custom chips survive refresh and server sync.
|
||||
@@ -97,6 +108,11 @@ export interface CreateFlowState {
|
||||
communityContext?: string;
|
||||
/** Email collected on the “Save your progress” step (Figma Flow — Text `20097:14948`). */
|
||||
communitySaveEmail?: string;
|
||||
/**
|
||||
* Public app path for the uploaded community image (e.g. `/api/uploads/{uuid}`).
|
||||
* Set after successful `POST /api/uploads` with purpose `communityAvatar`.
|
||||
*/
|
||||
communityAvatarUrl?: string;
|
||||
/** Selected chip ids from `community-size` (MultiSelect). */
|
||||
selectedCommunitySizeIds?: string[];
|
||||
/** Selected chip ids from `community-structure` (organization types). */
|
||||
@@ -128,6 +144,14 @@ export interface CreateFlowState {
|
||||
selectedDecisionApproachIds?: string[];
|
||||
/** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.customRule.conflictManagement` presets. */
|
||||
selectedConflictManagementIds?: string[];
|
||||
/**
|
||||
* After **Confirm** on a method card step (`communication-methods`, etc.)
|
||||
* with ≥1 selection, reorder UI with selected cards first until the pin is
|
||||
* cleared by an empty selection (or resetting custom-rule state).
|
||||
*/
|
||||
methodSectionsPinCommitted?: Partial<
|
||||
Record<CreateFlowMethodCardFacetSection, boolean>
|
||||
>;
|
||||
/**
|
||||
* User edits from the `final-review` edit modal, keyed by preset method id
|
||||
* (e.g. `"signal"`). Merged onto preset defaults at publish time so the
|
||||
@@ -144,6 +168,19 @@ export interface CreateFlowState {
|
||||
string,
|
||||
ConflictManagementDetailEntry
|
||||
>;
|
||||
/**
|
||||
* Labels for user-authored method cards (UUID ids) added via the custom-method-card wizard.
|
||||
* Preset rows resolve from messages JSON; these entries supply title/support for publish + final-review.
|
||||
*/
|
||||
customMethodCardMetaById?: Record<
|
||||
string,
|
||||
{ label: string; supportText: string }
|
||||
>;
|
||||
/**
|
||||
* Custom data-field templates authored in the custom-method-card wizard (step 3).
|
||||
* Keyed by the same UUID as `customMethodCardMetaById` for that card.
|
||||
*/
|
||||
customMethodCardFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>;
|
||||
/**
|
||||
* Set when a user picks a template (Customize or Use without changes) before
|
||||
* completing the community stage. The community-review screen consumes this
|
||||
@@ -173,6 +210,11 @@ export interface CreateFlowState {
|
||||
* `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL.
|
||||
*/
|
||||
templateReviewEntryFromCreateFlow?: boolean;
|
||||
/**
|
||||
* When set, **Finalize** and signed-in **Save & Exit** update this published
|
||||
* rule (PATCH) instead of POSTing a new rule or only saving a draft.
|
||||
*/
|
||||
editingPublishedRuleId?: string;
|
||||
currentStep?: CreateFlowStep;
|
||||
/** Section drafts; structure will tighten as steps persist real shapes. */
|
||||
sections?: Record<string, unknown>[];
|
||||
@@ -190,8 +232,15 @@ export interface CreateFlowContextValue {
|
||||
state: CreateFlowState;
|
||||
currentStep: CreateFlowStep | null;
|
||||
updateState: (_updates: Partial<CreateFlowState>) => void;
|
||||
/** Replace entire flow state (e.g. hydrate from server draft). */
|
||||
replaceState: (_next: CreateFlowState) => void;
|
||||
/**
|
||||
* Replace entire flow state (e.g. hydrate from server draft), or compute the
|
||||
* next state from the previous snapshot (atomic read-modify-write).
|
||||
*/
|
||||
replaceState: (
|
||||
_next:
|
||||
| CreateFlowState
|
||||
| ((_prev: CreateFlowState) => CreateFlowState),
|
||||
) => void;
|
||||
/** Reset flow state and clear anonymous localStorage draft keys when present. */
|
||||
clearState: () => void;
|
||||
/**
|
||||
@@ -202,6 +251,14 @@ export interface CreateFlowContextValue {
|
||||
* after a prior "Customize template" prefill.
|
||||
*/
|
||||
resetCustomRuleSelections: () => void;
|
||||
/**
|
||||
* Mark whether a facet method stack should pin the author’s selections to
|
||||
* the head of expanded + compact order (set from the footer Confirm).
|
||||
*/
|
||||
setMethodSectionsPinCommitted: (
|
||||
section: CreateFlowMethodCardFacetSection,
|
||||
committed: boolean,
|
||||
) => void;
|
||||
/**
|
||||
* True after the user has edited any control inside the wizard. Screens flip
|
||||
* it via {@link markCreateFlowInteraction} from their event handlers.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CreateFlowState } from "../types";
|
||||
import { migrateLegacyCreateFlowState } from "../../../../lib/create/migrateLegacyCreateFlowState";
|
||||
import { clearPendingCommunityAvatarFile } from "../../../../lib/create/pendingCommunityAvatarUpload";
|
||||
|
||||
/** Anonymous in-progress create flow (local only until magic-link transfer). */
|
||||
export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const;
|
||||
@@ -53,6 +54,7 @@ export function clearAnonymousCreateFlowStorage(): void {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
void clearPendingCommunityAvatarFile();
|
||||
}
|
||||
|
||||
export function setTransferPendingFlag(): void {
|
||||
|
||||
@@ -4,17 +4,13 @@ import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorag
|
||||
/**
|
||||
* Wipe the anonymous in-progress create-flow draft from `localStorage` (both
|
||||
* the main `create-flow-anonymous` blob and the separate core-value details
|
||||
* key). Intended for call sites that navigate **into** the create flow from
|
||||
* outside and want a fresh slate — today that's the marketing "Popular
|
||||
* templates" click handler on the home page and the `/templates` index page
|
||||
* (when not in-flow). `CreateFlowProvider` reads `localStorage` during its
|
||||
* `useState` initializer, so clearing *before* pushing the next route means
|
||||
* the provider mounts empty and the Create Community stage starts clean.
|
||||
* key). Clearing *before* `router.push` means `CreateFlowProvider` can read
|
||||
* empty storage on mount.
|
||||
*
|
||||
* Note: this only touches localStorage. It does **not** delete the
|
||||
* authenticated user's server draft (`/api/drafts/me`). Server drafts are
|
||||
* loaded deliberately from the profile page, not re-hydrated into the flow
|
||||
* on every entry, so there's nothing to wipe here for signed-in users.
|
||||
* For marketing/profile “new rule” entry that should also remove the signed-in
|
||||
* server draft when backend sync is on, use {@link prepareFreshCreateFlowEntry}.
|
||||
*
|
||||
* This helper only touches `localStorage`; it does **not** `DELETE /api/drafts/me`.
|
||||
*/
|
||||
export function clearCreateFlowPersistedDrafts(): void {
|
||||
clearAnonymousCreateFlowStorage();
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Central `/create/...` path builders (Linear CR-92 §2).
|
||||
* Prefer these over string literals so layout, redirects, hooks, and tests stay aligned.
|
||||
*/
|
||||
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import { CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "./flowSteps";
|
||||
|
||||
export const CREATE_ROUTES = {
|
||||
root: "/",
|
||||
createRoot: "/create",
|
||||
/** First step resolves via redirect from `/create`. */
|
||||
createFirstStep: "/create",
|
||||
review: "/create/review",
|
||||
finalReview: "/create/final-review",
|
||||
completed: "/create/completed",
|
||||
editRule: "/create/edit-rule",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Post-login return and session-gate paths on wizard steps.
|
||||
* (Also used when `pathname` is unknown but `syncDraft` must be appended.)
|
||||
*/
|
||||
export const CREATE_FLOW_SYNC_DRAFT_QUERY = "syncDraft" as const;
|
||||
export const CREATE_FLOW_SYNC_DRAFT_VALUE = "1" as const;
|
||||
|
||||
export function createFlowStepPathWithSyncDraft(step: CreateFlowStep): string {
|
||||
return createFlowStepPath(step, {
|
||||
[CREATE_FLOW_SYNC_DRAFT_QUERY]: CREATE_FLOW_SYNC_DRAFT_VALUE,
|
||||
});
|
||||
}
|
||||
|
||||
export type CreateFlowPathQuery = Record<
|
||||
string,
|
||||
string | number | boolean | undefined
|
||||
>;
|
||||
|
||||
/**
|
||||
* Path for a wizard step: `/create/{screenId}` with optional query string.
|
||||
*/
|
||||
export function createFlowStepPath(
|
||||
step: CreateFlowStep,
|
||||
query?: CreateFlowPathQuery,
|
||||
): string {
|
||||
const base = `/create/${step}`;
|
||||
if (query == null || Object.keys(query).length === 0) return base;
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v === undefined) continue;
|
||||
sp.set(k, String(v));
|
||||
}
|
||||
const q = sp.toString();
|
||||
return q.length > 0 ? `${base}?${q}` : base;
|
||||
}
|
||||
|
||||
export function createCompletedPath(query?: CreateFlowPathQuery): string {
|
||||
return createFlowStepPath("completed", query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate back from a facet step to final-review / edit-rule, dropping
|
||||
* `reviewReturn` from the current query while preserving other params.
|
||||
*/
|
||||
export function createFlowStepPathAfterStrippingReviewReturn(
|
||||
step: CreateFlowStep,
|
||||
searchParams: URLSearchParams | null | undefined,
|
||||
): string {
|
||||
const params = new URLSearchParams(searchParams?.toString() ?? "");
|
||||
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
|
||||
const query: CreateFlowPathQuery = {};
|
||||
params.forEach((value, key) => {
|
||||
query[key] = value;
|
||||
});
|
||||
return createFlowStepPath(
|
||||
step,
|
||||
Object.keys(query).length > 0 ? query : undefined,
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,9 @@ if (PROPORTION_BY_STEP_INDEX.length !== FLOW_STEP_ORDER.length) {
|
||||
export function getProportionBarProgressForCreateFlowStep(
|
||||
step: CreateFlowStep | null | undefined,
|
||||
): ProportionBarState {
|
||||
if (step === "edit-rule") {
|
||||
return "3-2";
|
||||
}
|
||||
const idx = getStepIndex(step);
|
||||
if (idx < 0) return "1-0";
|
||||
return PROPORTION_BY_STEP_INDEX[idx] ?? "1-0";
|
||||
|
||||
@@ -129,9 +129,15 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
|
||||
messageNamespace: "create.reviewAndComplete.finalReview",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"edit-rule": {
|
||||
layoutKind: "review",
|
||||
figmaNodeId: "20907-212767",
|
||||
messageNamespace: "create.reviewAndComplete.finalReview",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
completed: {
|
||||
layoutKind: "completed",
|
||||
figmaNodeId: "20907-213286",
|
||||
figmaNodeId: "20907-213288",
|
||||
messageNamespace: "create.reviewAndComplete.completed",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { CreateFlowState, CreateFlowStep } from "../types";
|
||||
import { CUSTOM_RULE_FACETS } from "../../../../lib/create/customRuleFacets";
|
||||
import type {
|
||||
CreateFlowMethodCardFacetSection,
|
||||
CreateFlowState,
|
||||
CreateFlowStep,
|
||||
} from "../types";
|
||||
import type footerMessages from "../../../../messages/en/create/footer.json";
|
||||
|
||||
type FooterMessageKey = keyof typeof footerMessages;
|
||||
@@ -7,9 +12,9 @@ type FooterMessageKey = keyof typeof footerMessages;
|
||||
* Binding for each Custom Rule stage step whose footer primary button
|
||||
* gates the user on "has at least one chip selected?". All five screens
|
||||
* render the same `<Button …>`; only the disable predicate and the
|
||||
* footer message differ — this table is the single source of truth for
|
||||
* both, so `CreateFlowLayoutClient` can render one JSX block for the
|
||||
* whole group.
|
||||
* footer message differ — rows are derived from {@link CUSTOM_RULE_FACETS}
|
||||
* (Linear CR-92) so `CreateFlowLayoutClient` stays aligned with template
|
||||
* prefill, strip keys, and API section ids.
|
||||
*
|
||||
* `selectionIds` returns the currently-selected ids array from flow
|
||||
* state for that step (empty array when nothing has been selected or
|
||||
@@ -35,35 +40,27 @@ export type CustomRuleConfirmFooterStep = {
|
||||
};
|
||||
|
||||
export const CUSTOM_RULE_CONFIRM_FOOTER_STEPS: readonly CustomRuleConfirmFooterStep[] =
|
||||
[
|
||||
{
|
||||
step: "core-values",
|
||||
footerMessageKey: "confirmCoreValues",
|
||||
selectionIds: (s) => s.selectedCoreValueIds ?? [],
|
||||
},
|
||||
{
|
||||
step: "communication-methods",
|
||||
footerMessageKey: "confirmCommunication",
|
||||
selectionIds: (s) => s.selectedCommunicationMethodIds ?? [],
|
||||
},
|
||||
{
|
||||
step: "membership-methods",
|
||||
footerMessageKey: "confirmMembership",
|
||||
selectionIds: (s) => s.selectedMembershipMethodIds ?? [],
|
||||
},
|
||||
{
|
||||
step: "decision-approaches",
|
||||
footerMessageKey: "confirmDecisionApproaches",
|
||||
selectionIds: (s) => s.selectedDecisionApproachIds ?? [],
|
||||
},
|
||||
{
|
||||
step: "conflict-management",
|
||||
footerMessageKey: "confirmConflictManagement",
|
||||
selectionIds: (s) => s.selectedConflictManagementIds ?? [],
|
||||
},
|
||||
] as const;
|
||||
CUSTOM_RULE_FACETS.filter((r) => r.footerMessageKey != null).map((r) => ({
|
||||
step: r.createFlowStep as CustomRuleConfirmFooterStep["step"],
|
||||
footerMessageKey: r.footerMessageKey as FooterMessageKey,
|
||||
selectionIds: r.selectionIds,
|
||||
}));
|
||||
|
||||
export const CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP: ReadonlyMap<
|
||||
CreateFlowStep,
|
||||
CustomRuleConfirmFooterStep
|
||||
> = new Map(CUSTOM_RULE_CONFIRM_FOOTER_STEPS.map((e) => [e.step, e]));
|
||||
|
||||
/**
|
||||
* Map a custom-rule Confirm footer step to the facet-backed method-card section
|
||||
* (core values omit — chip MultiSelect uses a different ordering model).
|
||||
*/
|
||||
export function methodCardFacetSectionForConfirmStep(
|
||||
step: CustomRuleConfirmFooterStep["step"],
|
||||
): CreateFlowMethodCardFacetSection | undefined {
|
||||
const row = CUSTOM_RULE_FACETS.find((r) => r.createFlowStep === step);
|
||||
if (row == null || row.kind !== "method" || row.apiMethodSectionId == null) {
|
||||
return undefined;
|
||||
}
|
||||
return row.apiMethodSectionId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { TemplateFacetGroupKey } from "../../../../lib/create/templateReviewMapping";
|
||||
import { createFlowStepForCustomRuleFacetGroup } from "../../../../lib/create/customRuleFacets";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
|
||||
/**
|
||||
* Custom-rule URL segment for a final-review category row (`+` navigation).
|
||||
* Source: {@link CUSTOM_RULE_FACETS} (CR-92).
|
||||
*/
|
||||
export function createFlowStepForFacetGroup(
|
||||
groupKey: TemplateFacetGroupKey,
|
||||
): CreateFlowStep {
|
||||
return createFlowStepForCustomRuleFacetGroup(groupKey);
|
||||
}
|
||||
@@ -31,9 +31,13 @@ export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Valid step IDs for the create flow (for validation)
|
||||
* Valid URL segments for `/create/[screenId]` (includes branch-only `edit-rule`).
|
||||
* Linear order for navigation remains {@link FLOW_STEP_ORDER}.
|
||||
*/
|
||||
export const VALID_STEPS: readonly CreateFlowStep[] = FLOW_STEP_ORDER;
|
||||
export const VALID_STEPS: readonly CreateFlowStep[] = [
|
||||
...FLOW_STEP_ORDER,
|
||||
"edit-rule",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* First step in the flow (entry point)
|
||||
@@ -113,6 +117,26 @@ export function getStepIndex(step: CreateFlowStep | null | undefined): number {
|
||||
return FLOW_STEP_ORDER.indexOf(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps where below `lg` the main column scrolls with split layout
|
||||
* (`CreateFlowLayoutClient` — Linear CR-92 §4).
|
||||
*/
|
||||
export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [
|
||||
"community-size",
|
||||
"community-structure",
|
||||
"core-values",
|
||||
"decision-approaches",
|
||||
] as const;
|
||||
|
||||
export function createFlowStepUsesSelectSplitScroll(
|
||||
step: CreateFlowStep | null | undefined,
|
||||
): boolean {
|
||||
if (!step) return false;
|
||||
return (CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS as readonly string[]).includes(
|
||||
step,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given string is a valid create flow step
|
||||
*/
|
||||
@@ -149,6 +173,32 @@ export function parseCreateFlowScreenFromPathname(
|
||||
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
|
||||
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
|
||||
|
||||
/**
|
||||
* Only set from `/create/review` “Create from template” with `fromFlow=1`.
|
||||
* Enables facet-ranked `GET /api/templates` + “RECOMMENDED” on the grid; omit
|
||||
* on profile and marketing so stale localStorage facets never show badges.
|
||||
*/
|
||||
export const TEMPLATES_FACET_RECOMMEND_QUERY = "recommendTemplates" as const;
|
||||
export const TEMPLATES_FACET_RECOMMEND_VALUE = "1" as const;
|
||||
|
||||
/** `/create/completed?celebrate=1` — post-finalize toast; set only after **initial** POST publish, not PATCH updates. */
|
||||
export const CREATE_FLOW_COMPLETED_CELEBRATE_QUERY = "celebrate" as const;
|
||||
export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const;
|
||||
|
||||
/** `/create/{step}?reviewReturn=…` — set when opening a custom-rule step from final-review or edit-rule via + */
|
||||
export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const;
|
||||
|
||||
export type CreateFlowReviewReturnTarget = "final-review" | "edit-rule";
|
||||
|
||||
export function parseReviewReturnSearchParam(
|
||||
searchParams: { get: (name: string) => string | null } | null | undefined,
|
||||
): CreateFlowReviewReturnTarget | null {
|
||||
if (!searchParams) return null;
|
||||
const raw = searchParams.get(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
|
||||
if (raw === "final-review" || raw === "edit-rule") return raw;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `/create/review-template/{slug}` with optional marker so chrome can send
|
||||
* footer Back to `/create/review` instead of marketing home.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { deleteServerDraft } from "../../../../lib/create/api";
|
||||
import { clearAnonymousCreateFlowStorage } from "./anonymousDraftStorage";
|
||||
import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorage";
|
||||
|
||||
const SYNC_ENABLED =
|
||||
process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||
|
||||
/**
|
||||
* Call **before** navigating into `/create` from marketing or profile “new rule”
|
||||
* entry points so signed-in + sync matches an anonymous fresh start: wipe
|
||||
* `localStorage` draft keys and, when sync is on, `DELETE /api/drafts/me`.
|
||||
* Anonymous `DELETE` is harmless (401). Await ensures the server draft is gone
|
||||
* before mount so {@link SignedInDraftHydration} does not rehydrate stale work.
|
||||
*
|
||||
* Do **not** use for “Continue draft” — that path should load the server draft.
|
||||
*/
|
||||
export async function prepareFreshCreateFlowEntry(): Promise<void> {
|
||||
clearAnonymousCreateFlowStorage();
|
||||
clearCoreValueDetailsLocalStorage();
|
||||
if (SYNC_ENABLED) {
|
||||
await deleteServerDraft();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CREATE_ROUTES } from "./createFlowPaths";
|
||||
|
||||
export type CompletedStepExitRouter = { push: (_href: string) => void };
|
||||
|
||||
/**
|
||||
* Leaving `/create/completed` (post-publish shell or managing a rule from profile).
|
||||
*
|
||||
* Clears wizard client state only. Does **not** `DELETE /api/drafts/me` — the stored
|
||||
* draft may be unrelated in-progress work for another rule (one `RuleDraft`
|
||||
* row per authenticated user).
|
||||
*/
|
||||
export function runCompletedStepExit(opts: {
|
||||
clearState: () => void;
|
||||
clearAnonymousCreateFlowStorage: () => void;
|
||||
router: CompletedStepExitRouter;
|
||||
}): void {
|
||||
opts.clearState();
|
||||
opts.clearAnonymousCreateFlowStorage();
|
||||
opts.router.push(CREATE_ROUTES.root);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
} from "../create/utils/flowSteps";
|
||||
import type { CreateFlowStep } from "../create/types";
|
||||
import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage";
|
||||
import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage";
|
||||
import { prepareFreshCreateFlowEntry } from "../create/utils/prepareFreshCreateFlowEntry";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import {
|
||||
ProfilePageSignedOutView,
|
||||
@@ -245,9 +247,18 @@ export default function ProfilePageClient() {
|
||||
const handleContinueDraft = useCallback(() => {
|
||||
if (draft == null || !draft.hasDraft) return;
|
||||
const step = resolveContinueStepState(draft.state);
|
||||
clearAnonymousCreateFlowStorage();
|
||||
clearCoreValueDetailsLocalStorage();
|
||||
router.push(`/create/${step}`);
|
||||
}, [draft, router]);
|
||||
|
||||
const handleStartNewCustomRule = useCallback(() => {
|
||||
void (async () => {
|
||||
await prepareFreshCreateFlowEntry();
|
||||
router.push("/create");
|
||||
})();
|
||||
}, [router]);
|
||||
|
||||
const handleRequestDeleteDraft = useCallback(() => {
|
||||
setActionError(null);
|
||||
setDraftDeleteOpen(true);
|
||||
@@ -360,6 +371,7 @@ export default function ProfilePageClient() {
|
||||
}}
|
||||
onCloseDeleteAccount={() => setAccountDeleteOpen(false)}
|
||||
onConfirmDeleteAccount={handleConfirmDeleteAccount}
|
||||
onStartNewCustomRule={handleStartNewCustomRule}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ export type ProfilePageViewProps = {
|
||||
onDismissProfileSuccess: () => void;
|
||||
onDismissActionError: () => void;
|
||||
onDismissRulesError: () => void;
|
||||
/** Clears local + server draft (when sync) then routes to `/create` — same fresh start as marketing “Create rule”. */
|
||||
onStartNewCustomRule: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -81,13 +83,6 @@ export type ProfilePageViewProps = {
|
||||
const profileSectionHeadingClass =
|
||||
"font-bricolage text-base font-bold leading-[22px] text-[var(--color-content-default-primary)] md:font-inter md:text-xl md:font-bold md:leading-7 xl:font-bricolage-grotesque xl:font-bold xl:text-[28px] xl:leading-9";
|
||||
|
||||
/**
|
||||
* Sticky `top` for page content below the product {@link Top} (standard variant).
|
||||
* Must match `Top.view.tsx`: nav `h` 40px → `lg` 84px → `xl` 88px, plus `header` `border-b` (+1px).
|
||||
*/
|
||||
const stickyBelowTopTopClass =
|
||||
"top-[41px] lg:top-[85px] xl:top-[89px]";
|
||||
|
||||
export type ProfilePageSignedOutViewProps = {
|
||||
onSignIn: () => void;
|
||||
/** `min-width: 1024px` — welcome uses {@link HeaderLockup} `L` per Figma `21962:17220`. */
|
||||
@@ -111,8 +106,8 @@ export function ProfilePageSignedOutView({
|
||||
<header
|
||||
className={
|
||||
profileLgUp
|
||||
? `sticky z-10 bg-[var(--color-surface-default-primary)] ${stickyBelowTopTopClass}`
|
||||
: `flex flex-col gap-1 py-3 md:sticky md:top-[41px] md:z-10 md:bg-[var(--color-surface-default-primary)]`
|
||||
? "sticky top-0 z-10 bg-[var(--color-surface-default-primary)]"
|
||||
: "flex flex-col gap-1 py-3 md:sticky md:top-0 md:z-10 md:bg-[var(--color-surface-default-primary)]"
|
||||
}
|
||||
>
|
||||
{profileLgUp ? (
|
||||
@@ -199,6 +194,7 @@ export function ProfilePageView({
|
||||
onDismissProfileSuccess,
|
||||
onDismissActionError,
|
||||
onDismissRulesError,
|
||||
onStartNewCustomRule,
|
||||
}: ProfilePageViewProps) {
|
||||
const t = useTranslation("pages.profile");
|
||||
const tLogin = useTranslation("pages.login");
|
||||
@@ -213,7 +209,7 @@ export function ProfilePageView({
|
||||
id: "create-custom",
|
||||
title: t("optionCreateCustom"),
|
||||
description: "",
|
||||
href: "/create",
|
||||
onClick: onStartNewCustomRule,
|
||||
leadingIcon: "edit",
|
||||
showDescription: false,
|
||||
},
|
||||
@@ -251,7 +247,7 @@ export function ProfilePageView({
|
||||
showDescription: false,
|
||||
},
|
||||
];
|
||||
}, [t, onSignOut, onOpenDeleteAccount, onOpenEmailChange]);
|
||||
}, [t, onSignOut, onOpenDeleteAccount, onOpenEmailChange, onStartNewCustomRule]);
|
||||
|
||||
const ruleCardShellClass =
|
||||
"w-full !max-w-full cursor-default !gap-3 !rounded-[12px] shadow-[0_0_48px_rgba(0,0,0,0.1)] lg:!rounded-[24px] lg:shadow-[0_0_24px_rgba(0,0,0,0.1)]";
|
||||
@@ -263,8 +259,8 @@ export function ProfilePageView({
|
||||
<header
|
||||
className={
|
||||
profileLgUp
|
||||
? `lg:sticky lg:z-10 lg:bg-[var(--color-surface-default-primary)] lg:top-[85px] xl:top-[89px]`
|
||||
: `flex flex-col gap-1 py-3 md:sticky md:top-[41px] md:z-10 md:bg-[var(--color-surface-default-primary)]`
|
||||
? "lg:sticky lg:top-0 lg:z-10 lg:bg-[var(--color-surface-default-primary)]"
|
||||
: "flex flex-col gap-1 py-3 md:sticky md:top-0 md:z-10 md:bg-[var(--color-surface-default-primary)]"
|
||||
}
|
||||
>
|
||||
{profileLgUp ? (
|
||||
@@ -348,6 +344,11 @@ export function ProfilePageView({
|
||||
{
|
||||
id: "view",
|
||||
label: t("viewPublic"),
|
||||
href: `/rules/${encodeURIComponent(rule.id)}`,
|
||||
},
|
||||
{
|
||||
id: "manage",
|
||||
label: t("manageRule"),
|
||||
href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
|
||||
import { parseDocumentSectionsForDisplay } from "../../../../lib/create/buildPublishPayload";
|
||||
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
|
||||
import CommunityRule from "../../../components/type/CommunityRule";
|
||||
import HeaderLockup from "../../../components/type/HeaderLockup";
|
||||
|
||||
@@ -49,7 +49,7 @@ export default async function PublicRuleDetailPage({ params }: PageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const sections = parseDocumentSectionsForDisplay(rule.document);
|
||||
const sections = parsePublishedDocumentForCommunityRuleDisplay(rule.document);
|
||||
const description =
|
||||
typeof rule.summary === "string" && rule.summary.trim().length > 0
|
||||
? rule.summary
|
||||
|
||||
@@ -5,9 +5,14 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
||||
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
||||
import { clearCreateFlowPersistedDrafts } from "../../(app)/create/utils/clearCreateFlowPersistedDrafts";
|
||||
import { buildTemplateReviewHref } from "../../(app)/create/utils/flowSteps";
|
||||
import { prepareFreshCreateFlowEntry } from "../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||
import {
|
||||
buildTemplateReviewHref,
|
||||
TEMPLATES_FACET_RECOMMEND_QUERY,
|
||||
TEMPLATES_FACET_RECOMMEND_VALUE,
|
||||
} from "../../(app)/create/utils/flowSteps";
|
||||
import { useTranslation } from "../../contexts/MessagesContext";
|
||||
import { useTemplatesFacetGridEntries } from "./useTemplatesFacetGridEntries";
|
||||
|
||||
export interface TemplatesPageClientProps {
|
||||
initialGridEntries: TemplateGridCardEntry[];
|
||||
@@ -44,9 +49,16 @@ export default function TemplatesPageClient({
|
||||
{/* Suspense boundary required by `useSearchParams` below
|
||||
(Next.js 15+ static-generation contract). */}
|
||||
<Suspense
|
||||
fallback={<TemplatesGrid entries={initialGridEntries} fromFlow={false} />}
|
||||
fallback={
|
||||
<TemplatesGrid
|
||||
entries={initialGridEntries}
|
||||
fromFlow={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TemplatesGridWithSearchParams entries={initialGridEntries} />
|
||||
<TemplatesGridWithSearchParams
|
||||
initialGridEntries={initialGridEntries}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,18 +67,25 @@ export default function TemplatesPageClient({
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `fromFlow=1` off the URL so we can skip the fresh-slate clear when
|
||||
* the user arrived from `/create/review`'s "Create from template" button.
|
||||
* That button pushes `/templates?fromFlow=1` so their in-progress community
|
||||
* stage is preserved when they pick a template here.
|
||||
* - `fromFlow=1` — skip `prepareFreshCreateFlowEntry` on template click
|
||||
* (draft preserved). Used by review “Create from template” and profile.
|
||||
* - `recommendTemplates=1` (with review only) — rank templates + “RECOMMENDED”
|
||||
* from `GET /api/templates?facet.*` using the persisted community draft.
|
||||
*/
|
||||
function TemplatesGridWithSearchParams({
|
||||
entries,
|
||||
initialGridEntries,
|
||||
}: {
|
||||
entries: TemplateGridCardEntry[];
|
||||
initialGridEntries: TemplateGridCardEntry[];
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const fromFlow = searchParams.get("fromFlow") === "1";
|
||||
const enableFacetRecommendations =
|
||||
searchParams.get(TEMPLATES_FACET_RECOMMEND_QUERY) ===
|
||||
TEMPLATES_FACET_RECOMMEND_VALUE;
|
||||
const entries = useTemplatesFacetGridEntries({
|
||||
initialGridEntries,
|
||||
enableFacetRecommendations,
|
||||
});
|
||||
return <TemplatesGrid entries={entries} fromFlow={fromFlow} />;
|
||||
}
|
||||
|
||||
@@ -83,11 +102,13 @@ function TemplatesGrid({
|
||||
entries={entries}
|
||||
onTemplateClick={(slug) => {
|
||||
if (!fromFlow) {
|
||||
// Direct entry to `/templates`: treat template click as a fresh
|
||||
// create-flow start and wipe any stale anonymous draft before
|
||||
// navigating. In-flow entry (`?fromFlow=1`) skips the clear so
|
||||
// the user's community stage survives the detour through here.
|
||||
clearCreateFlowPersistedDrafts();
|
||||
void (async () => {
|
||||
await prepareFreshCreateFlowEntry();
|
||||
router.push(
|
||||
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
|
||||
);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
router.push(
|
||||
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { readAnonymousCreateFlowState } from "../../(app)/create/utils/anonymousDraftStorage";
|
||||
import { buildFacetQueryString } from "../../../lib/create/buildFacetQueryString";
|
||||
import {
|
||||
fetchRankedTemplatesByFacets,
|
||||
isTemplatesFetchAborted,
|
||||
} from "../../../lib/create/fetchTemplates";
|
||||
import {
|
||||
gridEntriesWithFacetScores,
|
||||
type TemplateGridCardEntry,
|
||||
} from "../../../lib/templates/templateGridPresentation";
|
||||
|
||||
type UseTemplatesFacetGridEntriesArgs = {
|
||||
initialGridEntries: TemplateGridCardEntry[];
|
||||
enableFacetRecommendations: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* When `enableFacetRecommendations` (review → “Create from template” only),
|
||||
* re-fetch ranked templates from `GET /api/templates?facet.*` using the
|
||||
* persisted create-flow draft. Otherwise returns `initialGridEntries` from SSR.
|
||||
*/
|
||||
export function useTemplatesFacetGridEntries({
|
||||
initialGridEntries,
|
||||
enableFacetRecommendations,
|
||||
}: UseTemplatesFacetGridEntriesArgs): TemplateGridCardEntry[] {
|
||||
const [entries, setEntries] = useState(initialGridEntries);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableFacetRecommendations) {
|
||||
setEntries(initialGridEntries);
|
||||
return;
|
||||
}
|
||||
const state = readAnonymousCreateFlowState();
|
||||
const facetQuery = buildFacetQueryString(state);
|
||||
if (facetQuery.length === 0) {
|
||||
setEntries(initialGridEntries);
|
||||
return;
|
||||
}
|
||||
|
||||
const ac = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await fetchRankedTemplatesByFacets({
|
||||
signal: ac.signal,
|
||||
facetQuery,
|
||||
});
|
||||
if (ac.signal.aborted) return;
|
||||
if ("error" in result) {
|
||||
setEntries(initialGridEntries);
|
||||
return;
|
||||
}
|
||||
setEntries(
|
||||
gridEntriesWithFacetScores(result.templates, result.scores),
|
||||
);
|
||||
} catch (e) {
|
||||
if (isTemplatesFetchAborted(e)) return;
|
||||
setEntries(initialGridEntries);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
ac.abort();
|
||||
};
|
||||
}, [enableFacetRecommendations, initialGridEntries]);
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/server/db";
|
||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||
import {
|
||||
@@ -10,6 +11,9 @@ import {
|
||||
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
|
||||
import { getSessionUser } from "../../../../lib/server/session";
|
||||
import { apiRoute } from "../../../../lib/server/apiRoute";
|
||||
import { publishRuleBodySchema } from "../../../../lib/server/validation/createFlowSchemas";
|
||||
import { readLimitedJson } from "../../../../lib/server/validation/requestBody";
|
||||
import { jsonFromZodError } from "../../../../lib/server/validation/zodHttp";
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -41,6 +45,56 @@ export const GET = apiRoute<RouteContext>(
|
||||
},
|
||||
);
|
||||
|
||||
export const PATCH = apiRoute<RouteContext>(
|
||||
"rules.byId.patch",
|
||||
async (request: NextRequest, context) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
const row = await prisma.publishedRule.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
if (!row) {
|
||||
return notFound();
|
||||
}
|
||||
if (row.userId !== user.id) {
|
||||
return forbidden("You do not have permission to update this rule");
|
||||
}
|
||||
|
||||
const parsedBody = await readLimitedJson(request);
|
||||
if (parsedBody.ok === false) {
|
||||
return parsedBody.response;
|
||||
}
|
||||
|
||||
const validated = publishRuleBodySchema.safeParse(parsedBody.value);
|
||||
if (!validated.success) {
|
||||
return jsonFromZodError(validated.error);
|
||||
}
|
||||
|
||||
const { title, summary, document } = validated.data;
|
||||
|
||||
await prisma.publishedRule.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
title,
|
||||
summary,
|
||||
document: document as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiRoute<RouteContext>(
|
||||
"rules.byId.delete",
|
||||
async (_request, context) => {
|
||||
|
||||
@@ -20,8 +20,9 @@ export const GET = apiRoute("rules.list", async (request: NextRequest) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
|
||||
|
||||
/** Public catalog: mirror profile “my rules” recency semantics (last touched first). */
|
||||
const rules = await prisma.publishedRule.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -9,8 +9,9 @@ import { parseRequestedFacetsFromSearchParams } from "../../../lib/server/valida
|
||||
*
|
||||
* No params → curated ordering (`featured` desc, `sortOrder` asc, `title`
|
||||
* asc). With `facet.<group>=<value>` query params (repeatable per group),
|
||||
* templates are re-ranked by composed-method match count; ties fall back to
|
||||
* the curated order, score-0 templates remain at the end.
|
||||
* templates are re-ranked by `TemplateFacet` matrix match when seeded for
|
||||
* that slug, else by composed-method × `MethodFacet` match count; ties fall
|
||||
* back to the curated order, score-0 templates remain at the end.
|
||||
*
|
||||
* See `docs/guides/template-recommendation-matrix.md` §9.1.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiRoute } from "../../../../lib/server/apiRoute";
|
||||
import {
|
||||
notFound,
|
||||
serverMisconfigured,
|
||||
} from "../../../../lib/server/responses";
|
||||
import { resolveUploadedFileById } from "../../../../lib/server/uploads/resolveUploadedFile";
|
||||
import { getUploadRootFromEnv } from "../../../../lib/server/uploads/uploadRoot";
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
/**
|
||||
* Public read for opaque upload ids (no auth). Unguessable UUID stem;
|
||||
* do not use for sensitive documents without revisiting policy.
|
||||
*/
|
||||
export const GET = apiRoute<RouteContext>(
|
||||
"uploads.byId",
|
||||
async (_request, context) => {
|
||||
if (!getUploadRootFromEnv()) {
|
||||
return serverMisconfigured(
|
||||
"File uploads are not configured (UPLOAD_ROOT is unset).",
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const resolved = await resolveUploadedFileById(id);
|
||||
if (!resolved) {
|
||||
return notFound("Upload not found");
|
||||
}
|
||||
|
||||
const body = await readFile(resolved.absolutePath);
|
||||
return new NextResponse(new Uint8Array(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": resolved.contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||
import {
|
||||
dbUnavailable,
|
||||
errorJson,
|
||||
serverMisconfigured,
|
||||
unauthorized,
|
||||
rateLimited,
|
||||
} from "../../../lib/server/responses";
|
||||
import { getSessionUser } from "../../../lib/server/session";
|
||||
import { apiRoute } from "../../../lib/server/apiRoute";
|
||||
import { rateLimitKey } from "../../../lib/server/rateLimit";
|
||||
import { saveCreateFlowUpload } from "../../../lib/server/uploads/saveCreateFlowUpload";
|
||||
import { getUploadRootFromEnv } from "../../../lib/server/uploads/uploadRoot";
|
||||
import {
|
||||
CREATE_FLOW_UPLOAD_MAX_BYTES,
|
||||
type CreateFlowUploadPurpose,
|
||||
} from "../../../lib/server/uploads/uploadConstants";
|
||||
|
||||
function isPurpose(x: string): x is CreateFlowUploadPurpose {
|
||||
return x === "communityAvatar" || x === "customMethodAttachment";
|
||||
}
|
||||
|
||||
export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
if (!getUploadRootFromEnv()) {
|
||||
return serverMisconfigured(
|
||||
"File uploads are not configured (UPLOAD_ROOT is unset).",
|
||||
);
|
||||
}
|
||||
|
||||
const rl = rateLimitKey(`upload:${user.id}`, 5_000);
|
||||
if (rl.ok === false) {
|
||||
return rateLimited(rl.retryAfterMs);
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch {
|
||||
return errorJson(
|
||||
"payload_too_large",
|
||||
"Upload body is too large or malformed.",
|
||||
413,
|
||||
);
|
||||
}
|
||||
|
||||
const purposeRaw = formData.get("purpose");
|
||||
const file = formData.get("file");
|
||||
|
||||
if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) {
|
||||
return errorJson(
|
||||
"validation_error",
|
||||
"Invalid or missing `purpose` (expected communityAvatar | customMethodAttachment).",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return errorJson(
|
||||
"validation_error",
|
||||
"Missing `file` field (multipart file).",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > CREATE_FLOW_UPLOAD_MAX_BYTES) {
|
||||
return errorJson(
|
||||
"payload_too_large",
|
||||
`File exceeds maximum allowed size (${CREATE_FLOW_UPLOAD_MAX_BYTES} bytes).`,
|
||||
413,
|
||||
);
|
||||
}
|
||||
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
const mimeType = file.type || "application/octet-stream";
|
||||
|
||||
const saved = await saveCreateFlowUpload({
|
||||
purpose: purposeRaw,
|
||||
buffer: buf,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
if ("error" in saved) {
|
||||
if (saved.error === "misconfigured") {
|
||||
return serverMisconfigured(
|
||||
"File uploads are not configured (UPLOAD_ROOT is unset).",
|
||||
);
|
||||
}
|
||||
return errorJson(
|
||||
"validation_error",
|
||||
"File type or size is not allowed for this upload purpose.",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
url: saved.urlPath,
|
||||
id: saved.id,
|
||||
mimeType: saved.mimeType,
|
||||
byteLength: saved.byteLength,
|
||||
});
|
||||
});
|
||||
@@ -3,22 +3,38 @@
|
||||
import Image from "next/image";
|
||||
import { memo } from "react";
|
||||
import ArrowBackIcon from "./arrow_back.svg";
|
||||
import ChevronRightIcon from "./chevron_right.svg";
|
||||
import ContentCopyIcon from "./content_copy.svg";
|
||||
import CsvIcon from "./csv.svg";
|
||||
import CustomIcon from "./custom.svg";
|
||||
import EditIcon from "./edit.svg";
|
||||
import ExclamationIcon from "./exclamation.svg";
|
||||
import ChevronRightIcon from "./chevron_right.svg";
|
||||
import ImageGlyphIcon from "./image.svg";
|
||||
import LogOutIcon from "./log_out.svg";
|
||||
import MailIcon from "./mail.svg";
|
||||
import MarkdownCopyIcon from "./markdown_copy.svg";
|
||||
import NumberIcon from "./number.svg";
|
||||
import PictureAsPdfIcon from "./picture_as_pdf.svg";
|
||||
import TagsIcon from "./tags.svg";
|
||||
import TextBlockIcon from "./text_block.svg";
|
||||
import WarningIcon from "./warning.svg";
|
||||
|
||||
export const ICON_NAME_OPTIONS = [
|
||||
"arrow_back",
|
||||
"chevron_right",
|
||||
"content_copy",
|
||||
"csv",
|
||||
"custom",
|
||||
"edit",
|
||||
"exclamation",
|
||||
"image",
|
||||
"log_out",
|
||||
"mail",
|
||||
"markdown_copy",
|
||||
"number",
|
||||
"picture_as_pdf",
|
||||
"tags",
|
||||
"text_block",
|
||||
"warning",
|
||||
] as const;
|
||||
|
||||
@@ -33,10 +49,18 @@ const iconMap: Record<IconName, SvgComponent> = {
|
||||
arrow_back: ArrowBackIcon,
|
||||
chevron_right: ChevronRightIcon,
|
||||
content_copy: ContentCopyIcon,
|
||||
csv: CsvIcon,
|
||||
custom: CustomIcon,
|
||||
edit: EditIcon,
|
||||
exclamation: ExclamationIcon,
|
||||
image: ImageGlyphIcon,
|
||||
log_out: LogOutIcon,
|
||||
mail: MailIcon,
|
||||
markdown_copy: MarkdownCopyIcon,
|
||||
number: NumberIcon,
|
||||
picture_as_pdf: PictureAsPdfIcon,
|
||||
tags: TagsIcon,
|
||||
text_block: TextBlockIcon,
|
||||
warning: WarningIcon,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 20 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<path
|
||||
d="M3.75 11H6.75V9.5H4.25V6.5H6.75V5H3.75C3.46667 5 3.22917 5.09583 3.0375 5.2875C2.84583 5.47917 2.75 5.71667 2.75 6V10C2.75 10.2833 2.84583 10.5208 3.0375 10.7125C3.22917 10.9042 3.46667 11 3.75 11ZM7.65 11H10.65C10.9333 11 11.1708 10.9042 11.3625 10.7125C11.5542 10.5208 11.65 10.2833 11.65 10V8.5C11.65 8.21667 11.5542 7.95417 11.3625 7.7125C11.1708 7.47083 10.9333 7.35 10.65 7.35H9.15V6.5H11.65V5H8.65C8.36667 5 8.12917 5.09583 7.9375 5.2875C7.74583 5.47917 7.65 5.71667 7.65 6V7.5C7.65 7.78333 7.74583 8.0375 7.9375 8.2625C8.12917 8.4875 8.36667 8.6 8.65 8.6H10.15V9.5H7.65V11ZM14.25 11H15.75L17.5 5H16L15 8.45L14 5H12.5L14.25 11ZM2 16C1.45 16 0.979167 15.8042 0.5875 15.4125C0.195833 15.0208 0 14.55 0 14V2C0 1.45 0.195833 0.979167 0.5875 0.5875C0.979167 0.195833 1.45 0 2 0H18C18.55 0 19.0208 0.195833 19.4125 0.5875C19.8042 0.979167 20 1.45 20 2V14C20 14.55 19.8042 15.0208 19.4125 15.4125C19.0208 15.8042 18.55 16 18 16H2ZM2 14H18V2H2V14Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.3636 8.27273L16.2273 5.77273L13.7273 4.63636L16.2273 3.5L17.3636 1L18.5 3.5L21 4.63636L18.5 5.77273L17.3636 8.27273ZM17.3636 21L16.2273 18.5L13.7273 17.3636L16.2273 16.2273L17.3636 13.7273L18.5 16.2273L21 17.3636L18.5 18.5L17.3636 21ZM8.27273 18.2727L6 13.2727L1 11L6 8.72727L8.27273 3.72727L10.5455 8.72727L15.5455 11L10.5455 13.2727L8.27273 18.2727Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 484 B |
@@ -0,0 +1,11 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_19787_11971)">
|
||||
<path d="M5 3.75H19C19.6858 3.75 20.25 4.31421 20.25 5V19C20.25 19.6858 19.6858 20.25 19 20.25H5C4.31421 20.25 3.75 19.6858 3.75 19V5C3.75 4.31421 4.31421 3.75 5 3.75Z" stroke="white" stroke-width="1.5"/>
|
||||
<path d="M10.7496 15.298L9.39281 13.7098C9.18897 13.4712 8.81826 13.4772 8.62221 13.7222L6.64988 16.1877C6.38797 16.515 6.62106 17 7.04031 17H16.9828C17.398 17 17.6323 16.5233 17.3787 16.1946L14.532 12.5045C14.334 12.2477 13.9477 12.2445 13.7454 12.498L11.5205 15.2852C11.3246 15.5306 10.9536 15.5368 10.7496 15.298Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_19787_11971">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 791 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 17 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<path
|
||||
d="M6 16C5.45 16 4.97917 15.8042 4.5875 15.4125C4.19583 15.0208 4 14.55 4 14V2C4 1.45 4.19583 0.979167 4.5875 0.5875C4.97917 0.195833 5.45 0 6 0H15C15.55 0 16.0208 0.195833 16.4125 0.5875C16.8042 0.979167 17 1.45 17 2V14C17 14.55 16.8042 15.0208 16.4125 15.4125C16.0208 15.8042 15.55 16 15 16H6ZM6 14H15V2H6V14ZM2 20C1.45 20 0.979167 19.8042 0.5875 19.4125C0.195833 19.0208 0 18.55 0 18V4H2V18H13V20H2ZM7.25 11H8.75V6.5H9.75V9.5H11.25V6.5H12.25V11H13.75V6C13.75 5.71667 13.6542 5.47917 13.4625 5.2875C13.2708 5.09583 13.0333 5 12.75 5H8.25C7.96667 5 7.72917 5.09583 7.5375 5.2875C7.34583 5.47917 7.25 5.71667 7.25 6V11Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 814 B |
@@ -0,0 +1,10 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_19787_11962)">
|
||||
<path d="M20.5 9.75L21 7.75H17L18 3.75H16L15 7.75H11L12 3.75H10L9 7.75H5L4.5 9.75H8.5L7.5 13.75H3.5L3 15.75H7L6 19.75H8L9 15.75H13L12 19.75H14L15 15.75H19L19.5 13.75H15.5L16.5 9.75H20.5ZM13.5 13.75H9.5L10.5 9.75H14.5L13.5 13.75Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_19787_11962">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 498 B |
@@ -0,0 +1,19 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask
|
||||
id="picture_as_pdf_icon_mask"
|
||||
style="mask-type:alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<rect width="24" height="24" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#picture_as_pdf_icon_mask)">
|
||||
<path
|
||||
d="M9 12.5H10V10.5H11C11.2833 10.5 11.5208 10.4042 11.7125 10.2125C11.9042 10.0208 12 9.78333 12 9.5V8.5C12 8.21667 11.9042 7.97917 11.7125 7.7875C11.5208 7.59583 11.2833 7.5 11 7.5H9V12.5ZM10 9.5V8.5H11V9.5H10ZM13 12.5H15C15.2833 12.5 15.5208 12.4042 15.7125 12.2125C15.9042 12.0208 16 11.7833 16 11.5V8.5C16 8.21667 15.9042 7.97917 15.7125 7.7875C15.5208 7.59583 15.2833 7.5 15 7.5H13V12.5ZM14 11.5V8.5H15V11.5H14ZM17 12.5H18V10.5H19V9.5H18V8.5H19V7.5H17V12.5ZM8 18C7.45 18 6.97917 17.8042 6.5875 17.4125C6.19583 17.0208 6 16.55 6 16V4C6 3.45 6.19583 2.97917 6.5875 2.5875C6.97917 2.19583 7.45 2 8 2H20C20.55 2 21.0208 2.19583 21.4125 2.5875C21.8042 2.97917 22 3.45 22 4V16C22 16.55 21.8042 17.0208 21.4125 17.4125C21.0208 17.8042 20.55 18 20 18H8ZM8 16H20V4H8V16ZM4 22C3.45 22 2.97917 21.8042 2.5875 21.4125C2.19583 21.0208 2 20.55 2 20V6H4V20H18V22H4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.51299 10.462C7.32099 10.654 7.08332 10.75 6.79999 10.75C6.51665 10.75 6.27899 10.654 6.08699 10.462C5.89565 10.2707 5.79999 10.0333 5.79999 9.75C5.79999 9.46667 5.89565 9.229 6.08699 9.037C6.27899 8.84567 6.51665 8.75 6.79999 8.75C7.08332 8.75 7.32099 8.84567 7.51299 9.037C7.70432 9.229 7.79999 9.46667 7.79999 9.75C7.79999 10.0333 7.70432 10.2707 7.51299 10.462Z" fill="white"/>
|
||||
<path d="M12.8 14.75H6.79999C6.51665 14.75 6.27899 14.654 6.08699 14.462C5.89565 14.2707 5.79999 14.0333 5.79999 13.75C5.79999 13.4667 5.89565 13.229 6.08699 13.037C6.27899 12.8457 6.51665 12.75 6.79999 12.75H12.8C13.0833 12.75 13.321 12.8457 13.513 13.037C13.7043 13.229 13.8 13.4667 13.8 13.75C13.8 14.0333 13.7043 14.2707 13.513 14.462C13.321 14.654 13.0833 14.75 12.8 14.75Z" fill="white"/>
|
||||
<path d="M17.512 14.462C17.3207 14.654 17.0833 14.75 16.8 14.75C16.5167 14.75 16.2793 14.654 16.088 14.462C15.896 14.2707 15.8 14.0333 15.8 13.75C15.8 13.4667 15.896 13.229 16.088 13.037C16.2793 12.8457 16.5167 12.75 16.8 12.75C17.0833 12.75 17.3207 12.8457 17.512 13.037C17.704 13.229 17.8 13.4667 17.8 13.75C17.8 14.0333 17.704 14.2707 17.512 14.462Z" fill="white"/>
|
||||
<path d="M16.8 10.75H10.8C10.5167 10.75 10.2793 10.654 10.088 10.462C9.89599 10.2707 9.79999 10.0333 9.79999 9.75C9.79999 9.46667 9.89599 9.229 10.088 9.037C10.2793 8.84567 10.5167 8.75 10.8 8.75H16.8C17.0833 8.75 17.3207 8.84567 17.512 9.037C17.704 9.229 17.8 9.46667 17.8 9.75C17.8 10.0333 17.704 10.2707 17.512 10.462C17.3207 10.654 17.0833 10.75 16.8 10.75Z" fill="white"/>
|
||||
<path d="M3.875 4.5H19.875C20.2243 4.5 20.5052 4.61557 20.7578 4.86816C21.0095 5.11983 21.125 5.4003 21.125 5.75V17.75C21.125 18.0993 21.0092 18.3796 20.7578 18.6318C20.5053 18.8839 20.2247 19 19.875 19H3.875C3.5253 19 3.24483 18.8845 2.99316 18.6328C2.74057 18.3802 2.625 18.0993 2.625 17.75V5.75C2.625 5.40073 2.74099 5.1209 2.99316 4.86914L2.99414 4.86816C3.2459 4.61599 3.52573 4.5 3.875 4.5Z" stroke="white" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 20C3.45 20 2.97917 19.8042 2.5875 19.4125C2.19583 19.0208 2 18.55 2 18V6C2 5.45 2.19583 4.97917 2.5875 4.5875C2.97917 4.19583 3.45 4 4 4H20C20.55 4 21.0208 4.19583 21.4125 4.5875C21.8042 4.97917 22 5.45 22 6V18C22 18.55 21.8042 19.0208 21.4125 19.4125C21.0208 19.8042 20.55 20 20 20H4Z" stroke="white" stroke-width="1.5"/>
|
||||
<path d="M18 17H6C5.71667 17 5.47917 16.9042 5.2875 16.7125C5.09583 16.5208 5 16.2833 5 16C5 15.7167 5.09583 15.4792 5.2875 15.2875C5.47917 15.0958 5.71667 15 6 15H18C18.2833 15 18.5208 15.0958 18.7125 15.2875C18.9042 15.4792 19 15.7167 19 16C19 16.2833 18.9042 16.5208 18.7125 16.7125C18.5208 16.9042 18.2833 17 18 17Z" fill="white"/>
|
||||
<path d="M18 13H6C5.71667 13 5.47917 12.9042 5.2875 12.7125C5.09583 12.5208 5 12.2833 5 12C5 11.7167 5.09583 11.4792 5.2875 11.2875C5.47917 11.0958 5.71667 11 6 11H18C18.2833 11 18.5208 11.0958 18.7125 11.2875C18.9042 11.4792 19 11.7167 19 12C19 12.2833 18.9042 12.5208 18.7125 12.7125C18.5208 12.9042 18.2833 13 18 13Z" fill="white"/>
|
||||
<path d="M14 9H6C5.71667 9 5.47917 8.90417 5.2875 8.7125C5.09583 8.52083 5 8.28333 5 8C5 7.71667 5.09583 7.47917 5.2875 7.2875C5.47917 7.09583 5.71667 7 6 7H14C14.2833 7 14.5208 7.09583 14.7125 7.2875C14.9042 7.47917 15 7.71667 15 8C15 8.28333 14.9042 8.52083 14.7125 8.7125C14.5208 8.90417 14.2833 9 14 9Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -115,21 +115,21 @@ const Button = memo<ButtonProps>(
|
||||
|
||||
const variantStyles: Record<string, string> = {
|
||||
filled:
|
||||
"bg-[var(--color-surface-inverse-primary)] text-[var(--color-content-inverse-primary)] border-[1.5px] border-transparent hover:bg-[var(--color-surface-inverse-primary)] hover:text-[var(--color-content-invert-brand-primary)] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus:bg-[var(--color-surface-inverse-primary)] focus:text-[var(--color-content-invert-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-invert-brand-primary)] active:text-[var(--color-content-invert-primary)] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
"bg-[var(--color-surface-inverse-primary)] text-[var(--color-content-inverse-primary)] border-[1.5px] border-transparent hover:bg-[var(--color-surface-inverse-primary)] hover:text-[var(--color-content-invert-brand-primary)] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus-visible:bg-[var(--color-surface-inverse-primary)] focus-visible:text-[var(--color-content-invert-brand-primary)] focus-visible:outline-none focus-visible:border-transparent focus-visible:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus-visible:scale-[1.02] active:bg-[var(--color-surface-invert-brand-primary)] active:text-[var(--color-content-invert-primary)] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
"filled-inverse":
|
||||
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-[1.5px] border-transparent hover:bg-[var(--color-surface-default-primary)] hover:text-[var(--color-content-default-brand-primary)] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus:bg-[var(--color-surface-default-primary)] focus:text-[var(--color-content-default-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-[1.5px] border-transparent hover:bg-[var(--color-surface-default-primary)] hover:text-[var(--color-content-default-brand-primary)] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus-visible:bg-[var(--color-surface-default-primary)] focus-visible:text-[var(--color-content-default-brand-primary)] focus-visible:outline-none focus-visible:border-transparent focus-visible:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus-visible:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
outline:
|
||||
"bg-transparent text-[var(--color-content-default-primary)] border-[1.5px] border-[var(--color-border-invert-primary)] hover:bg-transparent hover:text-[var(--color-content-default-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-default-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-border-invert-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[1.5px] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
"bg-transparent text-[var(--color-content-default-primary)] border-[1.5px] border-[var(--color-border-invert-primary)] hover:bg-transparent hover:text-[var(--color-content-default-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-content-default-primary)] focus-visible:outline-none focus-visible:border-[1.5px] focus-visible:border-[var(--color-border-invert-primary)] focus-visible:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus-visible:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[1.5px] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
"outline-inverse":
|
||||
"bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-[var(--color-border-default-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-invert-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-border-default-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-invert-primary)] active:border-[1.5px] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
"bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-[var(--color-border-default-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-content-invert-primary)] focus-visible:outline-none focus-visible:border-[1.5px] focus-visible:border-[var(--color-border-default-primary)] focus-visible:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus-visible:scale-[1.02] active:bg-transparent active:text-[var(--color-content-invert-primary)] active:border-[1.5px] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
ghost:
|
||||
"bg-transparent text-[var(--color-content-default-brand-primary)] border-[1.5px] border-transparent hover:bg-transparent hover:text-[var(--color-content-default-primary)] hover:border-transparent hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-default-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
"bg-transparent text-[var(--color-content-default-brand-primary)] border-[1.5px] border-transparent hover:bg-transparent hover:text-[var(--color-content-default-primary)] hover:border-transparent hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-content-default-brand-primary)] focus-visible:outline-none focus-visible:border-transparent focus-visible:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus-visible:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
"ghost-inverse":
|
||||
"bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-transparent hover:bg-transparent hover:text-[var(--color-content-invert-primary)] hover:border-transparent hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-invert-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-invert-brand-primary)] active:text-[var(--color-content-invert-primary)] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
"bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-transparent hover:bg-transparent hover:text-[var(--color-content-invert-primary)] hover:border-transparent hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-content-invert-brand-primary)] focus-visible:outline-none focus-visible:border-transparent focus-visible:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus-visible:scale-[1.02] active:bg-[var(--color-surface-invert-brand-primary)] active:text-[var(--color-content-invert-primary)] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
danger:
|
||||
"bg-transparent text-[var(--color-border-default-negative-primary)] border border-[var(--color-border-default-negative-primary)] hover:bg-[var(--color-surface-invert-negative-secondary)] hover:text-[var(--color-border-default-negative-primary)] hover:border-[var(--color-border-default-negative-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-border-default-negative-primary)] focus:outline-none focus:border-[var(--color-border-default-negative-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-invert-negative-primary)] active:text-[var(--color-content-invert-negative-primary)] active:border-[1.5px] active:border-[var(--color-border-default-negative-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
"bg-transparent text-[var(--color-border-default-negative-primary)] border border-[var(--color-border-default-negative-primary)] hover:bg-[var(--color-surface-invert-negative-secondary)] hover:text-[var(--color-border-default-negative-primary)] hover:border-[var(--color-border-default-negative-primary)] hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-border-default-negative-primary)] focus-visible:outline-none focus-visible:border-[var(--color-border-default-negative-primary)] focus-visible:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus-visible:scale-[1.02] active:bg-[var(--color-surface-invert-negative-primary)] active:text-[var(--color-content-invert-negative-primary)] active:border-[1.5px] active:border-[var(--color-border-default-negative-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
"danger-inverse":
|
||||
"bg-transparent text-[var(--color-content-invert-negative-primary)] border border-[var(--color-border-invert-negative-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-negative-primary)] hover:border-[var(--color-border-invert-negative-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-invert-negative-primary)] focus:outline-none focus:border-[var(--color-border-invert-negative-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-default-negative-primary)] active:text-[var(--color-content-default-primary)] active:border-[1.5px] active:border-[var(--color-border-default-negative-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
"bg-transparent text-[var(--color-content-invert-negative-primary)] border border-[var(--color-border-invert-negative-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-negative-primary)] hover:border-[var(--color-border-invert-negative-primary)] hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-content-invert-negative-primary)] focus-visible:outline-none focus-visible:border-[var(--color-border-invert-negative-primary)] focus-visible:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus-visible:scale-[1.02] active:bg-[var(--color-surface-default-negative-primary)] active:text-[var(--color-content-default-primary)] active:border-[1.5px] active:border-[var(--color-border-default-negative-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
|
||||
};
|
||||
|
||||
const hoverOutlineStyles: Record<string, string> = {
|
||||
|
||||
@@ -19,6 +19,9 @@ export interface InlineTextButtonProps {
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
/** When set, removes the default underline (e.g. inverse surfaces). */
|
||||
underline?: boolean;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,9 +40,16 @@ function InlineTextButtonComponent({
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
underline = true,
|
||||
"data-testid": dataTestId,
|
||||
}: InlineTextButtonProps) {
|
||||
const baseClasses =
|
||||
"cursor-pointer border-none bg-transparent p-0 font-inter font-normal text-[length:inherit] leading-[inherit] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)] disabled:cursor-not-allowed disabled:opacity-60";
|
||||
const baseClasses = [
|
||||
"cursor-pointer border-none bg-transparent p-0",
|
||||
underline
|
||||
? "font-inter font-normal text-[length:inherit] leading-[inherit] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px]"
|
||||
: "text-[length:inherit] leading-[inherit] text-[color:inherit] no-underline",
|
||||
"hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
].join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -47,6 +57,7 @@ function InlineTextButtonComponent({
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
data-testid={dataTestId}
|
||||
className={`${baseClasses} ${className}`.trim()}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
/**
|
||||
* Figma: Community Rule System — **Vertical button** (`19787:10896`).
|
||||
*
|
||||
* Tile control: column layout, brand-primary border on transparent surface,
|
||||
* 32px icon slot + centered 14/18 medium label (label rendered by `children`).
|
||||
*/
|
||||
export interface VerticalProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: (_event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
function VerticalComponent({
|
||||
children,
|
||||
onClick,
|
||||
className = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
"data-testid": dataTestId,
|
||||
}: VerticalProps) {
|
||||
const base =
|
||||
"box-border flex w-[90px] shrink-0 cursor-pointer flex-col items-center gap-[var(--spacing-scale-008)] rounded-[var(--spacing-scale-004)] border border-solid border-[var(--color-border-default-brand-primary)] bg-transparent px-[var(--spacing-scale-008)] py-[var(--spacing-scale-012)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] disabled:cursor-not-allowed disabled:opacity-60";
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
data-testid={dataTestId}
|
||||
className={`${base} ${className}`.trim()}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
VerticalComponent.displayName = "Vertical";
|
||||
|
||||
export default memo(VerticalComponent);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Vertical";
|
||||
export type { VerticalProps } from "./Vertical";
|
||||
@@ -32,6 +32,10 @@ const CardStackContainer = memo<CardStackProps>(
|
||||
headerLockupSize,
|
||||
toggleAlignment = "center",
|
||||
className = "",
|
||||
showAddCard = false,
|
||||
addCardLabel = "",
|
||||
addCardAriaLabel = "",
|
||||
onAddCard,
|
||||
}) => {
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(
|
||||
@@ -90,6 +94,10 @@ const CardStackContainer = memo<CardStackProps>(
|
||||
headerLockupSize={headerLockupSize}
|
||||
toggleAlignment={toggleAlignment}
|
||||
className={className}
|
||||
showAddCard={showAddCard}
|
||||
addCardLabel={addCardLabel}
|
||||
addCardAriaLabel={addCardAriaLabel}
|
||||
onAddCard={onAddCard}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup.types";
|
||||
|
||||
export interface CardStackItem {
|
||||
@@ -18,7 +19,7 @@ export interface CardStackProps {
|
||||
toggleLabel?: string;
|
||||
showLessLabel?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
description?: ReactNode;
|
||||
/** "default" = compact grid/column + expanded grid; "singleStack" = always one column, expand shows more in same stack */
|
||||
layout?: "default" | "singleStack";
|
||||
/**
|
||||
@@ -45,6 +46,11 @@ export interface CardStackProps {
|
||||
/** Alignment of the expand/collapse control in `singleStack` layout (Figma right-rail: end). */
|
||||
toggleAlignment?: "center" | "end";
|
||||
className?: string;
|
||||
/** Optional “Add” entry (e.g. custom method card wizard). */
|
||||
showAddCard?: boolean;
|
||||
addCardLabel?: string;
|
||||
addCardAriaLabel?: string;
|
||||
onAddCard?: () => void;
|
||||
}
|
||||
|
||||
export interface CardStackViewProps {
|
||||
@@ -57,7 +63,7 @@ export interface CardStackViewProps {
|
||||
toggleLabel: string;
|
||||
showLessLabel: string;
|
||||
title: string;
|
||||
description: string;
|
||||
description: ReactNode;
|
||||
layout: "default" | "singleStack";
|
||||
compactRecommendedLimit: number;
|
||||
compactCardIds: string[] | undefined;
|
||||
@@ -65,4 +71,8 @@ export interface CardStackViewProps {
|
||||
headerLockupSize: HeaderLockupSizeValue | undefined;
|
||||
toggleAlignment: "center" | "end";
|
||||
className: string;
|
||||
showAddCard: boolean;
|
||||
addCardLabel: string;
|
||||
addCardAriaLabel: string;
|
||||
onAddCard?: () => void;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import HeaderLockup from "../../type/HeaderLockup";
|
||||
import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup.types";
|
||||
import Selection from "../Selection";
|
||||
import type { CardStackViewProps } from "./CardStack.types";
|
||||
|
||||
function CardStackHeaderLockup({
|
||||
title,
|
||||
description,
|
||||
justification,
|
||||
size,
|
||||
wrapperClassName,
|
||||
}: {
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
justification: "center" | "left";
|
||||
size: HeaderLockupSizeValue;
|
||||
wrapperClassName?: string;
|
||||
}) {
|
||||
if (!title && !description) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={wrapperClassName ?? "min-w-0"}>
|
||||
<HeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification={justification}
|
||||
size={size}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardStackAddCardButton({
|
||||
label,
|
||||
ariaLabel,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
className="flex min-h-[88px] w-full shrink-0 items-center justify-center rounded-[var(--measures-radius-medium,8px)] bg-[var(--color-surface-default-secondary)] font-inter text-base font-medium text-[var(--color-content-default-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)]"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardStackView({
|
||||
cards,
|
||||
selectedIds,
|
||||
@@ -22,7 +90,19 @@ export function CardStackView({
|
||||
headerLockupSize,
|
||||
toggleAlignment,
|
||||
className,
|
||||
showAddCard,
|
||||
addCardLabel,
|
||||
addCardAriaLabel,
|
||||
onAddCard,
|
||||
}: CardStackViewProps) {
|
||||
const addTile =
|
||||
showAddCard && onAddCard && addCardLabel.length > 0 ? (
|
||||
<CardStackAddCardButton
|
||||
label={addCardLabel}
|
||||
ariaLabel={addCardAriaLabel || addCardLabel}
|
||||
onClick={onAddCard}
|
||||
/>
|
||||
) : null;
|
||||
const lockupSize = headerLockupSize ?? "L";
|
||||
const isSelected = (id: string) => selectedIds.includes(id);
|
||||
// Compact: explicit `compactCardIds` (caller-driven, used by create-flow
|
||||
@@ -47,16 +127,13 @@ export function CardStackView({
|
||||
const displayedCards = expanded ? cards : compactCards;
|
||||
return (
|
||||
<div className={`flex w-full flex-col gap-6 min-w-0 ${className}`}>
|
||||
{title || description ? (
|
||||
<div className="min-w-0 shrink-0">
|
||||
<HeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size={lockupSize}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<CardStackHeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size={lockupSize}
|
||||
wrapperClassName="min-w-0 shrink-0"
|
||||
/>
|
||||
<div className="flex w-full min-w-0 flex-col gap-2">
|
||||
{displayedCards.map((item) => (
|
||||
<Selection
|
||||
@@ -71,6 +148,7 @@ export function CardStackView({
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
{addTile}
|
||||
</div>
|
||||
{hasMore ? (
|
||||
<button
|
||||
@@ -89,16 +167,12 @@ export function CardStackView({
|
||||
|
||||
return (
|
||||
<div className={`flex w-full flex-col gap-6 min-w-0 ${className}`}>
|
||||
{title || description ? (
|
||||
<div className="min-w-0">
|
||||
<HeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size={lockupSize}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<CardStackHeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size={lockupSize}
|
||||
/>
|
||||
|
||||
{expanded ? (
|
||||
<div className="mx-auto grid w-full max-w-[min(100%,860px)] grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
|
||||
@@ -115,6 +189,9 @@ export function CardStackView({
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
{addTile ? (
|
||||
<div className="min-w-0 md:col-span-2">{addTile}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : compactDesktopLayout === "pyramidFive" ? (
|
||||
<>
|
||||
@@ -133,6 +210,7 @@ export function CardStackView({
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
{addTile}
|
||||
</div>
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] md:block">
|
||||
{/*
|
||||
@@ -228,6 +306,11 @@ export function CardStackView({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{addTile ? (
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] md:block">
|
||||
{addTile}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : compactDesktopLayout === "flexWrap" ? (
|
||||
<>
|
||||
@@ -246,6 +329,7 @@ export function CardStackView({
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
{addTile}
|
||||
</div>
|
||||
{/* md–lg: pyramid (2 + 1), each row centered; lg+: one centered row (not edge-to-edge in a 2-col grid) */}
|
||||
{compactCards.length === 3 ? (
|
||||
@@ -280,6 +364,9 @@ export function CardStackView({
|
||||
onClick={() => onCardSelect(compactCards[2].id)}
|
||||
/>
|
||||
</div>
|
||||
{addTile ? (
|
||||
<div className="flex w-full justify-center px-2">{addTile}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-wrap justify-center gap-2 lg:flex">
|
||||
{compactCards.map((item) => (
|
||||
@@ -297,6 +384,11 @@ export function CardStackView({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{addTile ? (
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] lg:flex lg:justify-center">
|
||||
<div className="w-full min-w-[281px] max-w-[574px]">{addTile}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-wrap justify-center gap-2 md:flex">
|
||||
@@ -318,6 +410,11 @@ export function CardStackView({
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{addTile ? (
|
||||
<div className="flex w-full min-w-0 shrink-0 justify-center md:w-[281px] md:max-w-[574px] md:flex-[1_1_100%]">
|
||||
{addTile}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -338,6 +435,7 @@ export function CardStackView({
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
{addTile}
|
||||
</div>
|
||||
{/* Compact 640+: 6-col grid so each card spans 2; second row centered (cols 2–3 and 4–5) */}
|
||||
<div className="hidden md:grid grid-cols-6 gap-x-4 gap-y-6 w-full">
|
||||
@@ -365,6 +463,9 @@ export function CardStackView({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{addTile ? (
|
||||
<div className="col-span-6 min-w-0">{addTile}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,9 @@ const RuleContainer = memo<RuleProps>(
|
||||
({
|
||||
title,
|
||||
description,
|
||||
onDescriptionClick,
|
||||
descriptionEmptyHint,
|
||||
descriptionEditAriaLabel,
|
||||
icon,
|
||||
backgroundColor = "bg-[var(--color-community-teal-100)]",
|
||||
className = "",
|
||||
@@ -39,6 +42,7 @@ const RuleContainer = memo<RuleProps>(
|
||||
hasBottomLinks = false,
|
||||
bottomStatusLabel,
|
||||
bottomLinks,
|
||||
recommended = false,
|
||||
}) => {
|
||||
const size = sizeProp ?? "L";
|
||||
|
||||
@@ -75,6 +79,9 @@ const RuleContainer = memo<RuleProps>(
|
||||
<RuleView
|
||||
title={title}
|
||||
description={description}
|
||||
onDescriptionClick={onDescriptionClick}
|
||||
descriptionEmptyHint={descriptionEmptyHint}
|
||||
descriptionEditAriaLabel={descriptionEditAriaLabel}
|
||||
icon={icon}
|
||||
backgroundColor={backgroundColor}
|
||||
className={className}
|
||||
@@ -90,6 +97,7 @@ const RuleContainer = memo<RuleProps>(
|
||||
hasBottomLinks={hasBottomLinks}
|
||||
bottomStatusLabel={bottomStatusLabel}
|
||||
bottomLinks={bottomLinks}
|
||||
recommended={recommended}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { RuleSizeValue } from "../../../../lib/propNormalization";
|
||||
export interface Category {
|
||||
name: string;
|
||||
chipOptions: ChipOption[];
|
||||
/** When `false`, hide the row’s + affordance. Default: show when the Rule allows adds. */
|
||||
addButton?: boolean;
|
||||
onChipClick?: (categoryName: string, chipId: string) => void;
|
||||
onAddClick?: (categoryName: string) => void;
|
||||
onCustomChipConfirm?: (
|
||||
@@ -25,6 +27,18 @@ export interface RuleBottomLink {
|
||||
export interface RuleProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
/**
|
||||
* When set, the description row (or {@link descriptionEmptyHint} when there
|
||||
* is no body text) is clickable — caller handles modal / navigation.
|
||||
*/
|
||||
onDescriptionClick?: () => void;
|
||||
/**
|
||||
* When {@link onDescriptionClick} is set, forwarded to the control’s
|
||||
* `aria-label` (keyboard / SR).
|
||||
*/
|
||||
descriptionEditAriaLabel?: string;
|
||||
/** Shown when {@link onDescriptionClick} is set and `description` is empty. */
|
||||
descriptionEmptyHint?: string;
|
||||
icon?: React.ReactNode;
|
||||
backgroundColor?: string;
|
||||
className?: string;
|
||||
@@ -46,11 +60,20 @@ export interface RuleProps {
|
||||
/** Uppercase chip (e.g. IN PROGRESS); omit when no left badge. */
|
||||
bottomStatusLabel?: string;
|
||||
bottomLinks?: RuleBottomLink[];
|
||||
/**
|
||||
* When set and the card is collapsed (`expanded` false), show the
|
||||
* “RECOMMENDED” tag above the title (e.g. templates index). Ignored when
|
||||
* `expanded` — Figma `22142:898446` compact `Card / Rule` only.
|
||||
*/
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
export interface RuleViewProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
onDescriptionClick?: () => void;
|
||||
descriptionEmptyHint?: string;
|
||||
descriptionEditAriaLabel?: string;
|
||||
icon?: React.ReactNode;
|
||||
backgroundColor: string;
|
||||
className: string;
|
||||
@@ -66,4 +89,5 @@ export interface RuleViewProps {
|
||||
hasBottomLinks?: boolean;
|
||||
bottomStatusLabel?: string;
|
||||
bottomLinks?: RuleBottomLink[];
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import MultiSelect from "../../controls/MultiSelect";
|
||||
import InlineTextButton from "../../buttons/InlineTextButton";
|
||||
import NavigationLink from "../../navigation/Link";
|
||||
import Tag from "../../utility/Tag";
|
||||
import type { RuleBottomLink, RuleViewProps } from "./Rule.types";
|
||||
|
||||
export function RuleView({
|
||||
title,
|
||||
description,
|
||||
onDescriptionClick,
|
||||
descriptionEmptyHint,
|
||||
descriptionEditAriaLabel,
|
||||
icon,
|
||||
backgroundColor,
|
||||
className,
|
||||
@@ -24,6 +29,7 @@ export function RuleView({
|
||||
hasBottomLinks = false,
|
||||
bottomStatusLabel,
|
||||
bottomLinks,
|
||||
recommended = false,
|
||||
}: RuleViewProps) {
|
||||
const t = useTranslation("ruleCard");
|
||||
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
|
||||
@@ -86,6 +92,7 @@ export function RuleView({
|
||||
`;
|
||||
|
||||
// Title typography - use CSS responsive classes
|
||||
const showRecommendedTag = recommended && !expanded;
|
||||
const titleClass = `
|
||||
max-[639px]:font-inter max-[639px]:font-bold max-[639px]:text-[20px] max-[639px]:leading-[28px]
|
||||
min-[640px]:max-[1023px]:font-bricolage-grotesque min-[640px]:max-[1023px]:font-bold min-[640px]:max-[1023px]:text-[28px] min-[640px]:max-[1023px]:leading-[36px]
|
||||
@@ -252,12 +259,22 @@ export function RuleView({
|
||||
{/* Inner container for header text with padding */}
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-center w-full
|
||||
flex w-full
|
||||
${
|
||||
showRecommendedTag
|
||||
? "flex-col items-start justify-center gap-1"
|
||||
: "items-center justify-center"
|
||||
}
|
||||
max-[639px]:pl-[8px] max-[639px]:py-[8px]
|
||||
min-[640px]:max-[1023px]:pl-[12px] min-[640px]:max-[1023px]:py-[12px]
|
||||
min-[1024px]:px-[16px] min-[1024px]:py-[24px]
|
||||
`}
|
||||
>
|
||||
{showRecommendedTag ? (
|
||||
<Tag variant="templateRecommended">
|
||||
{t("recommendedLabel")}
|
||||
</Tag>
|
||||
) : null}
|
||||
<h3
|
||||
className={`${titleClass} cursor-inherit text-[var(--color-content-invert-primary)] overflow-hidden text-ellipsis w-full`}
|
||||
>
|
||||
@@ -314,6 +331,41 @@ export function RuleView({
|
||||
</div>
|
||||
) : expanded ? (
|
||||
<>
|
||||
{(description ||
|
||||
(onDescriptionClick &&
|
||||
typeof descriptionEmptyHint === "string")) && (
|
||||
<div
|
||||
className={`relative w-full shrink-0 border-b border-solid border-[var(--color-content-invert-primary)] pb-[16px] ${
|
||||
expanded && (isLarge || isMedium) ? "px-0" : "px-[12px]"
|
||||
}`}
|
||||
>
|
||||
{onDescriptionClick ? (
|
||||
<InlineTextButton
|
||||
type="button"
|
||||
underline={false}
|
||||
data-testid="rule-description-edit"
|
||||
ariaLabel={descriptionEditAriaLabel}
|
||||
className={`${descriptionClass} w-full min-w-0 cursor-pointer whitespace-pre-wrap text-left text-[var(--color-content-invert-primary)] hover:!opacity-100 ${
|
||||
!description && descriptionEmptyHint ? "opacity-70" : ""
|
||||
}`.trim()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDescriptionClick();
|
||||
}}
|
||||
>
|
||||
{description ?? descriptionEmptyHint ?? ""}
|
||||
</InlineTextButton>
|
||||
) : (
|
||||
description && (
|
||||
<p
|
||||
className={`${descriptionClass} cursor-inherit text-[var(--color-content-invert-primary)]`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Categories Section - Using MultiSelect */}
|
||||
{categories && categories.length > 0 && (
|
||||
<div
|
||||
@@ -345,23 +397,15 @@ export function RuleView({
|
||||
onCustomChipClose={(chipId) => {
|
||||
category.onCustomChipClose?.(category.name, chipId);
|
||||
}}
|
||||
addButton={!hideCategoryAddButton}
|
||||
addButton={
|
||||
!hideCategoryAddButton && category.addButton !== false
|
||||
}
|
||||
addButtonText="" // Empty text for icon-only circular button
|
||||
className="w-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Footer: Description */}
|
||||
{description && (
|
||||
<div className="border-t border-solid border-[var(--color-content-invert-primary)] pt-[16px] relative shrink-0 w-full">
|
||||
<p
|
||||
className={`${descriptionClass} cursor-inherit text-[var(--color-content-invert-primary)]`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Collapsed State: Description */
|
||||
|
||||
@@ -8,6 +8,7 @@ import ContentLockup from "../../type/ContentLockup";
|
||||
import ModalTextAreaField from "../../../(app)/create/components/ModalTextAreaField";
|
||||
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
|
||||
import type { TemplateChipDetail } from "../../../../lib/create/templateReviewMapping";
|
||||
import { formatConflictApplicableScopeForTextarea } from "../../../../lib/create/ruleSectionsFromMethodSelections";
|
||||
|
||||
export interface TemplateChipDetailModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -235,9 +236,15 @@ function resolveChipContent(
|
||||
disabled
|
||||
rows={4}
|
||||
/>
|
||||
<ReadOnlyScopeField
|
||||
<ModalTextAreaField
|
||||
label={cm.sectionHeadings.applicableScope}
|
||||
scopes={preset.sections.applicableScope}
|
||||
value={formatConflictApplicableScopeForTextarea(
|
||||
[],
|
||||
preset.sections.applicableScope,
|
||||
)}
|
||||
onChange={noop}
|
||||
disabled
|
||||
rows={4}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={cm.sectionHeadings.processProtocol}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { AddCustomFieldView } from "./AddCustomField.view";
|
||||
import type { AddCustomFieldProps, AddCustomFieldType } from "./AddCustomField.types";
|
||||
|
||||
/**
|
||||
* Figma: "Add Custom Field" control — Community Rule System (`20235:12994`).
|
||||
* Collapsed CTA expands to a 2×2 field-type picker (per-type modals deferred).
|
||||
*/
|
||||
const AddCustomFieldContainer = memo<AddCustomFieldProps>(
|
||||
({ active, onPressAdd, onSelectFieldType, className = "" }) => {
|
||||
const m = useMessages();
|
||||
const copy = m.create.customRule.customMethodCardWizard.addCustomField;
|
||||
|
||||
const fieldTypeLabels = useMemo(
|
||||
() => ({
|
||||
text: copy.fieldTypes.text,
|
||||
badges: copy.fieldTypes.badges,
|
||||
upload: copy.fieldTypes.upload,
|
||||
proportion: copy.fieldTypes.proportion,
|
||||
}),
|
||||
[copy.fieldTypes],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(t: AddCustomFieldType) => {
|
||||
onSelectFieldType?.(t);
|
||||
},
|
||||
[onSelectFieldType],
|
||||
);
|
||||
|
||||
return (
|
||||
<AddCustomFieldView
|
||||
active={active}
|
||||
onPressAdd={onPressAdd}
|
||||
onSelectFieldType={handleSelect}
|
||||
ctaLabel={copy.cta}
|
||||
fieldTypeLabels={fieldTypeLabels}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AddCustomFieldContainer.displayName = "AddCustomField";
|
||||
|
||||
export default AddCustomFieldContainer;
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { IconName } from "../../asset/icon";
|
||||
|
||||
export type AddCustomFieldType = "text" | "badges" | "upload" | "proportion";
|
||||
|
||||
/** Icons for each addable field type (wizard + summaries). */
|
||||
export const ADD_CUSTOM_FIELD_TYPE_ICONS = {
|
||||
text: "text_block",
|
||||
badges: "tags",
|
||||
upload: "image",
|
||||
proportion: "number",
|
||||
} as const satisfies Record<AddCustomFieldType, IconName>;
|
||||
|
||||
export interface AddCustomFieldProps {
|
||||
/** When true, show the 2×2 field-type grid; when false, show the primary CTA. */
|
||||
active: boolean;
|
||||
onPressAdd?: () => void;
|
||||
onSelectFieldType?: (type: AddCustomFieldType) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface AddCustomFieldViewProps {
|
||||
active: boolean;
|
||||
onPressAdd?: () => void;
|
||||
onSelectFieldType?: (type: AddCustomFieldType) => void;
|
||||
ctaLabel: string;
|
||||
fieldTypeLabels: Record<AddCustomFieldType, string>;
|
||||
className: string;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Icon from "../../asset/icon";
|
||||
import Vertical from "../../buttons/Vertical";
|
||||
import {
|
||||
ADD_CUSTOM_FIELD_TYPE_ICONS,
|
||||
type AddCustomFieldType,
|
||||
type AddCustomFieldViewProps,
|
||||
} from "./AddCustomField.types";
|
||||
|
||||
function FieldTypeButton({
|
||||
type,
|
||||
label,
|
||||
onSelect,
|
||||
}: {
|
||||
type: AddCustomFieldType;
|
||||
label: string;
|
||||
onSelect?: (t: AddCustomFieldType) => void;
|
||||
}) {
|
||||
return (
|
||||
<Vertical
|
||||
type="button"
|
||||
ariaLabel={label}
|
||||
onClick={() => onSelect?.(type)}
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
<Icon
|
||||
name={ADD_CUSTOM_FIELD_TYPE_ICONS[type]}
|
||||
size={32}
|
||||
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
|
||||
/>
|
||||
</span>
|
||||
<span className="w-full text-center font-inter text-[14px] font-medium leading-[18px] text-[var(--color-content-default-brand-primary,#fefcc9)]">
|
||||
{label}
|
||||
</span>
|
||||
</Vertical>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable block height for collapsed vs expanded so the Create dialog (`top-1/2 -translate-y-1/2`)
|
||||
* does not shrink and re-center when toggling `active`.
|
||||
*
|
||||
* - Collapsed CTA: `py-12` (48+48) + inner row (`py-3` + 20px icon/line) ≈ 140px border-box.
|
||||
* - Expanded: inner `p-4` (32) + Vertical tile (py 12+12, gap 8, 32px icon, 18px label) ≈ 114px — shorter without this floor.
|
||||
*/
|
||||
const ADD_CUSTOM_FIELD_SHELL_MIN_H_PX = 140;
|
||||
|
||||
function AddCustomFieldViewComponent({
|
||||
active,
|
||||
onPressAdd,
|
||||
onSelectFieldType,
|
||||
ctaLabel,
|
||||
fieldTypeLabels,
|
||||
className,
|
||||
}: AddCustomFieldViewProps) {
|
||||
const shellStyle = {
|
||||
minHeight: ADD_CUSTOM_FIELD_SHELL_MIN_H_PX,
|
||||
} as const;
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPressAdd}
|
||||
style={shellStyle}
|
||||
className={`flex w-full shrink-0 cursor-pointer items-center justify-center rounded-[var(--measures-radius-medium,8px)] bg-[var(--color-surface-default-secondary)] px-6 py-12 font-inter text-[16px] font-medium leading-5 text-[var(--color-content-default-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] ${className ?? ""}`.trim()}
|
||||
>
|
||||
<span className="flex items-center gap-[var(--spacing-scale-006)] rounded-full px-4 py-3">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{ctaLabel}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const expandedShellClasses = ["flex w-full shrink-0 flex-col", className ?? ""]
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
return (
|
||||
<div className={expandedShellClasses} style={shellStyle}>
|
||||
<div className="flex w-full flex-col gap-3 rounded-[var(--measures-radius-medium,8px)] bg-[var(--color-surface-default-secondary)] p-4">
|
||||
<div className="flex w-full flex-row flex-nowrap justify-center gap-3 overflow-x-auto max-sm:justify-start">
|
||||
<FieldTypeButton
|
||||
type="text"
|
||||
label={fieldTypeLabels.text}
|
||||
onSelect={onSelectFieldType}
|
||||
/>
|
||||
<FieldTypeButton
|
||||
type="badges"
|
||||
label={fieldTypeLabels.badges}
|
||||
onSelect={onSelectFieldType}
|
||||
/>
|
||||
<FieldTypeButton
|
||||
type="upload"
|
||||
label={fieldTypeLabels.upload}
|
||||
onSelect={onSelectFieldType}
|
||||
/>
|
||||
<FieldTypeButton
|
||||
type="proportion"
|
||||
label={fieldTypeLabels.proportion}
|
||||
onSelect={onSelectFieldType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const AddCustomFieldView = memo(AddCustomFieldViewComponent);
|
||||
AddCustomFieldView.displayName = "AddCustomFieldView";
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default } from "./AddCustomField.container";
|
||||
export type {
|
||||
AddCustomFieldProps,
|
||||
AddCustomFieldType,
|
||||
} from "./AddCustomField.types";
|
||||
@@ -89,9 +89,9 @@ function MultiSelectView({
|
||||
className={
|
||||
!addButtonText
|
||||
? // Circular button with border (Rule style)
|
||||
`bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary,#141414)]" : "border-[var(--color-border-default-tertiary,#464646)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`
|
||||
`cursor-pointer bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary,#141414)]" : "border-[var(--color-border-default-tertiary,#464646)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`
|
||||
: // Text add control (default palette: white label + brand “+”; inverse: inverse primary for both)
|
||||
`flex items-center justify-center overflow-hidden rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity ${
|
||||
`cursor-pointer flex items-center justify-center overflow-hidden rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity ${
|
||||
isSmall
|
||||
? "gap-[var(--measures-spacing-100,4px)] px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-200,8px)]"
|
||||
: "gap-[var(--measures-spacing-150,6px)] px-[var(--space-400,16px)] py-[var(--measures-spacing-300,12px)]"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: Community Rule System — "Add Custom Field/Popover" (List-item/lockup)
|
||||
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=20887-175710
|
||||
*/
|
||||
import { memo } from "react";
|
||||
import { ListItemView } from "./ListItem.view";
|
||||
import type { ListItemProps } from "./ListItem.types";
|
||||
|
||||
const ListItem = memo<ListItemProps>((props) => {
|
||||
return <ListItemView {...props} />;
|
||||
});
|
||||
|
||||
ListItem.displayName = "ListItem";
|
||||
|
||||
export default ListItem;
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { IconName } from "../../asset/icon";
|
||||
|
||||
export type ListItemProps = {
|
||||
label: string;
|
||||
leadingIcon: IconName;
|
||||
onClick: () => void;
|
||||
/** Bottom divider between rows — false on the final row per Figma. */
|
||||
showDivider: boolean;
|
||||
className?: string;
|
||||
variant?: "default" | "destructive";
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Icon from "../../asset/icon";
|
||||
import type { ListItemProps } from "./ListItem.types";
|
||||
|
||||
export const ListItemView = memo(function ListItemView({
|
||||
label,
|
||||
leadingIcon,
|
||||
onClick,
|
||||
showDivider,
|
||||
className = "",
|
||||
variant = "default",
|
||||
}: ListItemProps) {
|
||||
const dividerClass = showDivider
|
||||
? "border-b border-solid border-[var(--color-border-default-tertiary)]"
|
||||
: "";
|
||||
const contentTone =
|
||||
variant === "destructive"
|
||||
? "text-[var(--color-content-default-negative-primary)]"
|
||||
: "text-[var(--color-content-default-primary)]";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onClick}
|
||||
className={`relative flex w-full shrink-0 cursor-pointer items-center gap-[6px] px-[4px] py-[16px] text-left hover:bg-[var(--color-surface-default-tertiary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)] ${dividerClass} ${className}`}
|
||||
>
|
||||
<span
|
||||
className={`flex size-6 shrink-0 items-center justify-center overflow-visible ${contentTone}`}
|
||||
>
|
||||
<Icon name={leadingIcon} size={24} />
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 flex-1 text-left font-inter text-[12px] font-normal leading-4 whitespace-normal ${contentTone}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
ListItemView.displayName = "ListItemView";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./ListItem.container";
|
||||
export type { ListItemProps } from "./ListItem.types";
|
||||
@@ -27,6 +27,10 @@ const CreateContainer = memo<CreateProps>(
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
backdropVariant = "default",
|
||||
stepper,
|
||||
kebabTriggerAriaLabel,
|
||||
kebabMenuAriaLabel,
|
||||
kebabMenuItems,
|
||||
}) => {
|
||||
const createRef = useRef<HTMLDivElement>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
@@ -58,6 +62,10 @@ const CreateContainer = memo<CreateProps>(
|
||||
createRef={createRef}
|
||||
overlayRef={overlayRef}
|
||||
backdropVariant={backdropVariant}
|
||||
stepper={stepper}
|
||||
kebabTriggerAriaLabel={kebabTriggerAriaLabel}
|
||||
kebabMenuAriaLabel={kebabMenuAriaLabel}
|
||||
kebabMenuItems={kebabMenuItems}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RefObject } from "react";
|
||||
import type { CreateModalBackdropVariant } from "./CreateModalFrame.view";
|
||||
import type { ModalHeaderMenuItem } from "../ModalHeader/ModalHeader.types";
|
||||
|
||||
export interface CreateProps {
|
||||
isOpen: boolean;
|
||||
@@ -35,6 +36,11 @@ export interface CreateProps {
|
||||
* @default "default"
|
||||
*/
|
||||
backdropVariant?: CreateModalBackdropVariant;
|
||||
/** Passed through to ModalFooter; set explicitly when step visibility must not infer from steps alone. */
|
||||
stepper?: boolean;
|
||||
kebabTriggerAriaLabel?: string;
|
||||
kebabMenuAriaLabel?: string;
|
||||
kebabMenuItems?: ModalHeaderMenuItem[];
|
||||
}
|
||||
|
||||
export interface CreateViewProps {
|
||||
@@ -60,4 +66,8 @@ export interface CreateViewProps {
|
||||
createRef: RefObject<HTMLDivElement | null>;
|
||||
overlayRef: RefObject<HTMLDivElement | null>;
|
||||
backdropVariant: CreateModalBackdropVariant;
|
||||
stepper?: boolean;
|
||||
kebabTriggerAriaLabel?: string;
|
||||
kebabMenuAriaLabel?: string;
|
||||
kebabMenuItems?: ModalHeaderMenuItem[];
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ export function CreateView({
|
||||
createRef,
|
||||
overlayRef,
|
||||
backdropVariant,
|
||||
stepper,
|
||||
kebabTriggerAriaLabel,
|
||||
kebabMenuAriaLabel,
|
||||
kebabMenuItems,
|
||||
}: CreateViewProps) {
|
||||
return (
|
||||
<CreateModalFrameView
|
||||
@@ -41,7 +45,13 @@ export function CreateView({
|
||||
overlayRef={overlayRef}
|
||||
dialogRef={createRef}
|
||||
>
|
||||
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
|
||||
<ModalHeader
|
||||
onClose={onClose}
|
||||
moreOptionsAriaLabel={kebabTriggerAriaLabel}
|
||||
menuAriaLabel={kebabMenuAriaLabel}
|
||||
menuItems={kebabMenuItems}
|
||||
showMoreOptionsButton={(kebabMenuItems?.length ?? 0) > 0}
|
||||
/>
|
||||
|
||||
{headerContent !== undefined ? (
|
||||
<div className="shrink-0">{headerContent}</div>
|
||||
@@ -70,6 +80,7 @@ export function CreateView({
|
||||
nextButtonDisabled={nextButtonDisabled}
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
stepper={stepper}
|
||||
footerContent={footerContent}
|
||||
/>
|
||||
</CreateModalFrameView>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { memo, useEffect, useId, useRef, useState } from "react";
|
||||
import { ModalHeaderView } from "./ModalHeader.view";
|
||||
import type { ModalHeaderProps } from "./ModalHeader.types";
|
||||
|
||||
@@ -10,7 +10,55 @@ import type { ModalHeaderProps } from "./ModalHeader.types";
|
||||
* (right) icon buttons.
|
||||
*/
|
||||
const ModalHeaderContainer = memo<ModalHeaderProps>((props) => {
|
||||
return <ModalHeaderView {...props} />;
|
||||
const { menuItems = [] } = props;
|
||||
const hasMenu = menuItems.length > 0;
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuId = useId();
|
||||
const menuWrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen || !hasMenu) return;
|
||||
const onDoc = (event: MouseEvent) => {
|
||||
if (
|
||||
menuWrapRef.current &&
|
||||
!menuWrapRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [hasMenu, menuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen || !hasMenu) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [hasMenu, menuOpen]);
|
||||
|
||||
return (
|
||||
<div ref={menuWrapRef}>
|
||||
<ModalHeaderView
|
||||
{...props}
|
||||
menuId={menuId}
|
||||
menuOpen={menuOpen}
|
||||
onToggleMenu={hasMenu ? () => setMenuOpen((open) => !open) : undefined}
|
||||
onMenuItemClick={
|
||||
hasMenu
|
||||
? (item) => {
|
||||
item.onClick?.();
|
||||
setMenuOpen(false);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ModalHeaderContainer.displayName = "ModalHeader";
|
||||
|
||||