Edit flow configured

This commit is contained in:
adilallo
2026-04-29 18:29:16 -06:00
parent 3a9727bceb
commit fc845d8308
39 changed files with 681 additions and 165 deletions
+56 -8
View File
@@ -7,7 +7,7 @@ import {
useState,
type ReactNode,
} from "react";
import { usePathname, useRouter } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
@@ -15,7 +15,7 @@ import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
import CreateFlowFooter from "../../components/navigation/CreateFlowFooter";
import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav";
import { getNextStep, getStepIndex } from "./utils/flowSteps";
import { getNextStep, getStepIndex, parseReviewReturnSearchParam, CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "./utils/flowSteps";
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
import {
createFlowStepUsesCenteredTextLayout,
@@ -42,6 +42,7 @@ import {
} from "./utils/createFlowFooterClassNames";
import {
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
methodCardFacetSectionForConfirmStep,
type CustomRuleConfirmFooterStep,
} from "./utils/customRuleConfirmFooterSteps";
import { getDefaultFooterLabel } from "./utils/createFlowFooterLabels";
@@ -111,6 +112,8 @@ function CreateFlowLayoutContent({
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 {
@@ -123,7 +126,7 @@ function CreateFlowLayoutContent({
} = useCreateFlowNavigation(
skipCommunitySave ? { skipCommunitySave: true } : undefined,
);
const { state, clearState, updateState, resetCustomRuleSelections } =
const { state, clearState, updateState, resetCustomRuleSelections, setMethodSectionsPinCommitted } =
useCreateFlow();
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
useCreateFlowDraftSaveBanner();
@@ -253,13 +256,19 @@ function CreateFlowLayoutContent({
if (titleOk && editingId === last.id && sectionsClear) {
return;
}
updateState(createFlowStateFromPublishedRule(last));
updateState({
...createFlowStateFromPublishedRule(last),
/** Keep UI-only facet pin flags across published re-hydration (wizard draft field; not stored on publish). */
methodSectionsPinCommitted: state.methodSectionsPinCommitted,
});
}, [
currentStep,
router,
updateState,
state.editingPublishedRuleId,
state.title,
state.methodSectionsPinCommitted,
state.sections?.length,
]);
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
@@ -348,6 +357,13 @@ function CreateFlowLayoutContent({
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 rendered above the main column when any of the
@@ -590,7 +606,8 @@ function CreateFlowLayoutContent({
{footer.createFromTemplate}
</Button>
</div>
) : customRuleConfirmFooter && nextStep ? (
) : showCustomRuleFooterConfirm &&
customRuleConfirmFooter ? (
<Button
buttonType="filled"
palette="default"
@@ -601,6 +618,24 @@ function CreateFlowLayoutContent({
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
const cf = customRuleConfirmFooter;
const facet = methodCardFacetSectionForConfirmStep(cf.step);
if (facet != null && cf.selectionIds(state).length > 0) {
setMethodSectionsPinCommitted(facet, true);
}
if (reviewReturnTarget) {
const params = new URLSearchParams(
searchParams?.toString() ?? "",
);
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
const qs = params.toString();
router.push(
qs.length > 0
? `/create/${reviewReturnTarget}?${qs}`
: `/create/${reviewReturnTarget}`,
);
return;
}
goToNextStep();
}}
>
@@ -638,9 +673,22 @@ function CreateFlowLayoutContent({
? "/create/review"
: "/",
)
: previousStep
? goToPreviousStep
: undefined
: reviewReturnTarget
? () => {
const params = new URLSearchParams(
searchParams?.toString() ?? "",
);
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
const qs = params.toString();
router.push(
qs.length > 0
? `/create/${reviewReturnTarget}?${qs}`
: `/create/${reviewReturnTarget}`,
);
}
: previousStep
? goToPreviousStep
: undefined
}
/>
)}
@@ -77,6 +77,12 @@ export function SignedInDraftHydration({
return;
}
const urlStep = parseCreateFlowScreenFromPathname(pathname ?? null);
/** Owner “view published rule” shell — never merge server draft or redirect to `currentStep`. */
if (urlStep === "completed") {
return;
}
let cancelled = false;
setLoadingHydration(true);
@@ -10,6 +10,7 @@ import {
type ReactNode,
} from "react";
import type {
CreateFlowMethodCardFacetSection,
CreateFlowState,
CreateFlowContextValue,
CreateFlowStep,
@@ -137,6 +138,19 @@ export function CreateFlowProvider({
setInteractionTouched(true);
}, []);
const setMethodSectionsPinCommitted = useCallback(
(section: CreateFlowMethodCardFacetSection, committed: boolean) => {
setState((prevState) => ({
...prevState,
methodSectionsPinCommitted: {
...(prevState.methodSectionsPinCommitted ?? {}),
[section]: committed,
},
}));
},
[],
);
const updateState = useCallback((updates: Partial<CreateFlowState>) => {
setState((prevState) => {
const merged: CreateFlowState = { ...prevState, ...updates };
@@ -179,6 +193,7 @@ export function CreateFlowProvider({
selectedMembershipMethodIds: _e,
selectedDecisionApproachIds: _f,
selectedConflictManagementIds: _g,
methodSectionsPinCommitted: _h,
...rest
} = prev;
return rest;
@@ -195,6 +210,7 @@ export function CreateFlowProvider({
replaceState,
clearState,
resetCustomRuleSelections,
setMethodSectionsPinCommitted,
interactionTouched,
markCreateFlowInteraction,
};
@@ -6,6 +6,8 @@ import { useCreateFlow } from "../context/CreateFlowContext";
import type { CreateFlowStep } from "../types";
import {
type CreateFlowNavigationOptions,
type CreateFlowReviewReturnTarget,
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
buildTemplateReviewHref,
getNextStep,
getPreviousStep,
@@ -46,7 +48,10 @@ export function useCreateFlowNavigation(
currentStep: CreateFlowStep | null;
goToNextStep: () => void;
goToPreviousStep: () => void;
goToStep: (_step: CreateFlowStep) => void;
goToStep: (
_step: CreateFlowStep,
_navOpts?: { reviewReturn?: CreateFlowReviewReturnTarget },
) => void;
canGoNext: () => boolean;
canGoBack: () => boolean;
nextStep: CreateFlowStep | null;
@@ -122,11 +127,21 @@ export function useCreateFlowNavigation(
);
const goToStep = useCallback(
(step: CreateFlowStep) => {
(
step: CreateFlowStep,
navOpts?: { reviewReturn?: CreateFlowReviewReturnTarget },
) => {
blurActiveElement();
router.push(`/create/${step}`);
const params = new URLSearchParams(searchParams?.toString() ?? "");
if (navOpts?.reviewReturn != null) {
params.set(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, navOpts.reviewReturn);
} else {
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
}
const qs = params.toString();
router.push(qs.length > 0 ? `/create/${step}?${qs}` : `/create/${step}`);
},
[router],
[router, searchParams],
);
const canGoNext = useCallback(() => nextStep !== null, [nextStep]);
@@ -0,0 +1,109 @@
"use client";
import { useEffect, useMemo } from "react";
import { useCreateFlow } from "../context/CreateFlowContext";
import type { CreateFlowMethodCardFacetSection } from "../types";
import {
mergeCompactCardIdsWithPinnedSelected,
orderRankedMethodsWithPinnedSelection,
} from "../../../../lib/create/methodCardDisplayOrder";
import {
deriveCompactCards,
rankMethodsByScore,
useFacetRecommendations,
type RecommendationSection,
} from "./useFacetRecommendations";
type MethodEntry = { id: string; label: string; supportText: string };
/**
* Applies score ranking, compact-slot rules, optional “pinned selection” showcase
* order, and clears the pin draft flag when a section loses all selections.
*/
export function useMethodCardDeckOrdering(
section: RecommendationSection,
methods: readonly MethodEntry[],
selectedIds: readonly string[],
) {
const { state, setMethodSectionsPinCommitted } = useCreateFlow();
const facetKey = section as CreateFlowMethodCardFacetSection;
const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section);
const pinStored =
state.methodSectionsPinCommitted?.[facetKey] === true;
const pinActive = Boolean(pinStored && selectedIds.length > 0);
useEffect(() => {
if (selectedIds.length > 0) return;
if (!pinStored) return;
setMethodSectionsPinCommitted(facetKey, false);
}, [
facetKey,
pinStored,
selectedIds.length,
setMethodSectionsPinCommitted,
]);
const rankedMethods = useMemo(
() => rankMethodsByScore(methods, scoresBySlug),
[methods, scoresBySlug],
);
const displayMethods = useMemo(
() =>
orderRankedMethodsWithPinnedSelection(
rankedMethods,
selectedIds,
pinActive,
),
[rankedMethods, selectedIds, pinActive],
);
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
() =>
deriveCompactCards(
rankedMethods,
scoresBySlug,
hasAnyFacets,
/* limit */ 5,
),
[rankedMethods, scoresBySlug, hasAnyFacets],
);
const compactCardIds = useMemo(
() =>
mergeCompactCardIdsWithPinnedSelected(
displayMethods.map((m) => m.id),
baseCompactCardIds,
selectedIds,
pinActive,
5,
),
[displayMethods, baseCompactCardIds, selectedIds, pinActive],
);
const sampleCards = useMemo(
() =>
displayMethods.map((entry) => ({
id: entry.id,
label: entry.label,
supportText: entry.supportText,
recommended: recommendedIds.has(entry.id),
})),
[displayMethods, recommendedIds],
);
const methodById = useMemo(
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
[rankedMethods],
);
return {
rankedMethods,
displayMethods,
compactCardIds,
recommendedIds,
sampleCards,
methodById,
};
}
@@ -14,15 +14,11 @@
* the chip selection and any user edits as a `communicationMethodDetailsById[id]` override.
*/
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
deriveCompactCards,
rankMethodsByScore,
useFacetRecommendations,
} from "../../hooks/useFacetRecommendations";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
@@ -49,32 +45,10 @@ export function CommunicationMethodsScreen() {
const selectedIds = state.selectedCommunicationMethodIds ?? [];
const { scoresBySlug, hasAnyFacets } =
useFacetRecommendations("communication");
const rankedMethods = useMemo(
() => rankMethodsByScore(comm.methods, scoresBySlug),
[comm.methods, scoresBySlug],
);
const { compactCardIds, recommendedIds } = useMemo(
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
[rankedMethods, scoresBySlug, hasAnyFacets],
);
const sampleCards = useMemo(
() =>
rankedMethods.map((entry) => ({
id: entry.id,
label: entry.label,
supportText: entry.supportText,
recommended: recommendedIds.has(entry.id),
})),
[rankedMethods, recommendedIds],
);
const methodById = useMemo(
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
[rankedMethods],
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
"communication",
comm.methods,
selectedIds,
);
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
@@ -12,15 +12,11 @@
* any user edits as a `conflictManagementDetailsById[id]` override.
*/
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
deriveCompactCards,
rankMethodsByScore,
useFacetRecommendations,
} from "../../hooks/useFacetRecommendations";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
@@ -47,32 +43,10 @@ export function ConflictManagementScreen() {
const selectedIds = state.selectedConflictManagementIds ?? [];
const { scoresBySlug, hasAnyFacets } =
useFacetRecommendations("conflictManagement");
const rankedMethods = useMemo(
() => rankMethodsByScore(cm.methods, scoresBySlug),
[cm.methods, scoresBySlug],
);
const { compactCardIds, recommendedIds } = useMemo(
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
[rankedMethods, scoresBySlug, hasAnyFacets],
);
const sampleCards = useMemo(
() =>
rankedMethods.map((entry) => ({
id: entry.id,
label: entry.label,
supportText: entry.supportText,
recommended: recommendedIds.has(entry.id),
})),
[rankedMethods, recommendedIds],
);
const methodById = useMemo(
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
[rankedMethods],
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
"conflictManagement",
cm.methods,
selectedIds,
);
const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
@@ -13,15 +13,11 @@
* DB-driven content.
*/
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
deriveCompactCards,
rankMethodsByScore,
useFacetRecommendations,
} from "../../hooks/useFacetRecommendations";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
@@ -48,32 +44,10 @@ export function MembershipMethodsScreen() {
const selectedIds = state.selectedMembershipMethodIds ?? [];
const { scoresBySlug, hasAnyFacets } =
useFacetRecommendations("membership");
const rankedMethods = useMemo(
() => rankMethodsByScore(mem.methods, scoresBySlug),
[mem.methods, scoresBySlug],
);
const { compactCardIds, recommendedIds } = useMemo(
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
[rankedMethods, scoresBySlug, hasAnyFacets],
);
const sampleCards = useMemo(
() =>
rankedMethods.map((entry) => ({
id: entry.id,
label: entry.label,
supportText: entry.supportText,
recommended: recommendedIds.has(entry.id),
})),
[rankedMethods, recommendedIds],
);
const methodById = useMemo(
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
[rankedMethods],
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
"membership",
mem.methods,
selectedIds,
);
const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
@@ -15,18 +15,31 @@ import {
type FinalReviewCategoryRowDetailed,
} from "../../../../../lib/create/buildFinalReviewCategories";
import { applyFinalReviewChipEditPatch } from "../../../../../lib/create/applyFinalReviewChipEditPatch";
import type { TemplateChipDetail } from "../../../../../lib/create/templateReviewMapping";
import type {
TemplateChipDetail,
TemplateFacetGroupKey,
} from "../../../../../lib/create/templateReviewMapping";
import {
FinalReviewChipEditModal,
type FinalReviewChipEditPatch,
type FinalReviewChipEditTarget,
} from "../../components/FinalReviewChipEditModal";
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
import {
getAssetPath,
vectorMarkPath,
} from "../../../../../lib/assetUtils";
const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [
"coreValues",
"communication",
"membership",
"decisionApproaches",
"conflictManagement",
] as const;
/**
* `finalReview.json.categories` ships a demo ordering + localized names
* (Values / Communication / Membership / Decision-making / Conflict
@@ -75,6 +88,7 @@ export function FinalReviewScreen({
variant?: "default" | "editPublished";
} = {}) {
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const { goToStep } = useCreateFlowNavigation();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.reviewAndComplete.finalReview");
const m = useMessages();
@@ -116,13 +130,23 @@ export function FinalReviewScreen({
const derived = buildFinalReviewCategoryRowsDetailed(state, names);
const rowsToRender: readonly FinalReviewCategoryRowDetailed[] =
derived.length > 0 ? derived : fallbackRows;
const usingFallbackRows = derived.length === 0;
const lookup = new Map<
string,
{ target: FinalReviewChipEditTarget | null; readOnly: TemplateChipDetail }
>();
const cats: Category[] = rowsToRender.map((row) => {
const cats: Category[] = rowsToRender.map((row, rowIndex) => {
const effectiveGroupKey: TemplateFacetGroupKey | null =
row.groupKey ??
(usingFallbackRows && rowIndex < FACET_FALLBACK_ORDER.length
? FACET_FALLBACK_ORDER[rowIndex]
: null);
const reviewReturn =
variant === "editPublished" ? ("edit-rule" as const) : ("final-review" as const);
const chipOptions = row.entries.map((entry, idx) => {
const chipId = `${row.name}-${idx}`;
const readOnly: TemplateChipDetail = {
@@ -150,6 +174,7 @@ export function FinalReviewScreen({
return {
name: row.name,
chipOptions,
addButton: effectiveGroupKey != null,
onChipClick: (_categoryName: string, chipId: string) => {
const hit = lookup.get(chipId);
if (!hit) return;
@@ -160,6 +185,15 @@ export function FinalReviewScreen({
setActiveReadOnlyDetail(hit.readOnly);
}
},
onAddClick:
effectiveGroupKey != null
? () => {
markCreateFlowInteraction();
goToStep(createFlowStepForFacetGroup(effectiveGroupKey), {
reviewReturn,
});
}
: undefined,
};
});
return { categories: cats, chipLookup: lookup };
@@ -167,6 +201,8 @@ export function FinalReviewScreen({
m.create.reviewAndComplete.finalReview.categories,
state,
markCreateFlowInteraction,
goToStep,
variant,
]);
void chipLookup;
@@ -23,15 +23,10 @@ import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import InfoMessageBox from "../../../../components/controls/InfoMessageBox";
import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMessageBox/InfoMessageBox.types";
import type { CardStackItem } from "../../../../components/cards/CardStack/CardStack.types";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
deriveCompactCards,
rankMethodsByScore,
useFacetRecommendations,
} from "../../hooks/useFacetRecommendations";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { DecisionApproachEditFields } from "../../components/methodEditFields";
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
@@ -62,32 +57,10 @@ export function DecisionApproachesScreen() {
[da.messageBox.items],
);
const { scoresBySlug, hasAnyFacets } =
useFacetRecommendations("decisionApproaches");
const rankedMethods = useMemo(
() => rankMethodsByScore(da.methods, scoresBySlug),
[da.methods, scoresBySlug],
);
const { compactCardIds, recommendedIds } = useMemo(
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
[rankedMethods, scoresBySlug, hasAnyFacets],
);
const sampleCards: CardStackItem[] = useMemo(
() =>
rankedMethods.map((entry) => ({
id: entry.id,
label: entry.label,
supportText: entry.supportText,
recommended: recommendedIds.has(entry.id),
})),
[rankedMethods, recommendedIds],
);
const methodById = useMemo(
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
[rankedMethods],
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
"decisionApproaches",
da.methods,
selectedIds,
);
const sidebarDescription = (
+23
View File
@@ -36,6 +36,13 @@ export type CreateFlowTextStateField =
| "communityContext"
| "communitySaveEmail";
/** Facet-backed method card stacks (`GET /api/create-flow/methods?section=`). */
export type CreateFlowMethodCardFacetSection =
| "communication"
| "membership"
| "decisionApproaches"
| "conflictManagement";
/**
* Serialized chip row for `community-structure` (preset + custom labels).
* Stored in drafts so custom chips survive refresh and server sync.
@@ -130,6 +137,14 @@ export interface CreateFlowState {
selectedDecisionApproachIds?: string[];
/** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.customRule.conflictManagement` presets. */
selectedConflictManagementIds?: string[];
/**
* After **Confirm** on a method card step (`communication-methods`, etc.)
* with ≥1 selection, reorder UI with selected cards first until the pin is
* cleared by an empty selection (or resetting custom-rule state).
*/
methodSectionsPinCommitted?: Partial<
Record<CreateFlowMethodCardFacetSection, boolean>
>;
/**
* User edits from the `final-review` edit modal, keyed by preset method id
* (e.g. `"signal"`). Merged onto preset defaults at publish time so the
@@ -209,6 +224,14 @@ export interface CreateFlowContextValue {
* after a prior "Customize template" prefill.
*/
resetCustomRuleSelections: () => void;
/**
* Mark whether a facet method stack should pin the authors selections to
* the head of expanded + compact order (set from the footer Confirm).
*/
setMethodSectionsPinCommitted: (
section: CreateFlowMethodCardFacetSection,
committed: boolean,
) => void;
/**
* True after the user has edited any control inside the wizard. Screens flip
* it via {@link markCreateFlowInteraction} from their event handlers.
@@ -1,4 +1,8 @@
import type { CreateFlowState, CreateFlowStep } from "../types";
import type {
CreateFlowMethodCardFacetSection,
CreateFlowState,
CreateFlowStep,
} from "../types";
import type footerMessages from "../../../../messages/en/create/footer.json";
type FooterMessageKey = keyof typeof footerMessages;
@@ -67,3 +71,24 @@ export const CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP: ReadonlyMap<
CreateFlowStep,
CustomRuleConfirmFooterStep
> = new Map(CUSTOM_RULE_CONFIRM_FOOTER_STEPS.map((e) => [e.step, e]));
/**
* Map a custom-rule Confirm footer step to the facet-backed method-card section
* (core values omit — chip MultiSelect uses a different ordering model).
*/
export function methodCardFacetSectionForConfirmStep(
step: CustomRuleConfirmFooterStep["step"],
): CreateFlowMethodCardFacetSection | undefined {
switch (step) {
case "communication-methods":
return "communication";
case "membership-methods":
return "membership";
case "decision-approaches":
return "decisionApproaches";
case "conflict-management":
return "conflictManagement";
default:
return undefined;
}
}
@@ -0,0 +1,19 @@
import type { TemplateFacetGroupKey } from "../../../../lib/create/templateReviewMapping";
import type { CreateFlowStep } from "../types";
const MAP: Record<TemplateFacetGroupKey, CreateFlowStep> = {
coreValues: "core-values",
communication: "communication-methods",
membership: "membership-methods",
decisionApproaches: "decision-approaches",
conflictManagement: "conflict-management",
};
/**
* Custom-rule URL segment for a final-review category row (`+` navigation).
*/
export function createFlowStepForFacetGroup(
groupKey: TemplateFacetGroupKey,
): CreateFlowStep {
return MAP[groupKey];
}
+14
View File
@@ -153,6 +153,20 @@ export function parseCreateFlowScreenFromPathname(
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
/** `/create/{step}?reviewReturn=…` — set when opening a custom-rule step from final-review or edit-rule via + */
export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const;
export type CreateFlowReviewReturnTarget = "final-review" | "edit-rule";
export function parseReviewReturnSearchParam(
searchParams: { get: (name: string) => string | null } | null | undefined,
): CreateFlowReviewReturnTarget | null {
if (!searchParams) return null;
const raw = searchParams.get(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
if (raw === "final-review" || raw === "edit-rule") return raw;
return null;
}
/**
* `/create/review-template/{slug}` with optional marker so chrome can send
* footer Back to `/create/review` instead of marketing home.
@@ -351,6 +351,11 @@ export function ProfilePageView({
{
id: "view",
label: t("viewPublic"),
href: `/rules/${encodeURIComponent(rule.id)}`,
},
{
id: "manage",
label: t("manageRule"),
href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`,
},
{
+2 -1
View File
@@ -20,8 +20,9 @@ export const GET = apiRoute("rules.list", async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
/** Public catalog: mirror profile “my rules” recency semantics (last touched first). */
const rules = await prisma.publishedRule.findMany({
orderBy: { createdAt: "desc" },
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
take,
select: {
id: true,
@@ -42,6 +42,7 @@ const RuleContainer = memo<RuleProps>(
hasBottomLinks = false,
bottomStatusLabel,
bottomLinks,
recommended = false,
}) => {
const size = sizeProp ?? "L";
@@ -96,6 +97,7 @@ const RuleContainer = memo<RuleProps>(
hasBottomLinks={hasBottomLinks}
bottomStatusLabel={bottomStatusLabel}
bottomLinks={bottomLinks}
recommended={recommended}
/>
);
},
+9
View File
@@ -4,6 +4,8 @@ import type { RuleSizeValue } from "../../../../lib/propNormalization";
export interface Category {
name: string;
chipOptions: ChipOption[];
/** When `false`, hide the rows + affordance. Default: show when the Rule allows adds. */
addButton?: boolean;
onChipClick?: (categoryName: string, chipId: string) => void;
onAddClick?: (categoryName: string) => void;
onCustomChipConfirm?: (
@@ -58,6 +60,12 @@ export interface RuleProps {
/** Uppercase chip (e.g. IN PROGRESS); omit when no left badge. */
bottomStatusLabel?: string;
bottomLinks?: RuleBottomLink[];
/**
* When set and the card is collapsed (`expanded` false), show the
* “RECOMMENDED” tag above the title (e.g. templates index). Ignored when
* `expanded` — Figma `22142:898446` compact `Card / Rule` only.
*/
recommended?: boolean;
}
export interface RuleViewProps {
@@ -81,4 +89,5 @@ export interface RuleViewProps {
hasBottomLinks?: boolean;
bottomStatusLabel?: string;
bottomLinks?: RuleBottomLink[];
recommended?: boolean;
}
+17 -2
View File
@@ -5,6 +5,7 @@ import { useTranslation } from "../../../contexts/MessagesContext";
import MultiSelect from "../../controls/MultiSelect";
import InlineTextButton from "../../buttons/InlineTextButton";
import NavigationLink from "../../navigation/Link";
import Tag from "../../utility/Tag";
import type { RuleBottomLink, RuleViewProps } from "./Rule.types";
export function RuleView({
@@ -28,6 +29,7 @@ export function RuleView({
hasBottomLinks = false,
bottomStatusLabel,
bottomLinks,
recommended = false,
}: RuleViewProps) {
const t = useTranslation("ruleCard");
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
@@ -90,6 +92,7 @@ export function RuleView({
`;
// Title typography - use CSS responsive classes
const showRecommendedTag = recommended && !expanded;
const titleClass = `
max-[639px]:font-inter max-[639px]:font-bold max-[639px]:text-[20px] max-[639px]:leading-[28px]
min-[640px]:max-[1023px]:font-bricolage-grotesque min-[640px]:max-[1023px]:font-bold min-[640px]:max-[1023px]:text-[28px] min-[640px]:max-[1023px]:leading-[36px]
@@ -256,12 +259,22 @@ export function RuleView({
{/* Inner container for header text with padding */}
<div
className={`
flex items-center justify-center w-full
flex w-full
${
showRecommendedTag
? "flex-col items-start justify-center gap-1"
: "items-center justify-center"
}
max-[639px]:pl-[8px] max-[639px]:py-[8px]
min-[640px]:max-[1023px]:pl-[12px] min-[640px]:max-[1023px]:py-[12px]
min-[1024px]:px-[16px] min-[1024px]:py-[24px]
`}
>
{showRecommendedTag ? (
<Tag variant="templateRecommended">
{t("recommendedLabel")}
</Tag>
) : null}
<h3
className={`${titleClass} cursor-inherit text-[var(--color-content-invert-primary)] overflow-hidden text-ellipsis w-full`}
>
@@ -384,7 +397,9 @@ export function RuleView({
onCustomChipClose={(chipId) => {
category.onCustomChipClose?.(category.name, chipId);
}}
addButton={!hideCategoryAddButton}
addButton={
!hideCategoryAddButton && category.addButton !== false
}
addButtonText="" // Empty text for icon-only circular button
className="w-full"
/>
@@ -57,6 +57,7 @@ export function GovernanceTemplateGrid({
key={card.slug}
title={card.title}
description={card.description}
recommended={card.recommended === true}
size={cardSize}
className={`
select-none
@@ -7,6 +7,7 @@ import type { TagProps } from "./Tag.types";
const DEFAULT_LABELS: Record<TagProps["variant"], string> = {
recommended: "RECOMMENDED",
selected: "SELECTED",
templateRecommended: "RECOMMENDED",
};
/**
+6 -2
View File
@@ -1,7 +1,11 @@
export type TagVariant = "recommended" | "selected";
export type TagVariant = "recommended" | "selected" | "templateRecommended";
export interface TagProps {
/** Visual variant: recommended (yellow) or selected (dark) */
/**
* Visual variant: recommended (yellow), selected (dark on light),
* or templateRecommended (dark pill on pastel `Card / Rule` — Figma
* `22142:898446`).
*/
variant: TagVariant;
/** Tag text. Defaults to "RECOMMENDED" or "SELECTED" when not provided. */
children?: React.ReactNode;
+11 -6
View File
@@ -7,12 +7,17 @@ import type { TagViewProps } from "./Tag.types";
*/
export function TagView({ variant, children, className }: TagViewProps) {
const isRecommended = variant === "recommended";
const bgClass = isRecommended
? "bg-[var(--color-surface-inverse-brand-accent)]"
: "bg-[var(--color-gray-1000)]";
const textClass = isRecommended
? "text-[var(--color-content-inverse-brand-primary)]"
: "text-[var(--color-gray-000)]";
const isTemplateRecommended = variant === "templateRecommended";
const bgClass = isTemplateRecommended
? "bg-[var(--color-surface-default-tertiary)]"
: isRecommended
? "bg-[var(--color-surface-inverse-brand-accent)]"
: "bg-[var(--color-gray-1000)]";
const textClass = isTemplateRecommended
? "text-[var(--color-gray-000)]"
: isRecommended
? "text-[var(--color-content-inverse-brand-primary)]"
: "text-[var(--color-gray-000)]";
return (
<span