Template flow cleaned up
This commit is contained in:
@@ -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
@@ -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)}`);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user