diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 36d6f24..057617d 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -34,11 +34,17 @@ import { } from "./utils/anonymousDraftStorage"; import { deleteServerDraft } from "../../../lib/create/api"; import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule"; -import { - fetchTemplateBySlug, - type RuleTemplateDto, -} from "../../../lib/create/fetchTemplates"; +import { buildTemplateCustomizePrefill } from "../../../lib/create/applyTemplatePrefill"; +import { loadTemplateReviewBySlug } from "../../../lib/create/loadTemplateReviewBySlug"; import messages from "../../../messages/en/index"; +import { + CREATE_FLOW_FOOTER_BUTTON_CLASS, + CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS, +} from "./utils/createFlowFooterClassNames"; +import { + CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP, + type CustomRuleConfirmFooterStep, +} from "./utils/customRuleConfirmFooterSteps"; import { useAuthModal } from "../../contexts/AuthModalContext"; import { useMessages, useTranslation } from "../../contexts/MessagesContext"; import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; @@ -114,7 +120,8 @@ function CreateFlowLayoutContent({ } = useCreateFlowNavigation( skipCommunitySave ? { skipCommunitySave: true } : undefined, ); - const { state, clearState, updateState } = useCreateFlow(); + const { state, clearState, updateState, resetCustomRuleSelections } = + useCreateFlow(); const { draftSaveBannerMessage, setDraftSaveBannerMessage } = useCreateFlowDraftSaveBanner(); const [publishBannerMessage, setPublishBannerMessage] = useState< @@ -188,38 +195,139 @@ function CreateFlowLayoutContent({ ); }, [state, router, openLogin]); - const handleUseTemplateWithoutChanges = useCallback(async () => { + /** + * Customize flow from a template-review page. Applies the template's + * customize selections onto `CreateFlowState` so the custom-rule screens + * render with chips pre-highlighted, then routes to `core-values` once + * the community name is set — otherwise to `informational` with a + * `pendingTemplateAction` pin so `/create/review` later redirects past + * itself straight to `core-values` (see `CommunityReviewScreen`). + * + * Why title alone? Other community-stage fields (e.g. + * `communityStructureChipSnapshots`) are sticky once the user lands on + * those screens, so they can't reliably answer "has the user given us + * real input yet?". A non-empty community name is the minimum bar + * `buildPublishPayload` already enforces — we reuse that here. + * + * Direct entry (marketing home "Popular templates" or `/templates` + * landed on directly) wipes the anonymous draft at the *click site* via + * `clearCreateFlowPersistedDrafts` before navigating, so `state.title` + * is empty here and the no-community branch fires naturally. No + * URL-marker plumbing needed in this handler. + */ + const handleCustomizeTemplate = useCallback(async () => { if (!templateReviewSlug) return; setTemplateReviewApplyError(null); setIsApplyingTemplate(true); - const result = await fetchTemplateBySlug(templateReviewSlug); + const loaded = await loadTemplateReviewBySlug(templateReviewSlug); setIsApplyingTemplate(false); - if (result === null) { - setTemplateReviewApplyError(messages.create.templateReview.errors.notFound); + if (loaded.ok === false) { + setTemplateReviewApplyError(loaded.message); return; } - if ("error" in result) { - setTemplateReviewApplyError(result.error); + const prefill = buildTemplateCustomizePrefill(loaded.template.body); + const hasCommunityName = + typeof state.title === "string" && state.title.trim().length > 0; + // Prefill merges (shallow) with current state. When we have to bounce the + // user to the community stage first, pin a pendingTemplateAction so + // `/create/review` knows to skip past itself to `core-values` later. + updateState({ + ...prefill, + ...(hasCommunityName + ? { pendingTemplateAction: undefined } + : { + pendingTemplateAction: { + slug: templateReviewSlug, + mode: "customize", + }, + }), + }); + router.push( + hasCommunityName ? "/create/core-values" : "/create/informational", + ); + }, [router, state.title, templateReviewSlug, updateState]); + + /** + * "Use without changes" from a template-review page. Drops users into the + * review-and-complete stage (`confirm-stakeholders` → `final-review`) so the + * publish flow — and its server-enforced sign-in gate (`publishRule` 401 → + * `openLogin`) — is reused. The template body becomes the rule document; + * the user's community name remains the rule title. + * + * Community-name branch: apply template body/summary immediately and jump + * to `confirm-stakeholders`. + * + * No-community-name branch: same template body/summary apply so state is + * ready, plus a `pendingTemplateAction` pin so `/create/review` later + * redirects past itself straight to `confirm-stakeholders` once community + * data is captured (see `CommunityReviewScreen`). Users aren't forced back + * through the template picker just to pick the same template again. + * + * Direct entry (marketing home "Popular templates" or `/templates` + * landed on directly) wipes the anonymous draft at the click site via + * `clearCreateFlowPersistedDrafts` before navigating, so `state.title` + * is empty and the no-community branch fires naturally. + */ + const handleUseTemplateWithoutChanges = useCallback(async () => { + if (!templateReviewSlug) return; + setTemplateReviewApplyError(null); + + setIsApplyingTemplate(true); + const loaded = await loadTemplateReviewBySlug(templateReviewSlug); + setIsApplyingTemplate(false); + if (loaded.ok === false) { + setTemplateReviewApplyError(loaded.message); return; } - const template: RuleTemplateDto = result; + const { template } = loaded; const doc = template.body; if (!doc || typeof doc !== "object" || Array.isArray(doc)) { setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed); return; } + const sectionsRaw = (doc as { sections?: unknown }).sections; + const sections = Array.isArray(sectionsRaw) + ? (sectionsRaw as Record[]) + : []; + if (sections.length === 0) { + setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed); + return; + } + + // Using the template verbatim: scrub any prior customize picks so they + // don't bleed into `document.coreValues` at publish time. + resetCustomRuleSelections(); + const summaryRaw = typeof template.description === "string" ? template.description.trim() : ""; - writeLastPublishedRule({ - id: `template:${template.slug}`, - title: template.title, - summary: summaryRaw.length > 0 ? summaryRaw : null, - document: doc as Record, + const hasCommunityName = + typeof state.title === "string" && state.title.trim().length > 0; + updateState({ + sections, + ...(summaryRaw.length > 0 ? { summary: summaryRaw } : {}), + ...(hasCommunityName + ? { pendingTemplateAction: undefined } + : { + pendingTemplateAction: { + slug: templateReviewSlug, + mode: "useWithoutChanges", + }, + }), }); - router.push("/create/completed"); - }, [router, templateReviewSlug]); + router.push( + hasCommunityName + ? "/create/confirm-stakeholders" + : "/create/informational", + ); + }, [ + resetCustomRuleSelections, + router, + state.title, + templateReviewSlug, + updateState, + ]); const runAuthenticatedExit = useCreateFlowExit({ state, @@ -360,8 +468,16 @@ function CreateFlowLayoutContent({ currentStep, ); - const footerPrimaryButtonClass = - "md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"; + /** + * Custom Rule stage "confirm selection" steps: all five render the same + * primary footer button, differing only by disable predicate and label. + * Driving JSX from a config keeps the five sites aligned — adding a new + * selection screen means one row here, not a new branch below. + */ + const customRuleConfirmFooter: CustomRuleConfirmFooterStep | undefined = + currentStep != null + ? CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP.get(currentStep) + : undefined; const hasTopOverlays = Boolean(draftSaveBannerMessage) || @@ -483,7 +599,7 @@ function CreateFlowLayoutContent({ palette="default" size="xsmall" disabled={isApplyingTemplate} - className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)] !text-white" + className={CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS} onClick={() => void handleUseTemplateWithoutChanges()} > {messages.create.templateReview.footer.useWithoutChanges} @@ -493,17 +609,8 @@ function CreateFlowLayoutContent({ palette="default" size="xsmall" disabled={isApplyingTemplate} - title={ - messages.create.templateReview.footer.customizeAriaHint - } - className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]" - onClick={() => { - if (!templateReviewSlug) return; - // Preserve template slug for a future customize / prefill ticket (informational does not read it yet). - router.push( - `/create/informational?template=${encodeURIComponent(templateReviewSlug)}`, - ); - }} + className={CREATE_FLOW_FOOTER_BUTTON_CLASS} + onClick={() => void handleCustomizeTemplate()} > {messages.create.templateReview.footer.customize} @@ -513,8 +620,12 @@ function CreateFlowLayoutContent({ buttonType="filled" palette="default" size="xsmall" - disabled={isPublishing} - className={footerPrimaryButtonClass} + disabled={ + isPublishing || + typeof state.title !== "string" || + state.title.trim().length === 0 + } + className={CREATE_FLOW_FOOTER_BUTTON_CLASS} onClick={() => { goToNextStep(); }} @@ -528,7 +639,7 @@ function CreateFlowLayoutContent({ palette="default" size="xsmall" disabled={isPublishing} - className={footerPrimaryButtonClass} + className={CREATE_FLOW_FOOTER_BUTTON_CLASS} onClick={() => { goToNextStep(); }} @@ -545,7 +656,7 @@ function CreateFlowLayoutContent({ communitySaveMagicLinkSuccess || !isValidCreateFlowSaveEmail(state.communitySaveEmail) } - className={footerPrimaryButtonClass} + className={CREATE_FLOW_FOOTER_BUTTON_CLASS} onClick={() => { void handleCommunitySaveMagicLinkSubmit(); }} @@ -562,8 +673,11 @@ function CreateFlowLayoutContent({ palette="default" size="xsmall" disabled={isPublishing} - className={footerPrimaryButtonClass} + className={CREATE_FLOW_FOOTER_BUTTON_CLASS} onClick={() => { + // Scrub any prior template-customize prefill so entering + // the custom-rule stage from review is always a clean slate. + resetCustomRuleSelections(); goToNextStep(); }} > @@ -574,93 +688,35 @@ function CreateFlowLayoutContent({ palette="default" size="xsmall" disabled={isPublishing} - className={footerPrimaryButtonClass} + className={CREATE_FLOW_FOOTER_BUTTON_CLASS} onClick={() => { - router.push("/templates"); + // `fromFlow=1` tells `/templates` to skip the fresh-slate + // draft clear it normally runs on template click, so the + // user's in-progress Create Community stage survives this + // 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"); }} > {footer.createFromTemplate} - ) : currentStep === "core-values" && nextStep ? ( + ) : customRuleConfirmFooter && nextStep ? ( - ) : currentStep === "communication-methods" && nextStep ? ( - - ) : currentStep === "membership-methods" && nextStep ? ( - - ) : currentStep === "decision-approaches" && nextStep ? ( - - ) : currentStep === "conflict-management" && nextStep ? ( - ) : nextStep ? (