Edit flow configured
This commit is contained in:
@@ -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 = (
|
||||
|
||||
@@ -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 author’s 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];
|
||||
}
|
||||
@@ -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)}`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { RuleSizeValue } from "../../../../lib/propNormalization";
|
||||
export interface Category {
|
||||
name: string;
|
||||
chipOptions: ChipOption[];
|
||||
/** When `false`, hide the row’s + 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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user