diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 0c19c8b..a23835a 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -135,8 +135,14 @@ function CreateFlowLayoutContent({ } = useCreateFlowNavigation( skipCommunitySave ? { skipCommunitySave: true } : undefined, ); - const { state, clearState, updateState, resetCustomRuleSelections, setMethodSectionsPinCommitted, replaceState } = - useCreateFlow(); + const { + state, + clearState, + updateState, + resetCustomRuleSelections, + setMethodSectionsPinCommitted, + replaceState, + } = useCreateFlow(); const { draftSaveBannerMessage, setDraftSaveBannerMessage } = useCreateFlowDraftSaveBanner(); const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] = diff --git a/app/(app)/create/hooks/useTemplateReviewActions.ts b/app/(app)/create/hooks/useTemplateReviewActions.ts index be976ad..a19b686 100644 --- a/app/(app)/create/hooks/useTemplateReviewActions.ts +++ b/app/(app)/create/hooks/useTemplateReviewActions.ts @@ -5,13 +5,14 @@ import { buildTemplateCustomizePrefill } from "../../../../lib/create/applyTempl import { loadTemplateReviewBySlug } from "../../../../lib/create/loadTemplateReviewBySlug"; import { stripCustomRuleSelectionFields } from "../../../../lib/create/stripCustomRuleSelectionFields"; import messages from "../../../../messages/en/index"; -import type { CreateFlowState } from "../types"; +import type { + CreateFlowContextValue, + CreateFlowState, +} from "../types"; type AppRouterLike = { push: (_href: string) => void }; -type UpdateState = (_patch: Partial) => void; -type ReplaceStateFn = ( - _next: CreateFlowState | ((_prev: CreateFlowState) => CreateFlowState), -) => void; +type UpdateState = CreateFlowContextValue["updateState"]; +type ReplaceStateFn = CreateFlowContextValue["replaceState"]; export type UseTemplateReviewActionsResult = { /** True iff the current pathname is a template-review route (locale/basePath tolerant). */ diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx index d3e1b06..d17a4e8 100644 --- a/app/(app)/create/screens/review/FinalReviewScreen.tsx +++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx @@ -123,7 +123,7 @@ export function FinalReviewScreen({ [markCreateFlowInteraction, updateState, state], ); - const { categories: finalReviewCategories, chipLookup } = useMemo(() => { + const { categories: finalReviewCategories } = useMemo(() => { const { names, rows: fallbackRows } = readFallbackCategoryRows( m.create.reviewAndComplete.finalReview.categories, ); @@ -196,7 +196,7 @@ export function FinalReviewScreen({ : undefined, }; }); - return { categories: cats, chipLookup: lookup }; + return { categories: cats }; }, [ m.create.reviewAndComplete.finalReview.categories, state, @@ -204,7 +204,6 @@ export function FinalReviewScreen({ goToStep, variant, ]); - void chipLookup; const ruleCardTitle = useMemo(() => { const raw = typeof state.title === "string" ? state.title.trim() : ""; diff --git a/app/api/templates/route.ts b/app/api/templates/route.ts index 16ea3cd..403d588 100644 --- a/app/api/templates/route.ts +++ b/app/api/templates/route.ts @@ -9,8 +9,9 @@ import { parseRequestedFacetsFromSearchParams } from "../../../lib/server/valida * * No params → curated ordering (`featured` desc, `sortOrder` asc, `title` * asc). With `facet.=` 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. + * templates are re-ranked by `TemplateFacet` matrix match when seeded for + * that slug, else by composed-method × `MethodFacet` 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. */ diff --git a/lib/create/applyTemplatePrefill.ts b/lib/create/applyTemplatePrefill.ts index a8cf7ef..d605ced 100644 --- a/lib/create/applyTemplatePrefill.ts +++ b/lib/create/applyTemplatePrefill.ts @@ -76,36 +76,6 @@ function buildCoreValuePrefill( }; } -/** - * Variant of {@link buildTemplateCustomizePrefill} that pulls *only* the - * Values section out of a template body. Used by the "Use without changes" - * handler so the verbatim template flow still seeds - * `coreValuesChipsSnapshot` + `selectedCoreValueIds` — without that, the - * final-review screen has no per-chip ids to attach edits to and falls - * back to the read-only chip modal for values. - * - * Returns an empty object when the body is malformed or has no Values - * section. - */ -export function buildCoreValuesPrefillFromTemplateBody( - body: unknown, -): Partial { - if (!body || typeof body !== "object") return {}; - const sections = (body as { sections?: unknown }).sections; - if (!Array.isArray(sections)) return {}; - - for (const raw of sections) { - if (!isTemplateSection(raw)) continue; - const key = normaliseCategoryKey(raw.categoryName as string); - if (key !== "values" && key !== "corevalues") continue; - const titles = entryTitles(raw.entries); - if (titles.length === 0) continue; - return buildCoreValuePrefill(titles); - } - - return {}; -} - /** * Map a curated template `body` (DB shape — `sections[]` with `categoryName` * + `entries[].title`) to the `CreateFlowState` keys the Create Custom Rule @@ -173,3 +143,19 @@ export function buildTemplateCustomizePrefill( return prefill; } + +/** + * Values section only — delegates to {@link buildTemplateCustomizePrefill} + * (same matching rules as Customize). Used by tests and any caller that + * needs core-value snapshot seeding without method fields. + */ +export function buildCoreValuesPrefillFromTemplateBody( + body: unknown, +): Partial { + const full = buildTemplateCustomizePrefill(body); + if (full.selectedCoreValueIds === undefined) return {}; + return { + selectedCoreValueIds: full.selectedCoreValueIds, + coreValuesChipsSnapshot: full.coreValuesChipsSnapshot, + }; +} diff --git a/lib/create/coreValueChipOptionsFromDraft.ts b/lib/create/coreValueChipOptionsFromDraft.ts index fa89cb3..6968885 100644 --- a/lib/create/coreValueChipOptionsFromDraft.ts +++ b/lib/create/coreValueChipOptionsFromDraft.ts @@ -13,23 +13,6 @@ function chipRowsFromPresets(presets: readonly CoreValuePreset[]): ChipOption[] })); } -function applySavedSelectionToPresetsOnly( - options: ChipOption[], - saved: string[] | undefined, -): ChipOption[] { - const selected = new Set(saved ?? []); - return options.map((opt) => - opt.state === "custom" - ? opt - : { - ...opt, - state: selected.has(opt.id) - ? ("selected" as const) - : ("unselected" as const), - }, - ); -} - /** Valid MultiSelect chip state from snapshot JSON. */ function normalizeChipState(s: unknown): ChipOption["state"] | undefined { return s === "selected" || @@ -58,7 +41,10 @@ export function buildCoreValueChipOptionsFromDraft( const selected = new Set(selectedCoreValueIds ?? []); if (!snapshot?.length) { - return applySavedSelectionToPresetsOnly(presetBase, selectedCoreValueIds); + return presetBase.map((opt) => ({ + ...opt, + state: selected.has(opt.id) ? ("selected" as const) : ("unselected" as const), + })); } const snapById = new Map(snapshot.map((r) => [r.id, r] as const)); diff --git a/lib/create/fetchTemplates.ts b/lib/create/fetchTemplates.ts index 9d6ef1e..85817a8 100644 --- a/lib/create/fetchTemplates.ts +++ b/lib/create/fetchTemplates.ts @@ -18,6 +18,39 @@ type TemplatesResponse = { scores?: Record; }; +function parseScoresPayload( + raw: unknown, +): Record { + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + return raw as Record; + } + return {}; +} + +async function getTemplatesJson( + queryString: string, + signal: AbortSignal | undefined, +): Promise< + | { ok: true; data: TemplatesResponse & { error?: string }; statusOk: boolean } + | { ok: false; error: "network" | "aborted" } +> { + const url = + queryString.length > 0 ? `/api/templates?${queryString}` : "/api/templates"; + try { + const res = await fetch(url, { + credentials: "include", + signal, + }); + const data = (await res.json()) as TemplatesResponse & { error?: string }; + return { ok: true, data, statusOk: res.ok }; + } catch (e) { + if (isAbortError(e)) { + return { ok: false, error: "aborted" }; + } + return { ok: false, error: "network" }; + } +} + /** Matches `listRankedRuleTemplatesFromDb` / GET `/api/templates` with facet params. */ export type TemplateFacetScoreDto = { score: number; @@ -48,27 +81,23 @@ export function isTemplatesFetchAborted(e: unknown): boolean { export async function fetchTemplates( options?: FetchTemplatesOptions, ): Promise { - try { - const res = await fetch("/api/templates", { - credentials: "include", - signal: options?.signal, - }); - const data = (await res.json()) as TemplatesResponse & { error?: string }; - if (!res.ok) { - return { - error: - typeof data.error === "string" - ? data.error - : "Could not load templates", - }; - } - return Array.isArray(data.templates) ? data.templates : []; - } catch (e) { - if (isAbortError(e)) { - throw e; + const got = await getTemplatesJson("", options?.signal); + if (got.ok === false) { + if (got.error === "aborted") { + throw new DOMException("Aborted", "AbortError"); } return { error: "Could not load templates" }; } + const { data, statusOk } = got; + if (!statusOk) { + return { + error: + typeof data.error === "string" + ? data.error + : "Could not load templates", + }; + } + return Array.isArray(data.templates) ? data.templates : []; } /** @@ -82,33 +111,24 @@ export async function fetchRankedTemplatesByFacets(options: { if (options.facetQuery.length === 0) { return { error: "Could not load templates" }; } - try { - const res = await fetch(`/api/templates?${options.facetQuery}`, { - credentials: "include", - signal: options.signal, - }); - const data = (await res.json()) as TemplatesResponse & { error?: string }; - if (!res.ok) { - return { - error: - typeof data.error === "string" - ? data.error - : "Could not load templates", - }; - } - const templates = Array.isArray(data.templates) ? data.templates : []; - const raw = data.scores; - const scores: Record = - raw && typeof raw === "object" && !Array.isArray(raw) - ? (raw as Record) - : {}; - return { templates, scores }; - } catch (e) { - if (isAbortError(e)) { - throw e; + const got = await getTemplatesJson(options.facetQuery, options.signal); + if (got.ok === false) { + if (got.error === "aborted") { + throw new DOMException("Aborted", "AbortError"); } return { error: "Could not load templates" }; } + const { data, statusOk } = got; + if (!statusOk) { + return { + error: + typeof data.error === "string" + ? data.error + : "Could not load templates", + }; + } + const templates = Array.isArray(data.templates) ? data.templates : []; + return { templates, scores: parseScoresPayload(data.scores) }; } export async function fetchTemplateBySlug( diff --git a/lib/templates/governanceTemplateCatalog.ts b/lib/templates/governanceTemplateCatalog.ts index 9ed699f..c35593b 100644 --- a/lib/templates/governanceTemplateCatalog.ts +++ b/lib/templates/governanceTemplateCatalog.ts @@ -14,9 +14,8 @@ export type GovernanceTemplateCatalogEntry = { /** Path under public/ for getAssetPath() — Figma Asset / Template Mark */ iconPath: string; /** - * When true, the templates grid shows the “RECOMMENDED” tag (facet-based - * scores will set this in `ruleTemplateToGridEntry` when wired; catalog - * entries omit unless intentionally static). + * When true, static catalog rows show the “RECOMMENDED” tag. Facet-ranked + * `/templates` sets this in `gridEntriesWithFacetScores` instead. */ recommended?: boolean; }; diff --git a/lib/templates/templateGridPresentation.ts b/lib/templates/templateGridPresentation.ts index e29ce84..069779a 100644 --- a/lib/templates/templateGridPresentation.ts +++ b/lib/templates/templateGridPresentation.ts @@ -114,22 +114,6 @@ export function gridEntriesWithFacetScores( const bySlug = (templates: RuleTemplateDto[]) => new Map(templates.map((t) => [t.slug, t] as const)); -/** - * Ordered subset for home: follow `slugOrder`; skip missing slugs. - */ -export function gridEntriesForSlugOrder( - templates: RuleTemplateDto[], - slugOrder: readonly string[], -): TemplateGridCardEntry[] { - const map = bySlug(templates); - const out: TemplateGridCardEntry[] = []; - for (const slug of slugOrder) { - const t = map.get(slug); - if (t) out.push(ruleTemplateToGridEntry(t)); - } - return out; -} - /** * Home row: prefer API row per slug; if missing, use static Figma catalog entry. */