"use client"; import { Suspense, useCallback, useEffect, useState, type ReactNode, } from "react"; import { usePathname, useRouter } from "next/navigation"; import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext"; import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation"; import { useCreateFlowExit } from "./hooks/useCreateFlowExit"; import CreateFlowTopNav from "../../components/utility/CreateFlowTopNav"; import { getNextStep, getStepIndex } from "./utils/flowSteps"; import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress"; import { createFlowStepUsesCenteredTextLayout, createFlowStepUsesCardLayout, } from "./utils/createFlowScreenRegistry"; import CreateFlowFooter from "../../components/utility/CreateFlowFooter"; import Button from "../../components/buttons/Button"; import { buildPublishPayload } from "../../../lib/create/buildPublishPayload"; import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail"; import { fetchAuthSession, publishRule, requestMagicLink, } from "../../../lib/create/api"; import { safeInternalPath } from "../../../lib/safeInternalPath"; import { clearAnonymousCreateFlowStorage, setTransferPendingFlag, } from "./utils/anonymousDraftStorage"; import { deleteServerDraft } from "../../../lib/create/api"; import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule"; import { fetchTemplateBySlug, type RuleTemplateDto, } from "../../../lib/create/fetchTemplates"; import messages from "../../../messages/en/index"; 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 { 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 { openLogin } = useAuthModal(); const skipCommunitySave = sessionResolved && Boolean(sessionUser); const { currentStep, nextStep, previousStep, goToNextStep, goToPreviousStep, } = useCreateFlowNavigation( skipCommunitySave ? { skipCommunitySave: true } : undefined, ); const { state, clearState, updateState } = useCreateFlow(); const { draftSaveBannerMessage, setDraftSaveBannerMessage } = useCreateFlowDraftSaveBanner(); const [publishBannerMessage, setPublishBannerMessage] = useState< string | null >(null); const [isPublishing, setIsPublishing] = useState(false); const [templateReviewApplyError, setTemplateReviewApplyError] = useState< string | null >(null); const [isApplyingTemplate, setIsApplyingTemplate] = useState(false); const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] = useState(false); const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState< string | null >(null); const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] = useState(false); const templateReviewMatch = pathname?.match( /\/create\/review-template\/([^/?#]+)/, ); const templateReviewSlug = templateReviewMatch?.[1] ? decodeURIComponent(templateReviewMatch[1]) : null; /** Match anywhere in path so locale/basePath variants still get template footer + layout. */ const isTemplateReviewRoute = Boolean( pathname?.includes("/create/review-template/"), ); const handleFinalize = useCallback(async () => { setPublishBannerMessage(null); const payloadResult = buildPublishPayload(state); if (payloadResult.ok === false) { setPublishBannerMessage( payloadResult.error === "missingCommunityName" ? messages.create.reviewAndComplete.publish.missingCommunityName : payloadResult.error, ); return; } const { title, summary, document: ruleDocument } = payloadResult; setIsPublishing(true); const publishResult = await publishRule({ title, summary, document: ruleDocument, }); setIsPublishing(false); if (publishResult.ok === true) { writeLastPublishedRule({ id: publishResult.id, title, summary: summary ?? null, document: ruleDocument, }); router.push("/create/completed"); return; } if (publishResult.status === 401) { openLogin({ variant: "default", nextPath: "/create/final-review?syncDraft=1", backdropVariant: "blurredYellow", }); return; } setPublishBannerMessage( publishResult.error.trim() !== "" ? publishResult.error : messages.create.reviewAndComplete.publish.genericPublishFailed, ); }, [state, router, openLogin]); const handleUseTemplateWithoutChanges = useCallback(async () => { if (!templateReviewSlug) return; setTemplateReviewApplyError(null); setIsApplyingTemplate(true); const result = await fetchTemplateBySlug(templateReviewSlug); setIsApplyingTemplate(false); if (result === null) { setTemplateReviewApplyError(messages.create.templateReview.errors.notFound); return; } if ("error" in result) { setTemplateReviewApplyError(result.error); return; } const template: RuleTemplateDto = result; const doc = template.body; if (!doc || typeof doc !== "object" || Array.isArray(doc)) { setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed); return; } 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, }); router.push("/create/completed"); }, [router, templateReviewSlug]); 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("/"); 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 ?? "/create"}?syncDraft=1`, backdropVariant: "blurredYellow", }); return; } if (!sessionUser) return; await runAuthenticatedExit(opts); }; useEffect(() => { if ( sessionResolved && sessionUser && currentStep === "community-save" ) { router.replace("/create/review"); } }, [sessionResolved, sessionUser, currentStep, router]); useEffect(() => { if (currentStep !== "community-save") { setCommunitySaveMagicLinkError(null); setCommunitySaveMagicLinkSuccess(false); setCommunitySaveMagicLinkSubmitting(false); } }, [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 isFinalReviewStep = currentStep === "final-review"; 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 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" : isFinalReviewStep || 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; const proportionBarProgress = getProportionBarProgressForCreateFlowStep( 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)]"; const hasTopOverlays = Boolean(draftSaveBannerMessage) || Boolean(publishBannerMessage) || Boolean(templateReviewApplyError) || Boolean(communitySaveMagicLinkError) || Boolean(communitySaveMagicLinkSuccess); return (
{hasTopOverlays ? (
{draftSaveBannerMessage ? (
setDraftSaveBannerMessage(null)} className="w-full" />
) : null} {publishBannerMessage ? (
setPublishBannerMessage(null)} className="w-full" />
) : null} {templateReviewApplyError ? (
setTemplateReviewApplyError(null)} className="w-full" />
) : null} {communitySaveMagicLinkError ? (
setCommunitySaveMagicLinkError(null)} className="w-full" />
) : null} {communitySaveMagicLinkSuccess ? (
setCommunitySaveMagicLinkSuccess(false)} className="w-full" />
) : null}
) : null} router.push("/create/final-review") : 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 ? (
) : currentStep === "core-values" && nextStep ? ( ) : currentStep === "communication-methods" && nextStep ? ( ) : currentStep === "membership-methods" && nextStep ? ( ) : currentStep === "decision-approaches" && nextStep ? ( ) : currentStep === "conflict-management" && nextStep ? ( ) : nextStep ? ( ) : null } onBackClick={ isTemplateReviewRoute ? () => router.push("/") : previousStep ? goToPreviousStep : undefined } /> )} ); } export default function CreateFlowLayoutClient({ children, }: { children: ReactNode; }) { return {children}; }