"use client"; import { Suspense, useCallback, useEffect, useState, type ReactNode, } from "react"; 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, 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, createFlowStepUsesCardLayout, } from "./utils/createFlowScreenRegistry"; import Button from "../../components/buttons/Button"; import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail"; import { fetchAuthSession, requestMagicLink, } from "../../../lib/create/api"; import { safeInternalPath } from "../../../lib/safeInternalPath"; import { clearAnonymousCreateFlowStorage, setTransferPendingFlag, } from "./utils/anonymousDraftStorage"; import { createFlowStateFromPublishedRule, isPublishedRuleSelectionMissing, methodSectionsPinsFromPublishedHydratePatch, } from "../../../lib/create/publishedDocumentToCreateFlowState"; import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets"; import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule"; import { deleteServerDraft } from "../../../lib/create/api"; 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, methodCardFacetSectionForConfirmStep, type CustomRuleConfirmFooterStep, } from "./utils/customRuleConfirmFooterSteps"; import { getDefaultFooterLabel } from "./utils/createFlowFooterLabels"; import { useAuthModal } from "../../contexts/AuthModalContext"; import { useMessages, useTranslation } from "../../contexts/MessagesContext"; import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; import { SignedInDraftHydration } from "./SignedInDraftHydration"; import Alert from "../../components/modals/Alert"; import Share from "../../components/modals/Share"; import { CreateFlowDraftSaveBannerProvider, useCreateFlowDraftSaveBanner, } from "./context/CreateFlowDraftSaveBannerContext"; /** First step where Save & Exit is offered (first Create Community select per Figma). */ const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure"); function CreateFlowSessionShell({ children }: { children: ReactNode }) { const [sessionUser, setSessionUser] = useState< { id: string; email: string } | null | undefined >(undefined); useEffect(() => { let cancelled = false; void fetchAuthSession().then(({ user }) => { if (!cancelled) setSessionUser(user); }); return () => { cancelled = true; }; }, []); const sessionResolved = sessionUser !== undefined; // Mirror in-progress draft to localStorage for ALL visitors once we know who // they are. Refresh-survival is the same UX for guest and signed-in users; // signed-in users additionally get an explicit "Save & Exit" that PUTs to // the server (handled in `useCreateFlowExit`). const enableLocalDraftMirroring = sessionResolved; return ( {children} ); } function CreateFlowLayoutContent({ children, sessionUser, sessionResolved, }: { children: ReactNode; sessionUser: { id: string; email: string } | null | undefined; sessionResolved: boolean; }) { const { create } = useMessages(); const footer = create.footer; const communitySaveMessages = create.community.communitySave; 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 { currentStep, nextStep, previousStep, goToNextStep, goToPreviousStep, templateReviewFooterBackToCreateReview, } = useCreateFlowNavigation( skipCommunitySave ? { skipCommunitySave: true } : undefined, ); const { state, clearState, updateState, resetCustomRuleSelections, setMethodSectionsPinCommitted, replaceState, } = useCreateFlow(); const { draftSaveBannerMessage, setDraftSaveBannerMessage } = useCreateFlowDraftSaveBanner(); const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] = useState(false); const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState< string | null >(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, updateState, loginReturnPath, }); const { isTemplateReviewRoute, templateReviewSlug, isApplyingTemplate, templateReviewApplyError, setTemplateReviewApplyError, handleCustomize: handleCustomizeTemplate, handleUseWithoutChanges: handleUseTemplateWithoutChanges, } = useTemplateReviewActions({ pathname, state, updateState, replaceState, router, }); const runAuthenticatedExit = useCreateFlowExit({ state, currentStep, clearState, router, user: sessionUser ?? null, setDraftSaveBannerMessage, }); const handleExit = async (opts?: { saveDraft?: boolean }) => { const saveDraft = opts?.saveDraft ?? false; if (!sessionResolved) return; // Exit from `/create/completed` is post-publish: the rule is saved, so we // skip the leave-confirm + login prompt and just wipe the in-flight draft. // 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(CREATE_ROUTES.root); return; } if (sessionUser === null) { if (saveDraft) return; const returnToTemplateReview = templateReviewSlug != null ? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1` : null; openLogin({ variant: "saveProgress", nextPath: returnToTemplateReview ?? `${pathname != null && pathname.length > 0 ? pathname : CREATE_ROUTES.createRoot}?${CREATE_FLOW_SYNC_DRAFT_QUERY}=${CREATE_FLOW_SYNC_DRAFT_VALUE}`, backdropVariant: "blurredYellow", }); return; } if (!sessionUser) return; await runAuthenticatedExit(opts); }; useEffect(() => { if ( sessionResolved && sessionUser && currentStep === "community-save" ) { router.replace(CREATE_ROUTES.review); } }, [sessionResolved, sessionUser, currentStep, router]); useEffect(() => { if (currentStep !== "community-save") { setCommunitySaveMagicLinkError(null); setCommunitySaveMagicLinkSuccess(false); setCommunitySaveMagicLinkSubmitting(false); } }, [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 && !isPublishedRuleSelectionMissing(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, ]); useEffect(() => { if (currentStep !== "completed") { setCompletedFlowBanner(null); } }, [currentStep]); const handleCommunitySaveMagicLinkSubmit = useCallback(async () => { setCommunitySaveMagicLinkError(null); setCommunitySaveMagicLinkSuccess(false); const raw = state.communitySaveEmail; const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : ""; if (!isValidCreateFlowSaveEmail(trimmed)) return; setCommunitySaveMagicLinkSubmitting(true); try { const stepAfterSave = getNextStep("community-save"); const segment = stepAfterSave ?? "review"; const rawNext = `/create/${segment}?syncDraft=1`; const nextPath = safeInternalPath(rawNext); const result = await requestMagicLink(trimmed, nextPath); if (result.ok === false) { if (result.retryAfterMs != null && result.retryAfterMs > 0) { const seconds = Math.ceil(result.retryAfterMs / 1000); setCommunitySaveMagicLinkError( tLogin("errors.rateLimited").replace("{seconds}", String(seconds)), ); } else { setCommunitySaveMagicLinkError( result.error || tLogin("errors.generic"), ); } return; } setTransferPendingFlag(); updateState({ communitySaveEmail: trimmed }); setCommunitySaveMagicLinkSuccess(true); } catch { setCommunitySaveMagicLinkError(tLogin("errors.network")); } finally { setCommunitySaveMagicLinkSubmitting(false); } }, [state.communitySaveEmail, tLogin, updateState]); const isCompletedStep = currentStep === "completed"; const isRightRailStep = currentStep === "decision-approaches"; 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 = createFlowStepUsesSelectSplitScroll( currentStep, ); const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1; /** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */ const mainContentClass = isCompletedStep ? "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" : isFinalReviewLike || isCardLayoutStep || isTemplateReviewRoute ? "items-start justify-center overflow-y-auto" : "items-start justify-center overflow-y-auto md:items-center"; const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep); const mainMaxMdJustify = isTextStep && !isCompletedStep && !isRightRailStep ? "max-md:justify-center" : "max-md:justify-start"; const mainMaxMdCross = isCompletedStep ? "max-md:flex-col max-md:items-stretch" : "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 || currentStep === "edit-rule"); const proportionBarProgress = getProportionBarProgressForCreateFlowStep( currentStep, ); /** * 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; /** 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 above the main column; order is top → bottom. */ const topBanners: Array<{ key: string; status: "danger" | "positive"; title: string; description?: string; onClose: () => void; }> = [ draftSaveBannerMessage ? { key: "draftSave", status: "danger" as const, title: messages.create.topNav.draftSaveBannerTitle, description: draftSaveBannerMessage, onClose: () => setDraftSaveBannerMessage(null), } : null, publishBannerMessage ? { key: "publish", status: "danger" as const, title: messages.create.reviewAndComplete.publish.finalizeBannerTitle, description: publishBannerMessage, onClose: () => setPublishBannerMessage(null), } : null, templateReviewApplyError ? { key: "templateApply", status: "danger" as const, title: messages.create.templateReview.errors.applyFailed, description: templateReviewApplyError, onClose: () => setTemplateReviewApplyError(null), } : null, communitySaveMagicLinkError ? { key: "magicLinkError", status: "danger" as const, title: communitySaveMessages.magicLinkErrorTitle, description: communitySaveMagicLinkError, onClose: () => setCommunitySaveMagicLinkError(null), } : null, communitySaveMagicLinkSuccess ? { key: "magicLinkSuccess", status: "positive" as const, title: communitySaveMessages.magicLinkSuccessTitle, description: communitySaveMessages.magicLinkSuccessDescription, onClose: () => setCommunitySaveMagicLinkSuccess(false), } : null, completedFlowBanner ? { key: `completedFlow-${completedFlowBanner.key}`, status: completedFlowBanner.status, title: completedFlowBanner.title, description: completedFlowBanner.description, onClose: () => setCompletedFlowBanner(null), } : null, ].filter((b): b is NonNullable => b !== null); return (
{topBanners.length > 0 ? (
{topBanners.map((b) => (
))}
) : null} setShareModalOpen(false)} onCopyLink={() => void copyPublishedRuleLink()} onEmailShare={mailtoPublishedRule} onSignalShare={() => void sharePublishedRuleViaSignal()} onSlackShare={() => void sharePublishedRuleViaSlack()} onDiscordShare={() => void sharePublishedRuleViaDiscord()} /> void handleOpenCompletedShareModal() : undefined } onSelectExportFormat={ isCompletedStep ? onCompletedExportFormat : undefined } onEdit={ isCompletedStep ? () => { const last = readLastPublishedRule(); if (!last) return; updateState({ editingPublishedRuleId: last.id, sections: [], }); router.push(createFlowStepPath("edit-rule")); } : undefined } onExit={(opts) => void handleExit(opts)} buttonPalette={isCompletedStep ? "inverse" : undefined} className={`shrink-0 ${ isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : "" }`.trim()} />
{children}
{!isCompletedStep && (
) : currentStep === "community-name" && nextStep ? ( ) : currentStep === "community-save" && nextStep ? (
) : currentStep === "review" && nextStep ? (
) : showCustomRuleFooterConfirm && customRuleConfirmFooter ? ( ) : nextStep || isFinalReviewLike ? ( ) : null } onBackClick={ isTemplateReviewRoute ? () => router.push( templateReviewFooterBackToCreateReview ? CREATE_ROUTES.review : CREATE_ROUTES.root, ) : reviewReturnTarget ? () => { router.push( createFlowStepPathAfterStrippingReviewReturn( reviewReturnTarget, searchParams, ), ); } : previousStep ? goToPreviousStep : undefined } /> )} ); } export default function CreateFlowLayoutClient({ children, }: { children: ReactNode; }) { return {children}; }