Implement create custom recommendations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user