Implement create custom recommendations

This commit is contained in:
adilallo
2026-04-20 12:41:10 -06:00
parent e9dab04b34
commit 45bbbb8a35
75 changed files with 6403 additions and 1452 deletions
+31 -9
View File
@@ -28,7 +28,11 @@ import {
requestMagicLink,
} from "../../../lib/create/api";
import { safeInternalPath } from "../../../lib/safeInternalPath";
import { setTransferPendingFlag } from "./utils/anonymousDraftStorage";
import {
clearAnonymousCreateFlowStorage,
setTransferPendingFlag,
} from "./utils/anonymousDraftStorage";
import { deleteServerDraft } from "../../../lib/create/api";
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import {
fetchTemplateBySlug,
@@ -64,10 +68,14 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
}, []);
const sessionResolved = sessionUser !== undefined;
const enableAnonymousPersistence = sessionResolved && sessionUser === null;
// Mirror in-progress draft to localStorage for ALL visitors once we know who
// they are. Refresh-survival is the same UX for guest and signed-in users;
// signed-in users additionally get an explicit "Save & Exit" that PUTs to
// the server (handled in `useCreateFlowExit`).
const enableLocalDraftMirroring = sessionResolved;
return (
<CreateFlowProvider enableAnonymousPersistence={enableAnonymousPersistence}>
<CreateFlowProvider enableLocalDraftMirroring={enableLocalDraftMirroring}>
<CreateFlowDraftSaveBannerProvider>
<CreateFlowLayoutContent
sessionUser={sessionUser}
@@ -91,7 +99,7 @@ function CreateFlowLayoutContent({
}) {
const { create } = useMessages();
const footer = create.footer;
const communitySaveMessages = create.communitySave;
const communitySaveMessages = create.community.communitySave;
const tLogin = useTranslation("pages.login");
const router = useRouter();
const pathname = usePathname();
@@ -142,7 +150,7 @@ function CreateFlowLayoutContent({
if (payloadResult.ok === false) {
setPublishBannerMessage(
payloadResult.error === "missingCommunityName"
? messages.create.publish.missingCommunityName
? messages.create.reviewAndComplete.publish.missingCommunityName
: payloadResult.error,
);
return;
@@ -176,7 +184,7 @@ function CreateFlowLayoutContent({
setPublishBannerMessage(
publishResult.error.trim() !== ""
? publishResult.error
: messages.create.publish.genericPublishFailed,
: messages.create.reviewAndComplete.publish.genericPublishFailed,
);
}, [state, router, openLogin]);
@@ -226,6 +234,20 @@ function CreateFlowLayoutContent({
const saveDraft = opts?.saveDraft ?? false;
if (!sessionResolved) return;
// Exit from `/create/completed` is post-publish: the rule is saved, so we
// skip the leave-confirm + login prompt and just wipe the in-flight draft.
// For signed-in users we also DELETE the server draft so a future visit to
// /create starts fresh instead of rehydrating yesterday's work.
if (currentStep === "completed") {
clearState();
clearAnonymousCreateFlowStorage();
if (sessionUser) {
void deleteServerDraft();
}
router.push("/");
return;
}
if (sessionUser === null) {
if (saveDraft) return;
const returnToTemplateReview =
@@ -372,7 +394,7 @@ function CreateFlowLayoutContent({
<Alert
type="banner"
status="danger"
title={messages.create.publish.finalizeBannerTitle}
title={messages.create.reviewAndComplete.publish.finalizeBannerTitle}
description={publishBannerMessage}
onClose={() => setPublishBannerMessage(null)}
className="w-full"
@@ -622,7 +644,7 @@ function CreateFlowLayoutContent({
goToNextStep();
}}
>
{footer.confirmRightRail}
{footer.confirmDecisionApproaches}
</Button>
) : currentStep === "conflict-management" && nextStep ? (
<Button
@@ -657,7 +679,7 @@ function CreateFlowLayoutContent({
>
{currentStep === "final-review"
? isPublishing
? messages.create.publish.finalizeButtonPublishing
? messages.create.reviewAndComplete.publish.finalizeButtonPublishing
: footer.finalizeCommunityRule
: currentStep === "confirm-stakeholders"
? footer.confirmStakeholders
+20 -35
View File
@@ -5,7 +5,6 @@ import { useSearchParams } from "next/navigation";
import type { CreateFlowState } from "./types";
import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils";
import {
clearAnonymousCreateFlowStorage,
hasTransferPendingFlag,
readAnonymousCreateFlowState,
} from "./utils/anonymousDraftStorage";
@@ -16,11 +15,18 @@ import messages from "../../../messages/en/index";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
/**
* When sync is on and the user is signed in, fetch `GET /api/drafts/me` once and merge into context.
* Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer} owns that path.
* When sync is on and the user is signed in, restore the server-side draft only
* when there is no in-flight localStorage draft to defer to. localStorage is
* the on-every-keystroke buffer (CreateFlowProvider mirrors state there for
* everyone), so a refresh mid-flow already has the freshest data; pulling the
* server draft on top would clobber unsaved keystrokes with a stale snapshot.
*
* **Conflict:** If both server draft and `create-flow-anonymous` are non-empty, `window.confirm`
* chooses account draft (OK) vs browser copy (Cancel); browser storage is cleared after resolution.
* Server draft becomes authoritative only when localStorage is empty — i.e.
* fresh device, after explicit Save & Exit (which clears localStorage), or
* after Exit-from-completed clears local state.
*
* Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer}
* owns that path.
*/
export function SignedInDraftHydration({
sessionUser,
@@ -54,6 +60,14 @@ export function SignedInDraftHydration({
return;
}
// Local draft wins over server: no fetch, no replaceState. The provider
// already hydrated from localStorage at mount, so the user sees their
// unsaved keystrokes immediately.
if (createFlowStateHasKeys(readAnonymousCreateFlowState())) {
finishedUserIdRef.current = userId;
return;
}
let cancelled = false;
setLoadingHydration(true);
@@ -62,43 +76,14 @@ export function SignedInDraftHydration({
const serverDraft = await fetchDraftFromServer();
if (cancelled) return;
const localDraft = readAnonymousCreateFlowState();
const hasServer =
serverDraft != null && createFlowStateHasKeys(serverDraft);
const hasLocal = createFlowStateHasKeys(localDraft);
if (touchedRef.current) {
finishedUserIdRef.current = userId;
return;
}
if (hasServer && hasLocal) {
const useAccount =
typeof window !== "undefined" &&
window.confirm(messages.create.draftHydration.conflictPrompt);
if (cancelled) return;
if (useAccount) {
replaceState(serverDraft as CreateFlowState);
} else {
replaceState(localDraft);
}
clearAnonymousCreateFlowStorage();
finishedUserIdRef.current = userId;
return;
}
if (hasServer) {
if (serverDraft != null && createFlowStateHasKeys(serverDraft)) {
replaceState(serverDraft as CreateFlowState);
clearAnonymousCreateFlowStorage();
finishedUserIdRef.current = userId;
return;
}
if (hasLocal) {
replaceState(localDraft);
clearAnonymousCreateFlowStorage();
}
finishedUserIdRef.current = userId;
} finally {
if (!cancelled) setLoadingHydration(false);
+54 -14
View File
@@ -32,22 +32,29 @@ interface CreateFlowProviderProps {
children: ReactNode;
initialStep?: CreateFlowStep | null;
/**
* When true (signed-out, session resolved), load/sync `create-flow-anonymous` in localStorage.
* When false, in-memory only (authenticated fresh create).
* When true (session resolved, guest or signed-in), mirror in-flight draft to
* `create-flow-anonymous` in localStorage so refresh / dev-restart never wipes
* progress. When false, in-memory only (e.g. unit tests, pre-session-resolve).
*
* Signed-in users additionally get an explicit "Save & Exit" that PUTs to the
* server (`useCreateFlowExit`); the server draft is the cross-device snapshot,
* localStorage is the on-every-keystroke buffer.
*/
enableAnonymousPersistence?: boolean;
enableLocalDraftMirroring?: boolean;
}
/**
* Create flow state. Anonymous users mirror state to localStorage; authenticated users stay in memory.
* Create flow state. All users mirror in-flight state to localStorage when
* `enableLocalDraftMirroring` is true; signed-in users layer an explicit
* server-draft snapshot on top via {@link useCreateFlowExit}.
*/
export function CreateFlowProvider({
children,
initialStep = null,
enableAnonymousPersistence = false,
enableLocalDraftMirroring = false,
}: CreateFlowProviderProps) {
const [state, setState] = useState<CreateFlowState>(() => {
const base = enableAnonymousPersistence
const base = enableLocalDraftMirroring
? readAnonymousCreateFlowState()
: {};
const storedDetails = readCoreValueDetailsFromLocalStorage();
@@ -62,15 +69,23 @@ export function CreateFlowProvider({
});
const [interactionTouched, setInteractionTouched] = useState(false);
const [currentStep] = useState<CreateFlowStep | null>(initialStep);
const prevPersistRef = useRef(enableAnonymousPersistence);
const prevPersistRef = useRef(enableLocalDraftMirroring);
const persistWriteSkipRef = useRef(true);
useEffect(() => {
clearLegacyCreateFlowKeysOnce();
}, []);
// Session resolved as guest after initial paint: hydrate from localStorage if still empty.
// Session resolved after initial paint: hydrate from localStorage, merging
// with anything already in state. We can't bail on `prev` being non-empty:
// the initializer pre-populates `coreValueDetailsByChipId` from a separate
// localStorage key, so `prev` is virtually always non-empty here.
// Merge strategy: `prev` wins for fields the user might have touched between
// mount and session-resolve; `from` fills in anything else; coreValueDetails
// is union-merged (prev wins per chip id since it loaded from the dedicated
// `create-flow-core-value-details` key).
useEffect(() => {
if (!enableAnonymousPersistence) {
if (!enableLocalDraftMirroring) {
prevPersistRef.current = false;
return;
}
@@ -79,14 +94,39 @@ export function CreateFlowProvider({
if (!wasOff) return;
const from = readAnonymousCreateFlowState();
if (Object.keys(from).length === 0) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate anonymous draft when guest persistence turns on
setState((prev) => (Object.keys(prev).length > 0 ? prev : { ...from }));
}, [enableAnonymousPersistence]);
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate local draft when mirroring turns on
setState((prev) => {
const merged: CreateFlowState = { ...from, ...prev };
const fromDetails = from.coreValueDetailsByChipId;
const prevDetails = prev.coreValueDetailsByChipId;
if (fromDetails || prevDetails) {
merged.coreValueDetailsByChipId = {
...(fromDetails ?? {}),
...(prevDetails ?? {}),
};
}
return merged;
});
}, [enableLocalDraftMirroring]);
useEffect(() => {
if (!enableAnonymousPersistence) return;
if (!enableLocalDraftMirroring) {
// Reset so the next OFF→ON transition skips its first write again.
persistWriteSkipRef.current = true;
return;
}
// Skip the very first write that runs on the same render where mirroring
// turned ON — the hydrate effect (above) is racing to setState the loaded
// draft, and writing the still-empty pre-hydrate state here would clobber
// localStorage. The next render (with the hydrated state) will write
// normally. Without this guard, drafts get wiped during HMR / any
// auth-session refetch that re-toggles `enableLocalDraftMirroring`.
if (persistWriteSkipRef.current) {
persistWriteSkipRef.current = false;
return;
}
writeAnonymousCreateFlowState(state);
}, [state, enableAnonymousPersistence]);
}, [state, enableLocalDraftMirroring]);
/** Meaning/signals for core values: survives refresh for signed-in users; merged with anonymous draft when both exist. */
useEffect(() => {
@@ -0,0 +1,232 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import facetGroups from "../../../../data/create/customRule/_facetGroups.json";
import {
type CreateFlowState,
} from "../types";
import { useCreateFlow } from "../context/CreateFlowContext";
/**
* Card-deck section ids served by `/api/create-flow/methods` (CR-88 §9.2).
*/
export type RecommendationSection =
| "communication"
| "membership"
| "decisionApproaches"
| "conflictManagement";
const FACET_GROUPS = ["size", "orgType", "scale", "maturity"] as const;
type FacetGroupId = (typeof FACET_GROUPS)[number];
/** Reverse map chipId → canonical facet value id, per group. */
const CHIP_TO_VALUE_BY_GROUP: Record<FacetGroupId, Record<string, string>> = (() => {
const out: Record<FacetGroupId, Record<string, string>> = {
size: {},
orgType: {},
scale: {},
maturity: {},
};
for (const group of FACET_GROUPS) {
const block = (facetGroups as Record<string, unknown>)[group];
if (block && typeof block === "object" && "values" in block) {
const values = (block as { values: Record<string, { chipId: string }> })
.values;
for (const [valueId, entry] of Object.entries(values)) {
out[group][entry.chipId] = valueId;
}
}
}
return out;
})();
/** Chip-id state accessors per group. */
const STATE_KEY_BY_GROUP: Record<FacetGroupId, keyof CreateFlowState> = {
size: "selectedCommunitySizeIds",
orgType: "selectedOrganizationTypeIds",
scale: "selectedScaleIds",
maturity: "selectedMaturityIds",
};
function readChipIds(
state: CreateFlowState,
group: FacetGroupId,
): string[] {
const value = state[STATE_KEY_BY_GROUP[group]];
return Array.isArray(value) ? (value as string[]) : [];
}
function buildFacetQuery(state: CreateFlowState): string {
const params = new URLSearchParams();
for (const group of FACET_GROUPS) {
const valuesById = CHIP_TO_VALUE_BY_GROUP[group];
for (const chipId of readChipIds(state, group)) {
const valueId = valuesById[chipId];
if (valueId) {
params.append(`facet.${group}`, valueId);
}
}
}
return params.toString();
}
export type FacetRecommendationsResult = {
/** `true` once the network call completes (or short-circuits with no facets). */
isReady: boolean;
/** `slug → score`; missing slug means `0`. */
scoresBySlug: Record<string, number>;
/**
* `true` iff the user has selected at least one community facet. When
* `false`, callers should preserve authoring order rather than reranking.
*/
hasAnyFacets: boolean;
};
const EMPTY_SCORES: Record<string, number> = {};
/**
* Calls `GET /api/create-flow/methods?section=<section>&facet.*=...` for the
* card-deck step `section` and returns a `slug → score` map for re-ranking
* the messages-file `methods[]` array (CR-88 §10).
*
* Returns `{ isReady: true, scoresBySlug: {} }` when the user has not selected
* any community facets — callers fall back to the authoring order.
*
* Network failures resolve to `scoresBySlug: {}` so the wizard is never
* blocked on the recommendation backend.
*/
export function useFacetRecommendations(
section: RecommendationSection,
): FacetRecommendationsResult {
const { state } = useCreateFlow();
const queryString = useMemo(() => buildFacetQuery(state), [state]);
const hasAnyFacets = queryString.length > 0;
const [result, setResult] = useState<FacetRecommendationsResult>({
isReady: !hasAnyFacets,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets,
});
// Track the last successful request input so we don't re-fetch on every state poke.
const lastQueryRef = useRef<string | null>(null);
useEffect(() => {
if (!hasAnyFacets) {
setResult({
isReady: true,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: false,
});
lastQueryRef.current = null;
return;
}
const requestKey = `${section}?${queryString}`;
if (lastQueryRef.current === requestKey) return;
lastQueryRef.current = requestKey;
const ctrl = new AbortController();
setResult((prev) => ({ ...prev, isReady: false, hasAnyFacets: true }));
fetch(`/api/create-flow/methods?section=${section}&${queryString}`, {
credentials: "include",
signal: ctrl.signal,
})
.then(async (res) => {
if (!res.ok) throw new Error(`status ${res.status}`);
return (await res.json()) as {
methods?: { slug: string; matches?: { score?: number } }[];
};
})
.then((json) => {
const scoresBySlug: Record<string, number> = {};
for (const m of json.methods ?? []) {
if (typeof m.slug === "string") {
scoresBySlug[m.slug] = m.matches?.score ?? 0;
}
}
setResult({ isReady: true, scoresBySlug, hasAnyFacets: true });
})
.catch((e) => {
if ((e as { name?: string }).name === "AbortError") return;
setResult({
isReady: true,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: true,
});
});
return () => {
ctrl.abort();
// Clear the dedup key so React 19 Strict Mode's mount → unmount → mount
// cycle (and any future remount) re-issues the request instead of
// returning early on the same key.
if (lastQueryRef.current === requestKey) {
lastQueryRef.current = null;
}
};
}, [section, queryString, hasAnyFacets]);
return result;
}
/**
* Stable comparator for re-ranking a messages-file `methods[]` array. Higher
* `scoresBySlug[id]` first; ties fall back to authoring index, so a
* zero-facet user sees the original ordering verbatim.
*/
export function rankMethodsByScore<T extends { id: string }>(
methods: readonly T[],
scoresBySlug: Record<string, number>,
): T[] {
const indexById = new Map<string, number>();
methods.forEach((m, i) => indexById.set(m.id, i));
return [...methods].sort((a, b) => {
const sa = scoresBySlug[a.id] ?? 0;
const sb = scoresBySlug[b.id] ?? 0;
if (sa !== sb) return sb - sa;
return (indexById.get(a.id) ?? 0) - (indexById.get(b.id) ?? 0);
});
}
/**
* Picks (a) which method ids fill the compact card stack and (b) which of
* those should render with the "Recommended" tag. The messages JSON no
* longer carries a static `recommended` flag — both selections come
* entirely from facet scores (CR-88 §10).
*
* Behavior:
* - Facets selected & at least one method scored > 0 →
* `compactCardIds` = up to `limit` top-scored methods (1..limit cards;
* never padded with unrecommended fillers). All shown cards get the
* "Recommended" badge.
* - No facets selected, or every method scored 0 → `compactCardIds` =
* first `limit` in ranked/authoring order, `recommendedIds` empty (no
* badges shown — honest "no signal yet" fallback).
*
* `CardStack.view` is responsible for laying out variable-length compact
* arrays gracefully (uses `.map`/`.slice` and length-guarded indexing).
*/
export function deriveCompactCards<T extends { id: string }>(
rankedMethods: readonly T[],
scoresBySlug: Record<string, number>,
hasAnyFacets: boolean,
limit: number,
): { compactCardIds: string[]; recommendedIds: Set<string> } {
const fallback = () => ({
compactCardIds: rankedMethods.slice(0, limit).map((m) => m.id),
recommendedIds: new Set<string>(),
});
if (!hasAnyFacets) return fallback();
const matched = rankedMethods.filter(
(m) => (scoresBySlug[m.id] ?? 0) > 0,
);
if (matched.length === 0) return fallback();
const top = matched.slice(0, limit);
return {
compactCardIds: top.map((m) => m.id),
recommendedIds: new Set(top.map((m) => m.id)),
};
}
@@ -35,7 +35,7 @@ export function CreateFlowScreenView({
case "community-name":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communityName"
messageNamespace="create.community.communityName"
stateField="title"
maxLength={48}
/>
@@ -45,7 +45,7 @@ export function CreateFlowScreenView({
case "community-context":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communityContext"
messageNamespace="create.community.communityContext"
stateField="communityContext"
maxLength={48}
mainAlign="center"
@@ -58,7 +58,7 @@ export function CreateFlowScreenView({
case "community-save":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communitySave"
messageNamespace="create.community.communitySave"
stateField="communitySaveEmail"
maxLength={254}
mainAlign="center"
@@ -13,6 +13,11 @@ import { useState, useCallback, useMemo } 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 { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/utility/CardStack";
import Create from "../../../../components/modals/Create";
@@ -24,10 +29,6 @@ import {
} from "../../components/createFlowLayoutTokens";
import ModalTextAreaField from "../../components/ModalTextAreaField";
const IN_PERSON_CARD_ID = "in-person-meetings";
const SIGNAL_CARD_ID = "signal";
const VIDEO_MEETINGS_CARD_ID = "video-meetings";
const SECTION_FIELDS = [
"corePrinciple",
"logisticsAdmin",
@@ -35,16 +36,6 @@ const SECTION_FIELDS = [
] as const;
type SectionField = (typeof SECTION_FIELDS)[number];
const COMMUNICATION_CARD_ORDER = [
IN_PERSON_CARD_ID,
SIGNAL_CARD_ID,
VIDEO_MEETINGS_CARD_ID,
"4",
"5",
"6",
"7",
] as const;
function AddPlatformModalContent({
platformCardId,
}: {
@@ -52,15 +43,13 @@ function AddPlatformModalContent({
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
const comm = m.create.communication;
const modal =
platformCardId in comm.modals
? comm.modals[platformCardId as keyof typeof comm.modals]
: null;
const defaults = modal?.sections ?? {
corePrinciple: "",
logisticsAdmin: "",
codeOfConduct: "",
const comm = m.create.customRule.communication;
const method = comm.methods.find((entry) => entry.id === platformCardId);
const sections = method?.sections;
const defaults: Record<SectionField, string> = {
corePrinciple: sections?.corePrinciple ?? "",
logisticsAdmin: sections?.logisticsAdmin ?? "",
codeOfConduct: sections?.codeOfConduct ?? "",
};
const [sectionValues, setSectionValues] = useState<
@@ -96,7 +85,7 @@ function AddPlatformModalContent({
export function CommunicationMethodsScreen() {
const m = useMessages();
const comm = m.create.communication;
const comm = m.create.customRule.communication;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
@@ -112,18 +101,32 @@ export function CommunicationMethodsScreen() {
[updateState],
);
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(
() =>
COMMUNICATION_CARD_ORDER.map((id) => {
const row = comm.cards[id as keyof typeof comm.cards];
return {
id,
label: row.label,
supportText: row.supportText,
recommended: true,
};
}),
[comm],
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 title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
@@ -157,25 +160,10 @@ export function CommunicationMethodsScreen() {
};
}
if (pendingCardId in comm.modals) {
const modal = comm.modals[pendingCardId as keyof typeof comm.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: comm.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
const cardRow =
pendingCardId in comm.cards
? comm.cards[pendingCardId as keyof typeof comm.cards]
: null;
const method = methodById.get(pendingCardId);
return {
title: cardRow?.label ?? comm.confirmModal.title,
description: cardRow?.supportText ?? comm.confirmModal.description,
title: method?.label ?? comm.confirmModal.title,
description: method?.supportText ?? comm.confirmModal.description,
nextButtonText: comm.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
@@ -235,7 +223,8 @@ export function CommunicationMethodsScreen() {
}}
hasMore={true}
toggleLabel={comm.page.seeAllLink}
compactRecommendedLimit={3}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="flexWrap"
headerLockupSize={mdUp ? "L" : "M"}
/>
@@ -7,7 +7,7 @@
* Card click opens the Figma "Add Approach" create modal (node `20874-172292`) with four
* controls: Core Principle, Applicable Scope (capsules), Process Protocol, and Restoration
* & Fallbacks. Section defaults are sourced from
* `messages/en/create/conflictManagement.json` and will be replaced with DB-driven
* `messages/en/create/customRule/conflictManagement.json` and will be replaced with DB-driven
* content; labels are hard-coded per the Figma design.
*/
@@ -15,6 +15,11 @@ import { useState, useCallback, useMemo } 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 { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/utility/CardStack";
import Create from "../../../../components/modals/Create";
@@ -27,17 +32,6 @@ import {
import ModalTextAreaField from "../../components/ModalTextAreaField";
import ApplicableScopeField from "../../components/ApplicableScopeField";
const CONFLICT_CARD_ORDER = [
"peer-mediation",
"conflict-resolution-council",
"facilitated-negotiation",
"ad-hoc-arbitration",
"conflict-workshops",
"6",
"7",
"8",
] as const;
type ConflictModalSections = {
corePrinciple: string;
applicableScope: string[];
@@ -53,12 +47,9 @@ function AddConflictApproachModalContent({
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
const cm = m.create.conflictManagement;
const modal =
approachCardId in cm.modals
? cm.modals[approachCardId as keyof typeof cm.modals]
: null;
const modalSections = modal?.sections;
const cm = m.create.customRule.conflictManagement;
const method = cm.methods.find((entry) => entry.id === approachCardId);
const modalSections = method?.sections;
const defaults: ConflictModalSections = {
corePrinciple: modalSections?.corePrinciple ?? "",
applicableScope: modalSections?.applicableScope ?? [],
@@ -126,7 +117,7 @@ function AddConflictApproachModalContent({
export function ConflictManagementScreen() {
const m = useMessages();
const cm = m.create.conflictManagement;
const cm = m.create.customRule.conflictManagement;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
@@ -142,18 +133,32 @@ export function ConflictManagementScreen() {
[updateState],
);
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(
() =>
CONFLICT_CARD_ORDER.map((id) => {
const row = cm.cards[id as keyof typeof cm.cards];
return {
id,
label: row.label,
supportText: row.supportText,
recommended: true,
};
}),
[cm],
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 title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
@@ -187,25 +192,10 @@ export function ConflictManagementScreen() {
};
}
if (pendingCardId in cm.modals) {
const modal = cm.modals[pendingCardId as keyof typeof cm.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: cm.addApproach.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
const cardRow =
pendingCardId in cm.cards
? cm.cards[pendingCardId as keyof typeof cm.cards]
: null;
const method = methodById.get(pendingCardId);
return {
title: cardRow?.label ?? cm.confirmModal.title,
description: cardRow?.supportText ?? cm.confirmModal.description,
title: method?.label ?? cm.confirmModal.title,
description: method?.supportText ?? cm.confirmModal.description,
nextButtonText: cm.addApproach.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
@@ -266,6 +256,7 @@ export function ConflictManagementScreen() {
hasMore={true}
toggleLabel={cm.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
@@ -7,7 +7,7 @@
* Card click opens the Figma create modal (node `20858-13948`) with three
* editable sections — Eligibility & Philosophy, Joining Process, and
* Expectations & Removal. Section defaults come from
* `messages/en/create/membership.json` and will be replaced with DB-driven
* `messages/en/create/customRule/membership.json` and will be replaced with DB-driven
* content.
*/
@@ -15,6 +15,11 @@ import { useState, useCallback, useMemo } 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 { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/utility/CardStack";
import Create from "../../../../components/modals/Create";
@@ -33,17 +38,6 @@ const SECTION_FIELDS = [
] as const;
type SectionField = (typeof SECTION_FIELDS)[number];
const MEMBERSHIP_CARD_ORDER = [
"open-access",
"orientation-required",
"invitation-only",
"contribution-based",
"mentorship",
"6",
"7",
"8",
] as const;
function AddMembershipModalContent({
membershipCardId,
}: {
@@ -51,15 +45,13 @@ function AddMembershipModalContent({
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
const mem = m.create.membership;
const modal =
membershipCardId in mem.modals
? mem.modals[membershipCardId as keyof typeof mem.modals]
: null;
const defaults = modal?.sections ?? {
eligibility: "",
joiningProcess: "",
expectations: "",
const mem = m.create.customRule.membership;
const method = mem.methods.find((entry) => entry.id === membershipCardId);
const sections = method?.sections;
const defaults: Record<SectionField, string> = {
eligibility: sections?.eligibility ?? "",
joiningProcess: sections?.joiningProcess ?? "",
expectations: sections?.expectations ?? "",
};
const [sectionValues, setSectionValues] = useState<
@@ -95,7 +87,7 @@ function AddMembershipModalContent({
export function MembershipMethodsScreen() {
const m = useMessages();
const mem = m.create.membership;
const mem = m.create.customRule.membership;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
@@ -111,18 +103,32 @@ export function MembershipMethodsScreen() {
[updateState],
);
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(
() =>
MEMBERSHIP_CARD_ORDER.map((id) => {
const row = mem.cards[id as keyof typeof mem.cards];
return {
id,
label: row.label,
supportText: row.supportText,
recommended: true,
};
}),
[mem],
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 title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
@@ -156,25 +162,10 @@ export function MembershipMethodsScreen() {
};
}
if (pendingCardId in mem.modals) {
const modal = mem.modals[pendingCardId as keyof typeof mem.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: mem.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
const cardRow =
pendingCardId in mem.cards
? mem.cards[pendingCardId as keyof typeof mem.cards]
: null;
const method = methodById.get(pendingCardId);
return {
title: cardRow?.label ?? mem.confirmModal.title,
description: cardRow?.supportText ?? mem.confirmModal.description,
title: method?.label ?? mem.confirmModal.title,
description: method?.supportText ?? mem.confirmModal.description,
nextButtonText: mem.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
@@ -235,6 +226,7 @@ export function MembershipMethodsScreen() {
hasMore={true}
toggleLabel={mem.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
@@ -17,7 +17,7 @@ import {
export function CompletedScreen() {
const mdUp = useCreateFlowMdUp();
const m = useMessages();
const completed = m.create.completed;
const completed = m.create.reviewAndComplete.completed;
const fallbackSections = useMemo(
() =>
@@ -14,7 +14,7 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
*/
export function InformationalScreen() {
const mdUp = useCreateFlowMdUp();
const copy = useMessages().create.informational;
const copy = useMessages().create.community.informational;
const items = [
{
@@ -14,7 +14,7 @@ import {
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
export function CommunityReviewScreen() {
const lgUp = useCreateFlowLgUp();
const t = useTranslation("create.review");
const t = useTranslation("create.community.review");
const { state } = useCreateFlow();
const cardTitle =
@@ -27,12 +27,12 @@ function buildFinalReviewCategories(
export function FinalReviewScreen() {
const { state } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.finalReview");
const t = useTranslation("create.reviewAndComplete.finalReview");
const m = useMessages();
const finalReviewCategories = useMemo(
() => buildFinalReviewCategories(m.create.finalReview.categories),
[m.create.finalReview.categories],
() => buildFinalReviewCategories(m.create.reviewAndComplete.finalReview.categories),
[m.create.reviewAndComplete.finalReview.categories],
);
const ruleCardTitle = useMemo(() => {
@@ -9,8 +9,9 @@
*
* Card click opens the Figma "Add Approach" create modal (node `20870-72155`) with five controls:
* Core Principle, Applicable Scope, Step-by-Step Instructions, Consensus Level, and Objections &
* Deadlocks. Section defaults are sourced from `messages/en/create/rightRail.json` and will be
* replaced with DB-driven content; labels are hard-coded per the Figma design.
* Deadlocks. Section defaults are sourced from `messages/en/create/customRule/decisionApproaches.json` (read
* via `m.create.customRule.decisionApproaches`) and will be replaced with DB-driven content; labels are
* hard-coded per the Figma design.
*/
import { useState, useCallback, useMemo } from "react";
@@ -24,6 +25,11 @@ import type { CardStackItem } from "../../../../components/utility/CardStack/Car
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
deriveCompactCards,
rankMethodsByScore,
useFacetRecommendations,
} from "../../hooks/useFacetRecommendations";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import ModalTextAreaField from "../../components/ModalTextAreaField";
import ApplicableScopeField from "../../components/ApplicableScopeField";
@@ -49,12 +55,9 @@ function AddDecisionApproachModalContent({
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
const rr = m.create.rightRail;
const modal =
approachCardId in rr.modals
? rr.modals[approachCardId as keyof typeof rr.modals]
: null;
const modalSections = modal?.sections;
const da = m.create.customRule.decisionApproaches;
const method = da.methods.find((entry) => entry.id === approachCardId);
const modalSections = method?.sections;
const defaults: RightRailModalSections = {
corePrinciple: modalSections?.corePrinciple ?? "",
applicableScope: modalSections?.applicableScope ?? [],
@@ -87,13 +90,13 @@ function AddDecisionApproachModalContent({
return (
<div className="flex flex-col gap-6">
<ModalTextAreaField
label={rr.sectionHeadings.corePrinciple}
label={da.sectionHeadings.corePrinciple}
value={sections.corePrinciple}
onChange={(v) => patch("corePrinciple", v)}
/>
<ApplicableScopeField
label={rr.sectionHeadings.applicableScope}
addLabel={rr.scopeAddButtonLabel}
label={da.sectionHeadings.applicableScope}
addLabel={da.scopeAddButtonLabel}
scopes={sections.applicableScope}
selectedScopes={sections.selectedApplicableScope}
onToggleScope={(scope) =>
@@ -109,12 +112,12 @@ function AddDecisionApproachModalContent({
}
/>
<ModalTextAreaField
label={rr.sectionHeadings.stepByStepInstructions}
label={da.sectionHeadings.stepByStepInstructions}
value={sections.stepByStepInstructions}
onChange={(v) => patch("stepByStepInstructions", v)}
/>
<IncrementerBlock
label={rr.sectionHeadings.consensusLevel}
label={da.sectionHeadings.consensusLevel}
value={sections.consensusLevel}
min={CONSENSUS_LEVEL_MIN}
max={CONSENSUS_LEVEL_MAX}
@@ -125,7 +128,7 @@ function AddDecisionApproachModalContent({
incrementAriaLabel="Increase consensus level"
/>
<ModalTextAreaField
label={rr.sectionHeadings.objectionsDeadlocks}
label={da.sectionHeadings.objectionsDeadlocks}
value={sections.objectionsDeadlocks}
onChange={(v) => patch("objectionsDeadlocks", v)}
/>
@@ -135,7 +138,7 @@ function AddDecisionApproachModalContent({
export function DecisionApproachesScreen() {
const m = useMessages();
const rr = m.create.rightRail;
const da = m.create.customRule.decisionApproaches;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
@@ -156,41 +159,53 @@ export function DecisionApproachesScreen() {
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
() =>
rr.messageBox.items.map((item) => ({
da.messageBox.items.map((item) => ({
id: item.id,
label: item.label,
})),
[rr.messageBox.items],
[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(
() =>
rr.cards.map((c) => ({
id: c.id,
label: c.label,
supportText: c.supportText,
recommended: c.recommended,
rankedMethods.map((entry) => ({
id: entry.id,
label: entry.label,
supportText: entry.supportText,
recommended: recommendedIds.has(entry.id),
})),
[rr.cards],
[rankedMethods, recommendedIds],
);
const cardById = useMemo(
() => new Map(rr.cards.map((c) => [c.id, c])),
[rr.cards],
const methodById = useMemo(
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
[rankedMethods],
);
const sidebarDescription = (
<>
{rr.sidebar.descriptionBefore}
{da.sidebar.descriptionBefore}
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{rr.sidebar.descriptionLinkLabel}
{da.sidebar.descriptionLinkLabel}
</InlineTextButton>
{rr.sidebar.descriptionAfter}
{da.sidebar.descriptionAfter}
</>
);
@@ -239,26 +254,17 @@ export function DecisionApproachesScreen() {
const modalConfig = (() => {
if (!pendingCardId) {
return {
title: rr.confirmModal.title,
description: rr.confirmModal.description,
nextButtonText: rr.confirmModal.nextButtonText,
title: da.confirmModal.title,
description: da.confirmModal.description,
nextButtonText: da.confirmModal.nextButtonText,
};
}
if (pendingCardId in rr.modals) {
const modal = rr.modals[pendingCardId as keyof typeof rr.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: rr.addApproach.nextButtonText,
};
}
const card = cardById.get(pendingCardId);
const method = methodById.get(pendingCardId);
return {
title: card?.label ?? rr.confirmModal.title,
description: card?.supportText ?? rr.confirmModal.description,
nextButtonText: rr.addApproach.nextButtonText,
title: method?.label ?? da.confirmModal.title,
description: method?.supportText ?? da.confirmModal.description,
nextButtonText: da.addApproach.nextButtonText,
};
})();
@@ -268,9 +274,9 @@ export function DecisionApproachesScreen() {
lgVerticalAlign="start"
header={
<DecisionMakingSidebar
title={rr.sidebar.title}
title={da.sidebar.title}
description={sidebarDescription}
messageBoxTitle={rr.messageBox.title}
messageBoxTitle={da.messageBox.title}
messageBoxItems={messageBoxItems}
messageBoxCheckedIds={messageBoxCheckedIds}
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
@@ -287,12 +293,13 @@ export function DecisionApproachesScreen() {
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel={rr.cardStack.toggleSeeAll}
showLessLabel={rr.cardStack.toggleShowLess}
toggleLabel={da.cardStack.toggleSeeAll}
showLessLabel={da.cardStack.toggleShowLess}
title=""
description=""
layout="singleStack"
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
className="w-full"
headerLockupSize={mdUp ? "L" : "M"}
/>
@@ -27,7 +27,7 @@ function selectedIdsFromOptions(options: ChipOption[]): string[] {
/** Create Community — Figma `20094:41317`, chips only (layout tokens shared with structure select). */
export function CommunitySizeSelectScreen() {
const m = useMessages();
const cs = m.create.communitySize;
const cs = m.create.community.communitySize;
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const [communitySizeOptions, setCommunitySizeOptions] = useState<
@@ -106,7 +106,7 @@ function snapshotRowsToChipOptions(
/** Create Community step 3 — Figma `20094:18244` (responsive grid + column caps via `createFlowLayoutTokens`). */
export function CommunityStructureSelectScreen() {
const m = useMessages();
const cs = m.create.communityStructure;
const cs = m.create.community.communityStructure;
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const [organizationTypeOptions, setOrganizationTypeOptions] = useState<
@@ -12,7 +12,7 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
export function ConfirmStakeholdersScreen() {
const { markCreateFlowInteraction } = useCreateFlow();
const t = useTranslation("create.confirmStakeholders");
const t = useTranslation("create.reviewAndComplete.confirmStakeholders");
const [toastDismissed, setToastDismissed] = useState(false);
const [stakeholderOptions, setStakeholderOptions] = useState<ChipOption[]>(
[],
@@ -99,7 +99,7 @@ function snapshotRowsToChipOptions(
/** Create Custom — Core Values (Figma `20264:68378`). Up to five selections; preset list + custom chips. */
export function CoreValuesSelectScreen() {
const m = useMessages();
const cv = m.create.coreValues;
const cv = m.create.customRule.coreValues;
const presets = useMemo(
() => normalizeCoreValuePresets(cv.values as CoreValuePresetJson[]),
[cv.values],
@@ -10,7 +10,7 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
/** Create Community — Figma Flow — Upload `20094:41524`. */
export function CommunityUploadScreen() {
const m = useMessages();
const u = m.create.communityUpload;
const u = m.create.community.communityUpload;
const { markCreateFlowInteraction } = useCreateFlow();
const handleUploadClick = () => {
+4 -4
View File
@@ -85,13 +85,13 @@ export interface CreateFlowState {
coreValuesChipsSnapshot?: CommunityStructureChipSnapshotRow[];
/** User-authored detail text keyed by chip id (preset ids or custom UUIDs). */
coreValueDetailsByChipId?: Record<string, CoreValueDetailEntry>;
/** Create Custom — communication methods step (`/create/communication-methods`); card ids from `create.communication` presets. */
/** Create Custom — communication methods step (`/create/communication-methods`); card ids from `create.customRule.communication` presets. */
selectedCommunicationMethodIds?: string[];
/** Create Custom — membership / join patterns (`/create/membership-methods`); card ids from `create.membership` presets. */
/** Create Custom — membership / join patterns (`/create/membership-methods`); card ids from `create.customRule.membership` presets. */
selectedMembershipMethodIds?: string[];
/** Create Custom — decision approaches (`/create/decision-approaches`); card ids from `create.rightRail` presets. */
/** Create Custom — decision approaches (`/create/decision-approaches`); card ids from `create.customRule.decisionApproaches` presets. */
selectedDecisionApproachIds?: string[];
/** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.conflictManagement` presets. */
/** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.customRule.conflictManagement` presets. */
selectedConflictManagementIds?: string[];
currentStep?: CreateFlowStep;
/** Section drafts; structure will tighten as steps persist real shapes. */
@@ -20,7 +20,9 @@ interface CreateFlowScreenDefinition {
/** Figma node id (file Community-Rule-System), dev mode. */
figmaNodeId: string;
/**
* Namespace for `useTranslation`, e.g. `create.communityName`.
* Namespace for `useTranslation`, e.g. `create.community.communityName`.
* Stage prefix (`community` / `customRule` / `reviewAndComplete`) matches the
* messages folder layout — see `messages/en/index.ts` and `docs/guides/template-recommendation-matrix.md` §1c.
* Not all screens use i18n the same way (e.g. card step uses `useMessages` elsewhere).
*/
messageNamespace: string;
@@ -40,97 +42,97 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
informational: {
layoutKind: "informational",
figmaNodeId: "20094-16005",
messageNamespace: "create.informational",
messageNamespace: "create.community.informational",
centeredBodyBelowMd: false,
},
"community-name": {
layoutKind: "text",
figmaNodeId: "20094-18187",
messageNamespace: "create.communityName",
messageNamespace: "create.community.communityName",
centeredBodyBelowMd: true,
},
"community-size": {
layoutKind: "select",
figmaNodeId: "20094-41317",
messageNamespace: "create.communitySize",
messageNamespace: "create.community.communitySize",
centeredBodyBelowMd: false,
},
"community-context": {
layoutKind: "text",
figmaNodeId: "20094-41243",
messageNamespace: "create.communityContext",
messageNamespace: "create.community.communityContext",
centeredBodyBelowMd: true,
},
"community-structure": {
layoutKind: "select",
figmaNodeId: "20094-18244",
messageNamespace: "create.communityStructure",
messageNamespace: "create.community.communityStructure",
centeredBodyBelowMd: false,
},
"community-upload": {
layoutKind: "upload",
figmaNodeId: "20094-41524",
messageNamespace: "create.communityUpload",
messageNamespace: "create.community.communityUpload",
centeredBodyBelowMd: false,
},
"community-save": {
layoutKind: "text",
figmaNodeId: "20097-14948",
messageNamespace: "create.communitySave",
messageNamespace: "create.community.communitySave",
centeredBodyBelowMd: true,
},
review: {
layoutKind: "review",
figmaNodeId: "19706-12135",
messageNamespace: "create.review",
messageNamespace: "create.community.review",
centeredBodyBelowMd: false,
},
"core-values": {
layoutKind: "select",
figmaNodeId: "20264-68378",
messageNamespace: "create.coreValues",
messageNamespace: "create.customRule.coreValues",
centeredBodyBelowMd: false,
},
"communication-methods": {
layoutKind: "card",
figmaNodeId: "20246-15828",
messageNamespace: "create.communication",
messageNamespace: "create.customRule.communication",
centeredBodyBelowMd: false,
},
"membership-methods": {
layoutKind: "card",
figmaNodeId: "20858-13947",
messageNamespace: "create.membership",
messageNamespace: "create.customRule.membership",
centeredBodyBelowMd: false,
},
"decision-approaches": {
layoutKind: "right-rail",
figmaNodeId: "20523-23509",
messageNamespace: "create.rightRail",
messageNamespace: "create.customRule.decisionApproaches",
centeredBodyBelowMd: false,
},
"conflict-management": {
layoutKind: "card",
figmaNodeId: "20879-15979",
messageNamespace: "create.conflictManagement",
messageNamespace: "create.customRule.conflictManagement",
centeredBodyBelowMd: false,
},
"confirm-stakeholders": {
layoutKind: "select",
figmaNodeId: "21104-46594",
messageNamespace: "create.confirmStakeholders",
messageNamespace: "create.reviewAndComplete.confirmStakeholders",
centeredBodyBelowMd: false,
},
"final-review": {
layoutKind: "review",
figmaNodeId: "20907-212767",
messageNamespace: "create.finalReview",
messageNamespace: "create.reviewAndComplete.finalReview",
centeredBodyBelowMd: false,
},
completed: {
layoutKind: "completed",
figmaNodeId: "20907-213286",
messageNamespace: "create.completed",
messageNamespace: "create.reviewAndComplete.completed",
centeredBodyBelowMd: false,
},
};
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse, type NextRequest } from "next/server";
import { isDatabaseConfigured } from "../../../../lib/server/env";
import { listMethodRecommendations } from "../../../../lib/server/methodRecommendations";
import { dbUnavailable } from "../../../../lib/server/responses";
import {
SECTION_IDS,
type SectionId,
parseRequestedFacetsFromSearchParams,
} from "../../../../lib/server/validation/methodFacetsSchemas";
const SECTION_SET = new Set<string>(SECTION_IDS);
/**
* GET /api/create-flow/methods?section=<section>[&facet.*=...]
*
* Returns slugs + per-method match scores for one of the four card-deck
* sections; the wizard renders by looking up the slug in the section's
* messages file (`useMessages().create.customRule.<section>.methods`).
*
* See `docs/guides/template-recommendation-matrix.md` §9.2 / §10.
*/
export async function GET(request: NextRequest) {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const sectionParam = request.nextUrl.searchParams.get("section");
if (!sectionParam || !SECTION_SET.has(sectionParam)) {
return NextResponse.json(
{
error: {
code: "validation_error",
message: `Unknown section. Expected one of: ${SECTION_IDS.join(", ")}`,
},
},
{ status: 400 },
);
}
const section = sectionParam as SectionId;
const facets = parseRequestedFacetsFromSearchParams(
request.nextUrl.searchParams,
);
const result = await listMethodRecommendations({ section, facets });
if (!result) {
// DB query failed; return empty so the wizard falls back to its messages
// deck in authoring order (§10).
return NextResponse.json({ section, methods: [] });
}
const methods = result.rankedSlugs.map((slug) => ({
slug,
matches: result.matchesBySlug[slug] ?? { score: 0, matchedFacets: [] },
}));
return NextResponse.json({ section, methods });
}
+17
View File
@@ -68,3 +68,20 @@ export async function PUT(request: NextRequest) {
draft: { payload: draft.payload, updatedAt: draft.updatedAt },
});
}
export async function DELETE() {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Idempotent: missing draft is a no-op so callers can fire-and-forget after
// publish / exit without worrying about prior state.
await prisma.ruleDraft.deleteMany({ where: { userId: user.id } });
return NextResponse.json({ ok: true });
}
+20 -6
View File
@@ -1,17 +1,31 @@
import { NextResponse } from "next/server";
import { NextResponse, type NextRequest } from "next/server";
import { isDatabaseConfigured } from "../../../lib/server/env";
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
import { listRankedRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
import { dbUnavailable } from "../../../lib/server/responses";
import { parseRequestedFacetsFromSearchParams } from "../../../lib/server/validation/methodFacetsSchemas";
/**
* Curated rule templates for recommendations (seed via Prisma Studio or a script).
* GET /api/templates
*
* No params → curated ordering (`featured` desc, `sortOrder` asc, `title`
* asc). With `facet.<group>=<value>` query params (repeatable per group),
* templates are re-ranked by composed-method match count; ties fall back to
* the curated order, score-0 templates remain at the end.
*
* See `docs/guides/template-recommendation-matrix.md` §9.1.
*/
export async function GET() {
export async function GET(request: NextRequest) {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const templates = await listRuleTemplatesFromDb();
const facets = parseRequestedFacetsFromSearchParams(
request.nextUrl.searchParams,
);
const { templates, scores } = await listRankedRuleTemplatesFromDb(facets);
const hasScores = Object.keys(scores).length > 0;
return NextResponse.json({ templates });
return NextResponse.json(
hasScores ? { templates, scores } : { templates },
);
}
@@ -26,6 +26,7 @@ const CardStackContainer = memo<CardStackProps>(
description = "",
layout = "default",
compactRecommendedLimit = 5,
compactCardIds,
compactDesktopLayout: compactDesktopLayoutProp = "grid",
headerLockupSize,
toggleAlignment = "center",
@@ -83,6 +84,7 @@ const CardStackContainer = memo<CardStackProps>(
description={description}
layout={layout}
compactRecommendedLimit={compactRecommendedLimit}
compactCardIds={compactCardIds}
compactDesktopLayout={compactDesktopLayoutProp}
headerLockupSize={headerLockupSize}
toggleAlignment={toggleAlignment}
@@ -25,6 +25,16 @@ export interface CardStackProps {
* Max recommended cards in compact (non-expanded) mode. Default 5; Figma compact stack uses 3.
*/
compactRecommendedLimit?: number;
/**
* Optional explicit list of card ids to render in the compact slot, in
* order. When provided, this overrides the default
* `cards.filter(c => c.recommended)` selection — the `recommended` flag
* then only controls the visual "Recommended" badge. Used by the
* create-flow card-deck steps so facet scores can pick the compact set
* (and badge only the truly matched subset). Cards whose ids are not in
* `cards` are silently dropped.
*/
compactCardIds?: string[];
/**
* At `md+`, how compact recommended cards are laid out. `flexWrap` matches Figma Flow — Compact Card Stack (three cards in a row).
* `pyramidFive` = two rows (3 + 2) centered for five recommended cards (membership step).
@@ -50,6 +60,7 @@ export interface CardStackViewProps {
description: string;
layout: "default" | "singleStack";
compactRecommendedLimit: number;
compactCardIds: string[] | undefined;
compactDesktopLayout: "grid" | "flexWrap" | "pyramidFive";
headerLockupSize: HeaderLockupSizeValue | undefined;
toggleAlignment: "center" | "end";
@@ -17,6 +17,7 @@ export function CardStackView({
description,
layout,
compactRecommendedLimit,
compactCardIds,
compactDesktopLayout,
headerLockupSize,
toggleAlignment,
@@ -24,10 +25,22 @@ export function CardStackView({
}: CardStackViewProps) {
const lockupSize = headerLockupSize ?? "L";
const isSelected = (id: string) => selectedIds.includes(id);
// Compact: recommended only (default up to 5). Expanded: all cards.
const compactCards = cards
.filter((c) => c.recommended ?? false)
.slice(0, compactRecommendedLimit);
// Compact: explicit `compactCardIds` (caller-driven, used by create-flow
// facet ranker) takes precedence over the legacy `recommended`-filter so
// the screen can show un-tagged cards in the compact slot when there is
// no facet signal yet (CR-88 §10).
const compactCards = (() => {
if (compactCardIds && compactCardIds.length > 0) {
const byId = new Map(cards.map((c) => [c.id, c]));
return compactCardIds
.map((id) => byId.get(id))
.filter((c): c is (typeof cards)[number] => c !== undefined)
.slice(0, compactRecommendedLimit);
}
return cards
.filter((c) => c.recommended ?? false)
.slice(0, compactRecommendedLimit);
})();
// Single stack: always one column; expand reveals more in same stack (scrollable)
if (layout === "singleStack") {