Template recommendation implemented
This commit is contained in:
@@ -15,7 +15,16 @@ import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
|
||||
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
|
||||
import CreateFlowFooter from "../../components/navigation/CreateFlowFooter";
|
||||
import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav";
|
||||
import { getNextStep, getStepIndex, parseReviewReturnSearchParam, CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "./utils/flowSteps";
|
||||
import {
|
||||
getNextStep,
|
||||
getStepIndex,
|
||||
parseReviewReturnSearchParam,
|
||||
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||
TEMPLATES_FACET_RECOMMEND_QUERY,
|
||||
TEMPLATES_FACET_RECOMMEND_VALUE,
|
||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
||||
} from "./utils/flowSteps";
|
||||
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
||||
import {
|
||||
createFlowStepUsesCenteredTextLayout,
|
||||
@@ -600,7 +609,9 @@ function CreateFlowLayoutContent({
|
||||
// detour. Direct entries to `/templates` (no marker) and
|
||||
// home "Popular templates" clicks always start fresh by
|
||||
// wiping anonymous draft storage at click time.
|
||||
router.push("/templates?fromFlow=1");
|
||||
router.push(
|
||||
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{footer.createFromTemplate}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import facetGroups from "../../../../data/create/customRule/_facetGroups.json";
|
||||
import {
|
||||
type CreateFlowState,
|
||||
} from "../types";
|
||||
import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
/**
|
||||
@@ -16,60 +13,6 @@ export type RecommendationSection =
|
||||
| "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;
|
||||
@@ -99,7 +42,10 @@ export function useFacetRecommendations(
|
||||
section: RecommendationSection,
|
||||
): FacetRecommendationsResult {
|
||||
const { state } = useCreateFlow();
|
||||
const queryString = useMemo(() => buildFacetQuery(state), [state]);
|
||||
const queryString = useMemo(
|
||||
() => buildFacetQueryString(state),
|
||||
[state],
|
||||
);
|
||||
const hasAnyFacets = queryString.length > 0;
|
||||
|
||||
const [result, setResult] = useState<FacetRecommendationsResult>({
|
||||
|
||||
@@ -153,6 +153,14 @@ export function parseCreateFlowScreenFromPathname(
|
||||
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
|
||||
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
|
||||
|
||||
/**
|
||||
* Only set from `/create/review` “Create from template” with `fromFlow=1`.
|
||||
* Enables facet-ranked `GET /api/templates` + “RECOMMENDED” on the grid; omit
|
||||
* on profile and marketing so stale localStorage facets never show badges.
|
||||
*/
|
||||
export const TEMPLATES_FACET_RECOMMEND_QUERY = "recommendTemplates" as const;
|
||||
export const TEMPLATES_FACET_RECOMMEND_VALUE = "1" as const;
|
||||
|
||||
/** `/create/{step}?reviewReturn=…` — set when opening a custom-rule step from final-review or edit-rule via + */
|
||||
export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const;
|
||||
|
||||
|
||||
@@ -6,8 +6,13 @@ import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
||||
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
||||
import { prepareFreshCreateFlowEntry } from "../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||
import { buildTemplateReviewHref } from "../../(app)/create/utils/flowSteps";
|
||||
import {
|
||||
buildTemplateReviewHref,
|
||||
TEMPLATES_FACET_RECOMMEND_QUERY,
|
||||
TEMPLATES_FACET_RECOMMEND_VALUE,
|
||||
} from "../../(app)/create/utils/flowSteps";
|
||||
import { useTranslation } from "../../contexts/MessagesContext";
|
||||
import { useTemplatesFacetGridEntries } from "./useTemplatesFacetGridEntries";
|
||||
|
||||
export interface TemplatesPageClientProps {
|
||||
initialGridEntries: TemplateGridCardEntry[];
|
||||
@@ -44,9 +49,16 @@ export default function TemplatesPageClient({
|
||||
{/* Suspense boundary required by `useSearchParams` below
|
||||
(Next.js 15+ static-generation contract). */}
|
||||
<Suspense
|
||||
fallback={<TemplatesGrid entries={initialGridEntries} fromFlow={false} />}
|
||||
fallback={
|
||||
<TemplatesGrid
|
||||
entries={initialGridEntries}
|
||||
fromFlow={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TemplatesGridWithSearchParams entries={initialGridEntries} />
|
||||
<TemplatesGridWithSearchParams
|
||||
initialGridEntries={initialGridEntries}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,18 +67,25 @@ export default function TemplatesPageClient({
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `fromFlow=1` off the URL so we can skip the fresh-slate clear when
|
||||
* the user arrived from `/create/review`'s "Create from template" button.
|
||||
* That button pushes `/templates?fromFlow=1` so their in-progress community
|
||||
* stage is preserved when they pick a template here.
|
||||
* - `fromFlow=1` — skip `prepareFreshCreateFlowEntry` on template click
|
||||
* (draft preserved). Used by review “Create from template” and profile.
|
||||
* - `recommendTemplates=1` (with review only) — rank templates + “RECOMMENDED”
|
||||
* from `GET /api/templates?facet.*` using the persisted community draft.
|
||||
*/
|
||||
function TemplatesGridWithSearchParams({
|
||||
entries,
|
||||
initialGridEntries,
|
||||
}: {
|
||||
entries: TemplateGridCardEntry[];
|
||||
initialGridEntries: TemplateGridCardEntry[];
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const fromFlow = searchParams.get("fromFlow") === "1";
|
||||
const enableFacetRecommendations =
|
||||
searchParams.get(TEMPLATES_FACET_RECOMMEND_QUERY) ===
|
||||
TEMPLATES_FACET_RECOMMEND_VALUE;
|
||||
const entries = useTemplatesFacetGridEntries({
|
||||
initialGridEntries,
|
||||
enableFacetRecommendations,
|
||||
});
|
||||
return <TemplatesGrid entries={entries} fromFlow={fromFlow} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { readAnonymousCreateFlowState } from "../../(app)/create/utils/anonymousDraftStorage";
|
||||
import { buildFacetQueryString } from "../../../lib/create/buildFacetQueryString";
|
||||
import {
|
||||
fetchRankedTemplatesByFacets,
|
||||
isTemplatesFetchAborted,
|
||||
} from "../../../lib/create/fetchTemplates";
|
||||
import {
|
||||
gridEntriesWithFacetScores,
|
||||
type TemplateGridCardEntry,
|
||||
} from "../../../lib/templates/templateGridPresentation";
|
||||
|
||||
type UseTemplatesFacetGridEntriesArgs = {
|
||||
initialGridEntries: TemplateGridCardEntry[];
|
||||
enableFacetRecommendations: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* When `enableFacetRecommendations` (review → “Create from template” only),
|
||||
* re-fetch ranked templates from `GET /api/templates?facet.*` using the
|
||||
* persisted create-flow draft. Otherwise returns `initialGridEntries` from SSR.
|
||||
*/
|
||||
export function useTemplatesFacetGridEntries({
|
||||
initialGridEntries,
|
||||
enableFacetRecommendations,
|
||||
}: UseTemplatesFacetGridEntriesArgs): TemplateGridCardEntry[] {
|
||||
const [entries, setEntries] = useState(initialGridEntries);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableFacetRecommendations) {
|
||||
setEntries(initialGridEntries);
|
||||
return;
|
||||
}
|
||||
const state = readAnonymousCreateFlowState();
|
||||
const facetQuery = buildFacetQueryString(state);
|
||||
if (facetQuery.length === 0) {
|
||||
setEntries(initialGridEntries);
|
||||
return;
|
||||
}
|
||||
|
||||
const ac = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await fetchRankedTemplatesByFacets({
|
||||
signal: ac.signal,
|
||||
facetQuery,
|
||||
});
|
||||
if (ac.signal.aborted) return;
|
||||
if ("error" in result) {
|
||||
setEntries(initialGridEntries);
|
||||
return;
|
||||
}
|
||||
setEntries(
|
||||
gridEntriesWithFacetScores(result.templates, result.scores),
|
||||
);
|
||||
} catch (e) {
|
||||
if (isTemplatesFetchAborted(e)) return;
|
||||
setEntries(initialGridEntries);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
ac.abort();
|
||||
};
|
||||
}, [enableFacetRecommendations, initialGridEntries]);
|
||||
|
||||
return entries;
|
||||
}
|
||||
Reference in New Issue
Block a user