Keep the template picker in the create flow, make catalog cards real links, and stop showing a fake community when review has no draft.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilallo
2026-09-10 15:22:46 -06:00
co-authored by Cursor
parent 3f9a8395be
commit 90db213413
31 changed files with 412 additions and 126 deletions
+19 -19
View File
@@ -27,8 +27,7 @@ import {
shouldOfferCreateFlowSaveAndExit,
isDirectTemplateReviewEntry,
createFlowStepUsesSelectSplitScroll,
TEMPLATES_FACET_RECOMMEND_QUERY,
TEMPLATES_FACET_RECOMMEND_VALUE,
isCreateFlowTemplatesPickerPath,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
} from "./utils/flowSteps";
@@ -309,6 +308,8 @@ function CreateFlowLayoutContent({
fromCreateWizard,
markCreateFlowInteraction,
});
const isCreateTemplatesPickerRoute =
isCreateFlowTemplatesPickerPath(pathname);
const runAuthenticatedExit = useCreateFlowExit({
state,
@@ -566,9 +567,11 @@ function CreateFlowLayoutContent({
? "items-stretch overflow-y-auto md:overflow-hidden"
: isSelectSplitScrollStep
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
: isTemplateReviewRoute || isFinalReviewLike || isCardLayoutStep
? CREATE_FLOW_MD_CENTERED_MAIN_CLASS
: "items-start justify-center overflow-y-auto md:items-center";
: isCreateTemplatesPickerRoute
? "items-start justify-start overflow-y-auto"
: isTemplateReviewRoute || isFinalReviewLike || isCardLayoutStep
? CREATE_FLOW_MD_CENTERED_MAIN_CLASS
: "items-start justify-center overflow-y-auto md:items-center";
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
const mainMaxMdJustify =
@@ -579,10 +582,9 @@ function CreateFlowLayoutContent({
? "max-md:flex-col max-md:items-stretch"
: "max-md:flex-col max-md:items-center";
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
const saveDraftOnExit = shouldOfferCreateFlowSaveAndExit(
currentStep,
sessionUser,
);
const saveDraftOnExit =
shouldOfferCreateFlowSaveAndExit(currentStep, sessionUser) ||
isCreateTemplatesPickerRoute;
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
currentStep,
@@ -814,9 +816,11 @@ function CreateFlowLayoutContent({
contentMaxClass={getCreateFlowContentMaxClass({
step: currentStep,
isTemplateReview: isTemplateReviewRoute,
isTemplatesPicker: isCreateTemplatesPickerRoute,
})}
progressBar={
!isTemplateReviewRoute &&
!isCreateTemplatesPickerRoute &&
!isFinalReviewLike &&
reviewReturnTarget !== "edit-rule"
}
@@ -920,15 +924,7 @@ function CreateFlowLayoutContent({
disabled={isPublishing}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
// `fromFlow=1` tells `/templates` to skip the fresh-slate
// draft clear it normally runs on template click, so the
// user's in-progress Create Community stage survives this
// 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?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
);
router.push(CREATE_ROUTES.templatesPicker);
}}
>
{footer.createFromTemplate}
@@ -1013,7 +1009,11 @@ function CreateFlowLayoutContent({
) : null
}
onBackClick={
isTemplateReviewRoute
isCreateTemplatesPickerRoute
? () => {
router.push(CREATE_ROUTES.review);
}
: isTemplateReviewRoute
? () =>
router.push(
templateReviewFooterBackToCreateReview
@@ -15,6 +15,7 @@ import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./components/createFlowLayoutToke
import Alert from "../../components/modals/Alert";
import {
isValidStep,
isCreateFlowTemplatesPickerPath,
parseCreateFlowScreenFromPathname,
} from "./utils/flowSteps";
import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry";
@@ -111,6 +112,9 @@ export function SignedInDraftHydration({
if (pathname?.includes("/create/review-template/")) {
return;
}
if (isCreateFlowTemplatesPickerPath(pathname)) {
return;
}
if (touchedRef.current) {
finishedUserIdRef.current = userId;
return;
@@ -71,8 +71,9 @@ export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
export function getCreateFlowContentMaxClass(options: {
step: CreateFlowStep | null | undefined;
isTemplateReview?: boolean;
isTemplatesPicker?: boolean;
}): string {
if (options.isTemplateReview) {
if (options.isTemplateReview || options.isTemplatesPicker) {
return CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS;
}
if (!options.step) {
@@ -43,7 +43,8 @@ const blurActiveElement = (): void => {
*
* Template review footer Back uses {@link buildTemplateReviewHref}s
* `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so
* users who came from `/create/review` return there instead of `/`.
* users who came from `/create/review` via `/create/templates` return there
* instead of `/`.
*/
export function useCreateFlowNavigation(
options?: CreateFlowNavigationOptions,
@@ -1,8 +1,9 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Rule from "../../../../components/cards/Rule";
import Button from "../../../../components/buttons/Button";
import { useTranslation } from "../../../../contexts/MessagesContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -17,7 +18,7 @@ import {
vectorMarkPath,
} from "../../../../../lib/assetUtils";
import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState";
import { createFlowStepPath } from "../../utils/createFlowPaths";
import { CREATE_ROUTES, createFlowStepPath } from "../../utils/createFlowPaths";
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
export function CommunityReviewScreen() {
@@ -25,6 +26,15 @@ export function CommunityReviewScreen() {
const lgUp = useCreateFlowLgUp();
const t = useTranslation("create.community.review");
const { state, updateState } = useCreateFlow();
/**
* Server layout has an empty context; the client layout may already hold a
* named draft. Defer empty-vs-congrats until after mount so the first paint
* matches SSR (`null`) instead of swapping HeaderLockup titles.
*/
const [reviewReady, setReviewReady] = useState(false);
useEffect(() => {
setReviewReady(true);
}, []);
/**
* If the user picked **Customize** from a template before finishing community
@@ -70,11 +80,10 @@ export function CommunityReviewScreen() {
const cardTitle =
typeof state.title === "string" && state.title.trim().length > 0
? state.title.trim()
: t("ruleCard.title");
: "";
/**
* No placeholder fallback: if the user skipped `community-context`, leave
* the card description off rather than render the old "Mutual Aid Monday
* is a grassroots community…" sample, which read as real user copy.
* the card description off rather than render sample copy as real user data.
*/
const cardDescription =
typeof state.communityContext === "string" &&
@@ -88,6 +97,36 @@ export function CommunityReviewScreen() {
? state.communityAvatarUrl.trim()
: null;
if (state.pendingTemplateAction || !reviewReady) {
return null;
}
if (!cardTitle) {
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd="space-1400"
>
<div
className={`flex flex-col items-start gap-6 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<CreateFlowHeaderLockup
title={t("empty.title")}
description={t("empty.description")}
/>
<Button
buttonType="filled"
palette="default"
size="xsmall"
href={CREATE_ROUTES.createFirstStep}
>
{t("empty.startLabel")}
</Button>
</div>
</CreateFlowStepShell>
);
}
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
@@ -0,0 +1,45 @@
"use client";
import { GovernanceTemplateGrid } from "../../../components/sections/GovernanceTemplateGrid";
import type { TemplateGridCardEntry } from "../../../../lib/templates/templateGridPresentation";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useTemplatesFacetGridEntries } from "../../../(marketing)/templates/useTemplatesFacetGridEntries";
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
import { CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS } from "../components/createFlowLayoutTokens";
import { buildTemplateReviewHref } from "../utils/flowSteps";
export function CreateFlowTemplatesPageClient({
initialGridEntries,
}: {
initialGridEntries: TemplateGridCardEntry[];
}) {
const t = useTranslation("pages.templates");
const entries = useTemplatesFacetGridEntries({
initialGridEntries,
enableFacetRecommendations: true,
});
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-1400"
>
<div
className={`mx-auto flex w-full min-w-0 flex-col gap-6 pb-8 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
>
<CreateFlowHeaderLockup
title={t("title")}
description={t("subtitle")}
justification="left"
/>
<GovernanceTemplateGrid
entries={entries}
hrefForTemplate={(slug) =>
buildTemplateReviewHref(slug, { fromCreateWizard: true })
}
/>
</div>
</CreateFlowStepShell>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import messages from "../../../../messages/en/index";
import { listRuleTemplatesFromDb } from "../../../../lib/server/ruleTemplates";
import { routeMetadata } from "../../../../lib/siteMetadata";
import { gridEntriesForFullCatalogWithFallback } from "../../../../lib/templates/templateGridPresentation";
import { CREATE_ROUTES } from "../utils/createFlowPaths";
import { CreateFlowTemplatesPageClient } from "./CreateFlowTemplatesPageClient";
export const metadata: Metadata = routeMetadata(CREATE_ROUTES.templatesPicker, {
title: messages.metadata.templates.title,
});
/** In-flow template catalog after review “Create from template”. */
export default async function CreateFlowTemplatesPage() {
const rows = await listRuleTemplatesFromDb();
const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows);
return <CreateFlowTemplatesPageClient initialGridEntries={initialGridEntries} />;
}
+1 -1
View File
@@ -215,7 +215,7 @@ export interface CreateFlowState {
templateReviewBackSlug?: string;
/**
* True when the user opened `/create/review-template/{slug}` from the create
* wizard (`/templates?fromFlow=1` after `/create/review`). Persisted so Back
* wizard (`/create/templates` after `/create/review`). Persisted so Back
* from template review targets `/create/review` and so returning from
* `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL.
*/
@@ -20,6 +20,8 @@ export const CREATE_ROUTES = {
/** Direct path to the first wizard step so client navigations skip the redirect hop. */
createFirstStep: `/create/${FIRST_STEP}`,
review: "/create/review",
/** In-flow template catalog (wizard chrome). Marketing catalog remains `/templates`. */
templatesPicker: "/create/templates",
finalReview: "/create/final-review",
completed: "/create/completed",
editRule: "/create/edit-rule",
+18 -2
View File
@@ -235,18 +235,34 @@ export function parseCreateFlowScreenFromPathname(
): CreateFlowStep | null {
if (!pathname || pathname.length === 0) return null;
if (pathname.includes("/create/review-template/")) return null;
if (isCreateFlowTemplatesPickerPath(pathname)) return null;
const parts = pathname.split("/").filter(Boolean);
const createIdx = parts.indexOf("create");
if (createIdx === -1 || createIdx >= parts.length - 1) return null;
const segment = parts[createIdx + 1];
if (segment === "review-template") return null;
if (segment === "review-template" || segment === "templates") return null;
return isValidStep(segment) ? segment : null;
}
/** Same query as `/templates?fromFlow=1` — template was picked after `/create/review`. */
/**
* `/create/templates` — in-flow catalog after review “Create from template”.
* Not a wizard step; layout keeps Back / Save & Exit and does not use
* marketing `/templates` chrome.
*/
export function isCreateFlowTemplatesPickerPath(
pathname: string | null | undefined,
): boolean {
if (!pathname) return false;
const parts = pathname.split("/").filter(Boolean);
const createIdx = parts.indexOf("create");
if (createIdx === -1 || createIdx !== parts.length - 2) return false;
return parts[createIdx + 1] === "templates";
}
/** Same query as `/create/review-template/…?fromFlow=1` — template was picked in-flow. */
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
@@ -1,7 +1,7 @@
"use client";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import HeaderLockup from "../../components/type/HeaderLockup";
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
@@ -68,8 +68,8 @@ export default function TemplatesPageClient({
/**
* - `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”
* (draft preserved). Used by profile “Create from template”.
* - `recommendTemplates=1` — rank templates + “RECOMMENDED”
* from `GET /api/templates?facet.*` using the persisted community draft.
*/
function TemplatesGridWithSearchParams({
@@ -96,19 +96,18 @@ function TemplatesGrid({
entries: TemplateGridCardEntry[];
fromFlow: boolean;
}) {
const router = useRouter();
return (
<GovernanceTemplateGrid
entries={entries}
onTemplateClick={(slug) => {
hrefForTemplate={(slug) =>
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow })
}
onTemplateClick={() => {
if (!fromFlow) {
// Marketing /templates has no session in hand; DELETE is
// best-effort and the sentinel blocks stale-draft hydration.
prepareFreshCreateFlowEntrySync({ signedIn: true });
}
router.push(
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
);
}}
/>
);
@@ -18,9 +18,10 @@ type UseTemplatesFacetGridEntriesArgs = {
};
/**
* 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.
* When `enableFacetRecommendations` (in-flow `/create/templates`, or marketing
* `/templates?recommendTemplates=1`), re-fetch ranked templates from
* `GET /api/templates?facet.*` using the persisted create-flow draft.
* Otherwise returns `initialGridEntries` from SSR.
*/
export function useTemplatesFacetGridEntries({
initialGridEntries,
+3 -1
View File
@@ -35,6 +35,7 @@ const RuleContainer = memo<RuleProps>(
backgroundColor = "bg-[var(--color-community-teal-100)]",
className = "",
onClick,
href,
expanded = false,
size: sizeProp,
categories,
@@ -95,8 +96,9 @@ const RuleContainer = memo<RuleProps>(
icon={icon}
backgroundColor={backgroundColor}
className={className}
href={hasBottomLinks ? undefined : href}
onClick={hasBottomLinks ? undefined : handleClick}
onKeyDown={hasBottomLinks ? undefined : handleKeyDown}
onKeyDown={hasBottomLinks || href ? undefined : handleKeyDown}
expanded={expanded}
size={size}
categories={categories}
+6
View File
@@ -50,6 +50,11 @@ export interface RuleProps {
backgroundColor?: string;
className?: string;
onClick?: () => void;
/**
* When set, the card is a real link (focusable, Enter, open in a new tab)
* instead of a click-only `role="button"` surface.
*/
href?: string;
expanded?: boolean;
size?: RuleSizeValue;
categories?: Category[];
@@ -93,6 +98,7 @@ export interface RuleViewProps {
backgroundColor: string;
className: string;
onClick?: () => void;
href?: string;
onKeyDown?: (_event: React.KeyboardEvent<HTMLDivElement>) => void;
expanded: boolean;
size: RuleSizeValue;
+34 -10
View File
@@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
import NextLink from "next/link";
import MultiSelect from "../../controls/MultiSelect";
import InlineTextButton from "../../buttons/InlineTextButton";
import NavigationLink from "../../navigation/Link";
@@ -19,6 +20,7 @@ export function RuleView({
backgroundColor,
className,
onClick,
href,
onKeyDown,
expanded,
size,
@@ -264,16 +266,11 @@ export function RuleView({
);
}
return (
<div
className={`${backgroundColor} ${cardPadding} ${cardGap} ${borderRadiusClass} shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] ${interactiveCard ? "hover:shadow-[0px_0px_64px_0px_rgba(0,0,0,0.15)] transition-shadow duration-200" : ""} flex flex-col items-start justify-center relative ${cardWidth || "w-full"} ${className || ""}`}
tabIndex={interactiveCard ? 0 : undefined}
role={interactiveCard ? "button" : "article"}
aria-label={ariaLabel}
aria-expanded={interactiveCard ? expanded : undefined}
onClick={interactiveCard ? onClick : undefined}
onKeyDown={interactiveCard ? onKeyDown : undefined}
>
const cardClassName = `${backgroundColor} ${cardPadding} ${cardGap} ${borderRadiusClass} shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] ${interactiveCard ? "hover:shadow-[0px_0px_64px_0px_rgba(0,0,0,0.15)] transition-shadow duration-200" : ""} flex flex-col items-start justify-center relative ${cardWidth || "w-full"} ${href ? "no-underline text-inherit" : ""} ${className || ""}`;
const isLinkCard = Boolean(href) && interactiveCard;
const cardBody = (
<>
{/* Figma: Header = `border-b` row, `gap-px`, icon `pl-1 pr-2 py-2` + `border-l` on title. */}
<div
className="
@@ -483,6 +480,33 @@ export function RuleView({
</div>
)
)}
</>
);
if (isLinkCard && href) {
return (
<NextLink
href={href}
className={cardClassName}
aria-label={ariaLabel}
onClick={onClick}
>
{cardBody}
</NextLink>
);
}
return (
<div
className={cardClassName}
tabIndex={interactiveCard ? 0 : undefined}
role={interactiveCard ? "button" : "article"}
aria-label={ariaLabel}
aria-expanded={interactiveCard ? expanded : undefined}
onClick={interactiveCard ? onClick : undefined}
onKeyDown={interactiveCard ? onKeyDown : undefined}
>
{cardBody}
</div>
);
}
@@ -9,7 +9,13 @@ import type { GovernanceTemplateCatalogEntry } from "../../../../lib/templates/g
export interface GovernanceTemplateGridProps {
entries: GovernanceTemplateCatalogEntry[];
onTemplateClick: (_slug: string) => void;
/**
* Real navigation target per card. Cards render as anchors so they are
* keyboard-focusable and work in a new tab.
*/
hrefForTemplate: (_slug: string) => string;
/** Optional side effects on activate (analytics, draft reset). */
onTemplateClick?: (_slug: string) => void;
/**
* When true, use project **`md`** (640px) for a 2-column grid (e.g. `/use-cases`).
* Default keeps the template shell break at **768px**.
@@ -19,6 +25,7 @@ export interface GovernanceTemplateGridProps {
export function GovernanceTemplateGrid({
entries,
hrefForTemplate,
onTemplateClick,
twoColumnsFromMd = false,
}: GovernanceTemplateGridProps) {
@@ -105,9 +112,14 @@ export function GovernanceTemplateGrid({
/>
}
backgroundColor={card.backgroundColor}
onClick={() => {
onTemplateClick(card.slug);
}}
href={hrefForTemplate(card.slug)}
onClick={
onTemplateClick
? () => {
onTemplateClick(card.slug);
}
: undefined
}
/>
))}
</div>
@@ -5,10 +5,10 @@
*/
import { memo, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslation } from "../../../contexts/MessagesContext";
import { logger } from "../../../../lib/logger";
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
import { buildTemplateReviewHref } from "../../../(app)/create/utils/flowSteps";
import {
fetchTemplates,
isTemplatesFetchAborted,
@@ -34,7 +34,6 @@ declare global {
const RuleStackContainer = memo<RuleStackProps>(
({ className = "", initialGridEntries, translationNamespace, twoColumnsFromMd }) => {
const router = useRouter();
const namespace = translationNamespace ?? "pages.home.ruleStack";
const t = useTranslation(namespace);
const [gridEntries, setGridEntries] = useState<TemplateGridCardEntry[] | null>(
@@ -101,12 +100,12 @@ const RuleStackContainer = memo<RuleStackProps>(
// `signedIn: true` because this surface has no session in hand; DELETE is
// best-effort and the sentinel blocks stale-draft hydration.
prepareFreshCreateFlowEntrySync({ signedIn: true });
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
};
return (
<RuleStackView
className={className}
hrefForTemplate={(slug) => buildTemplateReviewHref(slug)}
onTemplateClick={handleTemplateClick}
gridEntries={gridEntries}
sectionTitle={t("title")}
@@ -21,6 +21,7 @@ export interface RuleStackProps {
export interface RuleStackViewProps {
className: string;
onTemplateClick: (_slug: string) => void;
hrefForTemplate: (_slug: string) => string;
/** `null` while loading curated templates from the API. */
gridEntries: TemplateGridCardEntry[] | null;
sectionTitle: string;
@@ -10,6 +10,7 @@ import type { RuleStackViewProps } from "./RuleStack.types";
export function RuleStackView({
className,
onTemplateClick,
hrefForTemplate,
gridEntries,
sectionTitle,
sectionSubtitle,
@@ -46,6 +47,7 @@ export function RuleStackView({
) : (
<GovernanceTemplateGrid
entries={gridEntries}
hrefForTemplate={hrefForTemplate}
onTemplateClick={onTemplateClick}
twoColumnsFromMd={twoColumnsFromMd}
/>