Template flow cleaned up

This commit is contained in:
adilallo
2026-04-20 16:45:15 -06:00
parent d3bb8cdd0f
commit c08cd62872
32 changed files with 1545 additions and 254 deletions
+165 -109
View File
@@ -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<string, unknown>[])
: [];
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<string, unknown>,
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}
</Button>
@@ -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}
</Button>
</div>
) : currentStep === "core-values" && nextStep ? (
) : customRuleConfirmFooter && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
(state.selectedCoreValueIds?.length ?? 0) === 0
customRuleConfirmFooter.selectionIds(state).length === 0
}
className={footerPrimaryButtonClass}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmCoreValues}
</Button>
) : currentStep === "communication-methods" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
(state.selectedCommunicationMethodIds?.length ?? 0) === 0
}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmCommunication}
</Button>
) : currentStep === "membership-methods" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
(state.selectedMembershipMethodIds?.length ?? 0) === 0
}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmMembership}
</Button>
) : currentStep === "decision-approaches" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
(state.selectedDecisionApproachIds?.length ?? 0) === 0
}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmDecisionApproaches}
</Button>
) : currentStep === "conflict-management" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
(state.selectedConflictManagementIds?.length ?? 0) === 0
}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmConflictManagement}
{footer[customRuleConfirmFooter.footerMessageKey]}
</Button>
) : nextStep ? (
<Button
@@ -668,7 +724,7 @@ function CreateFlowLayoutContent({
palette="default"
size="xsmall"
disabled={isPublishing}
className={footerPrimaryButtonClass}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
if (currentStep === "final-review") {
void handleFinalize();
@@ -167,12 +167,34 @@ export function CreateFlowProvider({
clearCoreValueDetailsLocalStorage();
}, []);
// Keys produced by the Create Custom stage screens + `buildTemplateCustomizePrefill`.
// Kept in sync with `CreateFlowState` comments marked "Create Custom —".
const resetCustomRuleSelections = useCallback(() => {
setState((prev) => {
const {
selectedCoreValueIds: _a,
coreValuesChipsSnapshot: _b,
coreValueDetailsByChipId: _c,
selectedCommunicationMethodIds: _d,
selectedMembershipMethodIds: _e,
selectedDecisionApproachIds: _f,
selectedConflictManagementIds: _g,
...rest
} = prev;
return rest;
});
// Effect on `state.coreValueDetailsByChipId` clears its dedicated
// localStorage key when the field goes undefined, so we don't need to
// touch `clearCoreValueDetailsLocalStorage()` directly here.
}, []);
const contextValue: CreateFlowContextValue = {
state,
currentStep,
updateState,
replaceState,
clearState,
resetCustomRuleSelections,
interactionTouched,
markCreateFlowInteraction,
};
@@ -1,5 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import RuleCard from "../../../../components/cards/RuleCard";
import { useTranslation } from "../../../../contexts/MessagesContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
@@ -11,21 +13,65 @@ import {
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../components/createFlowLayoutTokens";
/**
* 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",
};
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
export function CommunityReviewScreen() {
const router = useRouter();
const lgUp = useCreateFlowLgUp();
const t = useTranslation("create.community.review");
const { state } = useCreateFlow();
const { state, updateState } = useCreateFlow();
/**
* If the user picked "Customize" or "Use without changes" from a template
* before entering community stage, we pinned `pendingTemplateAction` so
* this screen can skip itself — they already expressed their intent, no
* reason to make them re-pick from the review footer. We `replace` (not
* `push`) so Back from the destination goes to `community-save` instead of
* bouncing through here again. The action is cleared synchronously via
* `updateState` to guarantee the redirect only fires once: later visits to
* `/create/review` (e.g. navigating here directly) render normally.
*
* Ref guard covers React 18 StrictMode's double-mount in dev so we don't
* fire `router.replace` twice on the same transition.
*/
const firedRedirectRef = useRef(false);
useEffect(() => {
if (firedRedirectRef.current) return;
const pending = state.pendingTemplateAction;
if (!pending) return;
const target = PENDING_TEMPLATE_REDIRECT_TARGET[pending.mode];
if (!target) return;
firedRedirectRef.current = true;
updateState({ pendingTemplateAction: undefined });
router.replace(target);
}, [router, state.pendingTemplateAction, updateState]);
const cardTitle =
typeof state.title === "string" && state.title.trim().length > 0
? state.title.trim()
: t("ruleCard.title");
/**
* No placeholder fallback: if the user skipped `community-context`, leave
* the card description off rather than render the old "Mutual Aid Monday
* is a grassroots community…" sample, which read as real user copy.
*/
const cardDescription =
typeof state.communityContext === "string" &&
state.communityContext.trim().length > 0
? state.communityContext.trim()
: t("ruleCard.description");
: undefined;
return (
<CreateFlowStepShell
@@ -10,9 +10,13 @@ import {
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
CreateFlowLockupCardStepShell,
} from "../../components/CreateFlowLockupCardStepShell";
import {
buildFinalReviewCategoriesFromState,
type FinalReviewCategoryRow,
} from "../../../../../lib/create/buildFinalReviewCategories";
function buildFinalReviewCategories(
rows: { name: string; chips: string[] }[],
rows: readonly FinalReviewCategoryRow[],
): Category[] {
return rows.map((cat) => ({
name: cat.name,
@@ -24,16 +28,58 @@ function buildFinalReviewCategories(
}));
}
/**
* `finalReview.json.categories` ships a demo ordering + localized names
* (Values / Communication / Membership / Decision-making / Conflict
* management). We reuse that ordering for the state-derived rows so the
* RuleCard layout stays stable across customize / use-without-changes /
* plain-custom flows, and fall back to the demo chips when state resolves
* to nothing selected.
*/
function readFallbackCategoryNames(
categories: readonly { name: string; chips: readonly string[] }[],
): {
names: {
values: string;
communication: string;
membership: string;
decisions: string;
conflict: string;
};
rows: FinalReviewCategoryRow[];
} {
const get = (i: number): string =>
typeof categories[i]?.name === "string" ? categories[i].name : "";
return {
names: {
values: get(0),
communication: get(1),
membership: get(2),
decisions: get(3),
conflict: get(4),
},
rows: categories.map((c) => ({ name: c.name, chips: [...c.chips] })),
};
}
export function FinalReviewScreen() {
const { state } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.reviewAndComplete.finalReview");
const m = useMessages();
const finalReviewCategories = useMemo(
() => buildFinalReviewCategories(m.create.reviewAndComplete.finalReview.categories),
[m.create.reviewAndComplete.finalReview.categories],
);
const finalReviewCategories = useMemo(() => {
const { names, rows: fallbackRows } = readFallbackCategoryNames(
m.create.reviewAndComplete.finalReview.categories,
);
const derived = buildFinalReviewCategoriesFromState(state, names);
// When a user lands on final review with nothing actually selected (e.g.
// direct-nav during dev), keep the shipped demo chips rather than render
// an empty card — matches prior behavior for that edge case.
return buildFinalReviewCategories(
derived.length > 0 ? derived : fallbackRows,
);
}, [m.create.reviewAndComplete.finalReview.categories, state]);
const ruleCardTitle = useMemo(() => {
const raw = typeof state.title === "string" ? state.title.trim() : "";
+28 -23
View File
@@ -93,6 +93,18 @@ export interface CreateFlowState {
selectedDecisionApproachIds?: string[];
/** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.customRule.conflictManagement` presets. */
selectedConflictManagementIds?: string[];
/**
* Set when a user picks a template (Customize or Use without changes) before
* completing the community stage. The community-review screen consumes this
* to `router.replace` past `/create/review` to the correct downstream step
* (`core-values` for customize; `confirm-stakeholders` for use-without-changes)
* once community data is captured. Cleared the moment the redirect fires, so
* later visits to `/create/review` render normally.
*/
pendingTemplateAction?: {
slug: string;
mode: "customize" | "useWithoutChanges";
};
currentStep?: CreateFlowStep;
/** Section drafts; structure will tighten as steps persist real shapes. */
sections?: Record<string, unknown>[];
@@ -115,30 +127,23 @@ export interface CreateFlowContextValue {
/** Reset flow state and clear anonymous localStorage draft keys when present. */
clearState: () => void;
/**
* True after the user edits any template control (pages use local state until wired to `state`).
* Drives Save & Exit visibility together with hasCreateFlowUserInput (utils/hasCreateFlowUserInput.ts).
* Scrub only the Create Custom stage selections (core values, communication,
* membership, decision approaches, conflict management) from state. Keeps
* the community stage (title, context, size, structure) intact so users can
* re-enter the custom-rule flow from `/create/review` with a clean slate
* after a prior "Customize template" prefill.
*/
resetCustomRuleSelections: () => void;
/**
* True after the user has edited any control inside the wizard. Screens flip
* it via {@link markCreateFlowInteraction} from their event handlers.
*
* Current consumer: {@link SignedInDraftHydration} — when a signed-in user
* has already started editing, we skip replaying their server draft on top
* of in-progress local state. Save & Exit visibility is driven by step
* index (`SAVE_EXIT_FROM_STEP_INDEX` in `CreateFlowLayoutClient`), not this
* flag.
*/
interactionTouched: boolean;
markCreateFlowInteraction: () => void;
}
/**
* Base props interface for page templates
* Will be expanded in template implementation tickets (CR-51-55)
*/
export interface PageTemplateProps {
// Base props for all page templates
// Will be expanded in template tickets
}
/**
* Navigation handlers interface
* Will be implemented in CR-56
*/
export interface NavigationHandlers {
goToNextStep: () => void;
goToPreviousStep: () => void;
goToStep: (_step: CreateFlowStep) => void;
canGoNext: () => boolean;
canGoBack: () => boolean;
}
@@ -0,0 +1,22 @@
import { clearAnonymousCreateFlowStorage } from "./anonymousDraftStorage";
import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorage";
/**
* 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.
*
* 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.
*/
export function clearCreateFlowPersistedDrafts(): void {
clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage();
}
@@ -0,0 +1,24 @@
/**
* Typography + padding overrides applied to the primary/secondary buttons
* rendered inside `CreateFlowFooter`. The footer slot expects a compact
* size regardless of the default `<Button size="xsmall">` output, and both
* the Create Community / Custom Rule / Review flows and the template-review
* footer share the same override string — keeping it here prevents drift
* between those two call sites.
*
* The `!` prefixes bypass Button's own size tokens; the extra spacing vars
* mirror the Figma compact footer button spec. When the design system
* exposes a native size that matches, this module should collapse.
*/
export const CREATE_FLOW_FOOTER_BUTTON_CLASS =
"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)]";
/**
* Template-review "Use without changes" (ghost variant) renders on a dark
* backdrop and needs an explicit text-color override in addition to the
* shared compact sizing. Composed from the base class so any future tweak
* to typography/padding propagates automatically.
*/
export const CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS = `${CREATE_FLOW_FOOTER_BUTTON_CLASS} !text-white`;
@@ -0,0 +1,69 @@
import type { CreateFlowState, CreateFlowStep } from "../types";
import type footerMessages from "../../../../messages/en/create/footer.json";
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.
*
* `selectionIds` returns the currently-selected ids array from flow
* state for that step (empty array when nothing has been selected or
* the field hasn't been touched). Returning a fresh array on empty is
* fine: these are read-only length checks, not memo keys.
*
* Note: the Confirm Stakeholders step has its own dedicated label copy
* and is not gated on a selection count, so it stays out of this table.
* Template-review and Community Save also have bespoke two-button
* layouts and are intentionally excluded.
*/
export type CustomRuleConfirmFooterStep = {
step: Extract<
CreateFlowStep,
| "core-values"
| "communication-methods"
| "membership-methods"
| "decision-approaches"
| "conflict-management"
>;
footerMessageKey: FooterMessageKey;
selectionIds: (state: CreateFlowState) => readonly string[];
};
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;
export const CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP: ReadonlyMap<
CreateFlowStep,
CustomRuleConfirmFooterStep
> = new Map(CUSTOM_RULE_CONFIRM_FOOTER_STEPS.map((e) => [e.step, e]));
@@ -1,27 +0,0 @@
import type { CreateFlowState } from "../types";
const IGNORED_KEYS = new Set<string>(["currentStep"]);
function valueIndicatesUserInput(value: unknown): boolean {
if (value === undefined || value === null) return false;
if (typeof value === "string") return value.trim().length > 0;
if (typeof value === "boolean") return value;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.length > 0;
if (typeof value === "object") {
return Object.keys(value as object).length > 0;
}
return false;
}
/**
* True once the user has entered meaningful create-flow data (not only navigation metadata).
* Used to show "Save & Exit" vs a plain "Exit" that confirms data loss.
*/
export function hasCreateFlowUserInput(state: CreateFlowState): boolean {
for (const key of Object.keys(state)) {
if (IGNORED_KEYS.has(key)) continue;
if (valueIndicatesUserInput(state[key])) return true;
}
return false;
}
@@ -1,9 +1,11 @@
"use client";
import { useRouter } from "next/navigation";
import { Suspense } from "react";
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 { useTranslation } from "../../contexts/MessagesContext";
export interface TemplatesPageClientProps {
@@ -17,7 +19,6 @@ export interface TemplatesPageClientProps {
export default function TemplatesPageClient({
initialGridEntries,
}: TemplatesPageClientProps) {
const router = useRouter();
const t = useTranslation("pages.templates");
return (
@@ -39,16 +40,56 @@ export default function TemplatesPageClient({
/>
</div>
<div className="mt-6 min-[1024px]:mt-8">
<GovernanceTemplateGrid
entries={initialGridEntries}
onTemplateClick={(slug) => {
router.push(
`/create/review-template/${encodeURIComponent(slug)}`,
);
}}
/>
{/* Suspense boundary required by `useSearchParams` below
(Next.js 15+ static-generation contract). */}
<Suspense
fallback={<TemplatesGrid entries={initialGridEntries} fromFlow={false} />}
>
<TemplatesGridWithSearchParams entries={initialGridEntries} />
</Suspense>
</div>
</div>
</div>
);
}
/**
* 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.
*/
function TemplatesGridWithSearchParams({
entries,
}: {
entries: TemplateGridCardEntry[];
}) {
const searchParams = useSearchParams();
const fromFlow = searchParams.get("fromFlow") === "1";
return <TemplatesGrid entries={entries} fromFlow={fromFlow} />;
}
function TemplatesGrid({
entries,
fromFlow,
}: {
entries: TemplateGridCardEntry[];
fromFlow: boolean;
}) {
const router = useRouter();
return (
<GovernanceTemplateGrid
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();
}
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
}}
/>
);
}
@@ -1,6 +1,6 @@
"use client";
import { memo } from "react";
import { memo, useCallback } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuthModal } from "../../../contexts/AuthModalContext";
import { useTranslation } from "../../../contexts/MessagesContext";
@@ -9,6 +9,8 @@ import Button from "../../buttons/Button";
import AvatarContainer from "../../utility/AvatarContainer";
import Avatar from "../../icons/Avatar";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import { clearAnonymousCreateFlowStorage } from "../../../(app)/create/utils/anonymousDraftStorage";
import { clearCoreValueDetailsLocalStorage } from "../../../(app)/create/utils/coreValueDetailsLocalStorage";
import { TopNavView } from "./TopNav.view";
import type { TopNavProps, NavSize } from "./TopNav.types";
@@ -25,6 +27,20 @@ const TopNavContainer = memo<TopNavProps>(
const { openLogin } = useAuthModal();
const t = useTranslation("header");
/**
* TopNav is hidden on `/create` routes by ConditionalNavigationClient, so
* this button is always clicked from outside the wizard — there is no
* mounted CreateFlowProvider to reset. Wiping the anonymous draft keys
* here guarantees a fresh start; the provider that mounts on `/create`
* will read empty storage. Server drafts (signed-in Save & Exit) are
* left alone — they're intentional persistence the user opted into.
*/
const handleCreateRuleClick = useCallback(() => {
clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage();
router.push("/create");
}, [router]);
// Schema markup for site navigation
const schemaData = {
"@context": "https://schema.org",
@@ -197,7 +213,7 @@ const TopNavContainer = memo<TopNavProps>(
size={buttonSize}
buttonType={buttonType}
palette={palette}
onClick={() => router.push("/create")}
onClick={handleCreateRuleClick}
ariaLabel={t("ariaLabels.createNewRule")}
>
{renderAvatarGroup(containerSize, avatarSize)}
@@ -3,6 +3,7 @@
import { memo, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { logger } from "../../../../lib/logger";
import { clearCreateFlowPersistedDrafts } from "../../../(app)/create/utils/clearCreateFlowPersistedDrafts";
import {
fetchTemplates,
isTemplatesFetchAborted,
@@ -89,6 +90,11 @@ const RuleStackContainer = memo<RuleStackProps>(
}
}
logger.debug(`${slug} template clicked`);
// Marketing entry is always a *fresh* create-flow start: wipe any
// in-progress anonymous draft so a stale community name/structure from
// an earlier abandoned session can't short-circuit the `state.title`
// check in `handleCustomizeTemplate` / `handleUseTemplateWithoutChanges`.
clearCreateFlowPersistedDrafts();
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
};