Compare commits

..
27 Commits
Author SHA1 Message Date
adilalloandCursor 77b0ec8ad8 Return focus to the control that opened a dialog instead of leaving it on the document body.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 17:17:09 -06:00
an.di 98945ceb7e Merge pull request 'Restore the community photo after reload and reject empty, oversized, SVG, and spoofed uploads.' (#80) from adilallo/fix/CR-195-photo-upload into main
Reviewed-on: #80
2026-09-10 21:57:52 +00:00
adilalloandCursor 234f3998ad Restore the community photo after reload and reject empty, oversized, SVG, and spoofed uploads.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 15:50:32 -06:00
an.di 6ccc1e8c8e Merge pull request 'Keep the template picker in the create flow, make catalog cards real links, and stop showing a fake community when review has no draft.' (#79) from adilallo/fix/CR-183-builder-routing into main
Reviewed-on: #79
2026-09-10 21:30:10 +00:00
adilalloandCursor 90db213413 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>
2026-09-10 15:22:46 -06:00
an.di 3f9a8395be Merge pull request 'Unify create-flow gutters, viewport height, and action-bar width so tablet and wide layouts stay aligned with step content.' (#78) from adilallo/fix/CR-182-builder-layout into main
Reviewed-on: #78
2026-09-10 20:03:44 +00:00
adilalloandCursor 0e272f0c1e Unify create-flow gutters, viewport height, and action-bar width so tablet and wide layouts stay aligned with step content.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 12:02:06 -06:00
an.di 4055278628 Merge pull request 'Give builder fields persistent labels, treat description as optional, and validate save-progress email with a real form.' (#77) from adilallo/fix/CR-185-builder-inputs into main
Reviewed-on: #77
2026-09-10 17:40:55 +00:00
adilalloandCursor 42d83c8b62 Give builder fields persistent labels, treat description as optional, and validate save-progress email with a real form.
Name stays required; description is a labelled optional textarea. Email uses native validity and form submit. Drop the dead workshop link and fix a few copy errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 11:37:41 -06:00
an.di 130663f930 Merge pull request 'Expose builder chip selection beyond color, raise unselected contrast, and make the five-value limit visible' (#76) from adilallo/fix/CR-186-builder-chips into main
Reviewed-on: #76
2026-09-10 17:15:35 +00:00
adilalloandCursor 6c1d83e89d fix: paint chips inline so native button styles cannot override Figma colors
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 11:01:00 -06:00
adilalloandCursor 64a2ef5a00 Expose builder chip selection beyond color, raise unselected contrast, and make the five-value limit visible.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 09:04:06 -06:00
an.di 95e1937419 Merge pull request 'Fix case-study share chrome, hero lockup width, and copy confirmation in the share dialog.' (#75) from adilallo/fix/CR-212-case-studies-share into main
Reviewed-on: #75
2026-09-10 14:48:49 +00:00
adilalloandCursor ce581a42c8 Fix case-study share chrome, hero lockup width, and copy confirmation in the share dialog.
Marketing pages omitted create-flow nav copy, so Share rendered as a translation key; the case-study banner squeezed hero text to the thumbnail width; Copy link had no hover or copied state.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 22:00:19 -06:00
an.di ddf7b0ef7a Merge pull request 'Match the marketing homepage, About page, and folder header to the latest Figma' (#74) from adilallo/fix/CR-173-home-about-feature-grid into main
Reviewed-on: #74
2026-09-10 03:41:43 +00:00
adilalloandCursor 4bd9fa6dc6 Restore the folder header to Figma layout without cloning the nav, and page-center inner-page links with the home cluster.
Park the skip link in the Tailwind sheet so it cannot sit in document flow and push the yellow tab down.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 21:39:39 -06:00
adilalloandCursor b42716c262 Treat the feature-grid toolkit control as underlined text and keep its card glyphs with the other URL-served icons.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 18:37:51 -06:00
adilalloandCursor 40225e2845 Match the marketing homepage and About page to the latest Figma: black hero CTA, spaces in word-split headings, redesigned feature grid, and cited About statistics.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 17:40:11 -06:00
an.di 2d351987cf Merge pull request 'Give the shell one header, a skip link, and a cookie-aware first paint.' (#73) from adilallo/fix/CR-174-header-nav-session-chrome into main
Reviewed-on: #73
2026-09-09 23:23:48 +00:00
adilalloandCursor 5242f8c6bb Give the shell one header, a skip link, and a cookie-aware first paint.
Breakpoint clones were still in the tab order, marketing always painted Log in, and at 430px the logo overlay covered Use cases and Learn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 17:12:59 -06:00
an.di aecb890cc8 Merge pull request 'Give each route its own title and canonical, and add robots.txt plus a sitemap.' (#72) from adilallo/fix/CR-177-site-metadata into main
Reviewed-on: #72
2026-09-09 22:48:12 +00:00
adilalloandCursor b2b272d2cc Give each route its own title and canonical, and add robots.txt plus a sitemap.
The root layout was stamping the homepage title and production root onto every page. Routes now supply a page segment through one em-dash template, canonicals follow the request path, and non-production hosts disallow crawlers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 16:45:19 -06:00
adilallo e4e61c0c9b Update CloudronManifest.json 2026-09-02 17:21:32 -06:00
adilalloandCursor b6afdb6e32 Let Use without changes from a template collect identity instead of the full questionnaire.
Direct catalog picks walk name, description, and photo, then stakeholders; leftover drafts no longer skip that path. In-flow template picks keep community identity. Exit on a direct template preview leaves immediately because nothing has been collected yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 17:00:17 -06:00
adilalloandCursor f91ac7a893 Call the last create-flow action Publish so it reads as putting a rule on the web, not locking it forever.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 16:27:57 -06:00
adilalloandCursor db34a1f0df Let guests keep a published rule via Save & Exit and a post-finalize sign-in prompt.
Email is optional so they can continue without saving; skip from Save & Exit leaves the flow instead of returning to completed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 16:18:41 -06:00
adilalloandCursor 84ef943df4 Show a footer Remove on selected create-flow modules so removal is not kebab-only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 14:45:56 -06:00
248 changed files with 5736 additions and 1612 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ Reach for these before writing new markup:
| `[ value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` | | `[ value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` | | Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
| Help-icon + label above a control | `app/components/type/InputLabel` (`helpIcon` prop) | | Help-icon + label above a control | `app/components/type/InputLabel` (`helpIcon` prop) |
| Toggle chip (dim-but-clickable) | `Chip` with `state="Disabled" disabled={false}` | | Toggle chip | `Chip` with `state="selected"` / `"unselected"` (Chip sets `aria-pressed`; selected includes a check mark) |
| Card-click → structured creation modal | `Create` with `backdropVariant="blurredYellow"` | | Card-click → structured creation modal | `Create` with `backdropVariant="blurredYellow"` |
If a screen grows a 2nd inline copy of any pattern above, **extract a shared If a screen grows a 2nd inline copy of any pattern above, **extract a shared
+12 -10
View File
@@ -14,10 +14,10 @@ the file tree without affecting URLs.
| Group | URL surface | Audience | Chrome | | Group | URL surface | Audience | Chrome |
|---|---|---|---| |---|---|---|---|
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | `Top` (via root) + marketing `<Footer />` | | `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | `SkipToContent` + `ConditionalNavigation` + marketing `<Footer />` |
| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | `Top` (via root) — no footer except **`/profile`** (see `profile/layout.tsx`) | | `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | `SkipToContent` + `ConditionalNavigation` — no footer except **`/profile`** (see `profile/layout.tsx`) |
| `app/(admin)/` | `/monitor`, future ops dashboards | Operators | `Top` (via root) — no footer | | `app/(admin)/` | `/monitor`, future ops dashboards | Operators | `SkipToContent` + `ConditionalNavigation` — no footer |
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | `Top` (via root) — no footer | | `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | `Top` omitted — no footer |
| `app/(marketing-case-study)/` | `/use-cases/[slug]/rule` | Public case-study demos | Chromeless (no global `Top`; see `navigationChromelessPath.ts`) | | `app/(marketing-case-study)/` | `/use-cases/[slug]/rule` | Public case-study demos | Chromeless (no global `Top`; see `navigationChromelessPath.ts`) |
| `app/api/` | API routes | n/a | n/a | | `app/api/` | API routes | n/a | n/a |
@@ -28,14 +28,16 @@ the folder next to `(marketing)/`.
## Layout responsibilities ## Layout responsibilities
- **`app/layout.tsx`** — `<html>`, `<body>`, providers (`MessagesProvider`, - **`app/layout.tsx`** — `<html>`, `<body>`, providers (`MessagesProvider`,
`AuthModalProvider`), fonts, and `ConditionalNavigation`. Renders `AuthModalProvider` are per group), fonts. Renders
`{children}` directly inside the flex column. **Does not** render `{children}` directly inside the flex column. **Does not** render
`<main>` — each group layout owns that. `<main>` or the header — each group layout owns that.
- **`app/(marketing)/layout.tsx`** — wraps with `<main className="flex-1">` - **`app/(marketing)/layout.tsx`** — skip-to-content, `ConditionalNavigation`
and appends the public `<Footer />`. (SSR session), `<main id="main-content">`, public `<Footer />`.
- **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** / **`(dev)/layout.tsx`** - **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** —
wrap with `<main className="flex-1">`. No footer by default; **`app/(app)/profile/layout.tsx`** skip-to-content, `ConditionalNavigation`, `<main id="main-content">`.
No footer by default; **`app/(app)/profile/layout.tsx`**
appends the marketing `<Footer />` for `/profile` only. appends the marketing `<Footer />` for `/profile` only.
- **`app/(dev)/layout.tsx`** — `<main id="main-content">` only.
- **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific - **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific
chrome inside the group's `<main>` — never render `<html>`, `<body>`, chrome inside the group's `<main>` — never render `<html>`, `<body>`,
`<main>`, or providers. `<main>`, or providers.
+5
View File
@@ -29,6 +29,11 @@ NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
# Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.). # Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.).
# NEXT_PUBLIC_RUM_DASHBOARD_URL= # NEXT_PUBLIC_RUM_DASHBOARD_URL=
# Search indexing. Unset: only communityrule.com / communityrule.info are crawlable
# (staging and local are disallowed in robots.txt). Set true/false to override.
# SITE_INDEXING=true
# SITE_INDEXING=false
# Writable directory for `POST /api/uploads` (community photo + custom-method attachments). # Writable directory for `POST /api/uploads` (community photo + custom-method attachments).
# In production (e.g. Cloudron localstorage mount), set to the mounted path. Local dev example: # In production (e.g. Cloudron localstorage mount), set to the mounted path. Local dev example:
# UPLOAD_ROOT="/absolute/path/to/community-rule/var/uploads" # UPLOAD_ROOT="/absolute/path/to/community-rule/var/uploads"
+1 -1
View File
@@ -4,7 +4,7 @@
"title": "Community Rule", "title": "Community Rule",
"author": "MEDLab", "author": "MEDLab",
"description": "Community governance and rule-building app", "description": "Community governance and rule-building app",
"version": "0.1.13", "version": "0.1.14",
"httpPort": 3000, "httpPort": 3000,
"healthCheckPath": "/api/health", "healthCheckPath": "/api/health",
"memoryLimit": 805306368, "memoryLimit": 805306368,
+16 -1
View File
@@ -1,8 +1,16 @@
import type { Metadata } from "next";
import { Suspense, type ReactNode } from "react"; import { Suspense, type ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation"; import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
import SkipToContent from "../components/navigation/SkipToContent";
import { MessagesProvider } from "../contexts/MessagesContext"; import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext"; import { AuthModalProvider } from "../contexts/AuthModalContext";
import messages from "../../messages/en/index"; import messages from "../../messages/en/index";
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
export const metadata: Metadata = {
robots: NO_INDEX_ROBOTS,
};
// `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16). // `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16).
// See `(app)/layout.tsx` for the matching `<Suspense fallback={null}>` rationale // See `(app)/layout.tsx` for the matching `<Suspense fallback={null}>` rationale
@@ -14,10 +22,17 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
return ( return (
<MessagesProvider messages={messages}> <MessagesProvider messages={messages}>
<AuthModalProvider> <AuthModalProvider>
<SkipToContent />
<Suspense fallback={null}> <Suspense fallback={null}>
<ConditionalNavigation /> <ConditionalNavigation />
</Suspense> </Suspense>
<main className="flex-1">{children}</main> <main
id={MAIN_CONTENT_ID}
tabIndex={-1}
className="flex-1 outline-none"
>
{children}
</main>
</AuthModalProvider> </AuthModalProvider>
</MessagesProvider> </MessagesProvider>
); );
+7
View File
@@ -1,5 +1,12 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { routeMetadata } from "../../../lib/siteMetadata";
import MonitorPageContent from "./MonitorPageContent"; import MonitorPageContent from "./MonitorPageContent";
export const metadata: Metadata = routeMetadata("/monitor", {
title: messages.metadata.monitor.title,
});
export default function MonitorPage() { export default function MonitorPage() {
return <MonitorPageContent />; return <MonitorPageContent />;
} }
+145 -55
View File
@@ -23,15 +23,16 @@ import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE, CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
getNextStep, getNextStep,
getStepIndex,
parseReviewReturnSearchParam, parseReviewReturnSearchParam,
shouldOfferCreateFlowSaveAndExit,
isDirectTemplateReviewEntry,
createFlowStepUsesSelectSplitScroll, createFlowStepUsesSelectSplitScroll,
TEMPLATES_FACET_RECOMMEND_QUERY, isCreateFlowTemplatesPickerPath,
TEMPLATES_FACET_RECOMMEND_VALUE,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY, TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE, TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
} from "./utils/flowSteps"; } from "./utils/flowSteps";
import { import {
CREATE_FLOW_COMMUNITY_SAVE_FORM_ID,
CREATE_FLOW_SYNC_DRAFT_QUERY, CREATE_FLOW_SYNC_DRAFT_QUERY,
CREATE_FLOW_SYNC_DRAFT_VALUE, CREATE_FLOW_SYNC_DRAFT_VALUE,
CREATE_ROUTES, CREATE_ROUTES,
@@ -64,13 +65,23 @@ import {
} from "../../../lib/create/publishedDocumentToCreateFlowState"; } from "../../../lib/create/publishedDocumentToCreateFlowState";
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets"; import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule"; import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import {
clearPendingKeepRuleLogin,
hasPendingKeepRuleLogin,
} from "../../../lib/create/pendingKeepRuleLogin";
import { runCompletedStepExit } from "./utils/runCompletedStepExit"; import { runCompletedStepExit } from "./utils/runCompletedStepExit";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { import {
CREATE_FLOW_FOOTER_BUTTON_CLASS, CREATE_FLOW_FOOTER_BUTTON_CLASS,
CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS, CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS,
} from "./utils/createFlowFooterClassNames"; } from "./utils/createFlowFooterClassNames";
import { CREATE_FLOW_MD_CENTERED_MAIN_CLASS } from "./components/createFlowLayoutTokens"; import {
CREATE_FLOW_MD_CENTERED_MAIN_CLASS,
CREATE_FLOW_PAGE_GUTTER_CLASS,
CREATE_FLOW_SCROLL_REGION_CLASS,
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
getCreateFlowContentMaxClass,
} from "./components/createFlowLayoutTokens";
import { import {
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP, CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
methodCardFacetSectionForConfirmStep, methodCardFacetSectionForConfirmStep,
@@ -91,9 +102,6 @@ import {
useCreateFlowDraftSaveBanner, useCreateFlowDraftSaveBanner,
} from "./context/CreateFlowDraftSaveBannerContext"; } from "./context/CreateFlowDraftSaveBannerContext";
/** First step where Save & Exit is offered (first Create Community select per Figma). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
function CreateFlowSessionShell({ children }: { children: ReactNode }) { function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const [sessionUser, setSessionUser] = useState< const [sessionUser, setSessionUser] = useState<
{ id: string; email: string } | null | undefined { id: string; email: string } | null | undefined
@@ -111,9 +119,9 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const sessionResolved = sessionUser !== undefined; const sessionResolved = sessionUser !== undefined;
// Mirror in-progress draft to localStorage for ALL visitors once we know who // 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; // 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 // Save & Exit: guests open the save-progress login modal; signed-in users
// the server (handled in `useCreateFlowExit`). // PUT the server draft (`useCreateFlowExit`).
const enableLocalDraftMirroring = sessionResolved; const enableLocalDraftMirroring = sessionResolved;
return ( return (
@@ -154,9 +162,9 @@ function CreateFlowLayoutContent({
const { const {
currentStep, currentStep,
nextStep, nextStep,
previousStep,
goToNextStep, goToNextStep,
goToPreviousStep, goToPreviousStep,
canGoBack,
templateReviewFooterBackToCreateReview, templateReviewFooterBackToCreateReview,
} = useCreateFlowNavigation( } = useCreateFlowNavigation(
skipCommunitySave ? { skipCommunitySave: true } : undefined, skipCommunitySave ? { skipCommunitySave: true } : undefined,
@@ -197,6 +205,8 @@ function CreateFlowLayoutContent({
} | null>(null); } | null>(null);
const [shareModalOpen, setShareModalOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false);
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false); const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
const [completedHasPublishedRule, setCompletedHasPublishedRule] =
useState(false);
const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>( const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>(
null, null,
); );
@@ -267,17 +277,20 @@ function CreateFlowLayoutContent({
title: completedCopy.guestInvitesSkippedTitle, title: completedCopy.guestInvitesSkippedTitle,
description: completedCopy.guestInvitesSkippedDescription, description: completedCopy.guestInvitesSkippedDescription,
}); });
return; } else {
setCompletedFlowBanner({
key: "guestClaimHint",
status: "warning",
title: completedCopy.guestClaimHintTitle,
description: completedCopy.guestClaimHintDescription,
});
} }
setCompletedFlowBanner({
key: "guestClaimHint",
status: "warning",
title: completedCopy.guestClaimHintTitle,
description: completedCopy.guestClaimHintDescription,
});
}, },
}); });
const fromCreateWizard =
searchParams.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) ===
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE;
const { const {
isTemplateReviewRoute, isTemplateReviewRoute,
templateReviewSlug, templateReviewSlug,
@@ -292,7 +305,11 @@ function CreateFlowLayoutContent({
updateState, updateState,
replaceState, replaceState,
router, router,
fromCreateWizard,
markCreateFlowInteraction,
}); });
const isCreateTemplatesPickerRoute =
isCreateFlowTemplatesPickerPath(pathname);
const runAuthenticatedExit = useCreateFlowExit({ const runAuthenticatedExit = useCreateFlowExit({
state, state,
@@ -305,14 +322,34 @@ function CreateFlowLayoutContent({
}); });
const handleExit = async (opts?: { saveDraft?: boolean }) => { const handleExit = async (opts?: { saveDraft?: boolean }) => {
const saveDraft = opts?.saveDraft ?? false; // Direct template preview: browsing a catalog card, nothing collected yet.
// In-flow (`?fromFlow=1`) still has community progress — keep save / confirm.
if (isDirectTemplateReviewEntry(pathname, searchParams)) {
clearState();
router.push(CREATE_ROUTES.root);
return;
}
if (!sessionResolved) return; if (!sessionResolved) return;
// Exit from `/create/completed` is post-publish: the rule is saved, so we // Completed is post-publish. Guests still need Save & Exit → keep-this-rule
// skip the leave-confirm + login prompt and just wipe the in-flight draft. // login (the document is live but unclaimed). Signed-in users just leave.
// 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") { if (currentStep === "completed") {
if (sessionUser === null) {
openLogin({
variant: "keepRule",
nextPath: CREATE_ROUTES.completed,
backdropVariant: "blurredYellow",
onDismiss: () => {
runCompletedStepExit({
clearState,
clearAnonymousCreateFlowStorage,
router,
});
},
});
return;
}
runCompletedStepExit({ runCompletedStepExit({
clearState, clearState,
clearAnonymousCreateFlowStorage, clearAnonymousCreateFlowStorage,
@@ -322,7 +359,6 @@ function CreateFlowLayoutContent({
} }
if (sessionUser === null) { if (sessionUser === null) {
if (saveDraft) return;
const returnToTemplateReview = const returnToTemplateReview =
templateReviewSlug != null templateReviewSlug != null
? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1` ? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1`
@@ -428,15 +464,54 @@ function CreateFlowLayoutContent({
useEffect(() => { useEffect(() => {
if (currentStep !== "completed") { if (currentStep !== "completed") {
setCompletedFlowBanner(null); setCompletedFlowBanner(null);
setCompletedHasPublishedRule(false);
return;
} }
setCompletedHasPublishedRule(Boolean(readLastPublishedRule()));
}, [currentStep]); }, [currentStep]);
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => { useEffect(() => {
if (currentStep !== "completed") return;
if (!sessionResolved) return;
if (sessionUser !== null) {
clearPendingKeepRuleLogin();
return;
}
if (!hasPendingKeepRuleLogin()) return;
const timeoutId = window.setTimeout(() => {
if (!hasPendingKeepRuleLogin()) return;
clearPendingKeepRuleLogin();
openLogin({
variant: "keepRule",
nextPath: CREATE_ROUTES.completed,
backdropVariant: "blurredYellow",
});
}, 0);
return () => window.clearTimeout(timeoutId);
}, [currentStep, sessionResolved, sessionUser, openLogin]);
const handleCommunitySaveMagicLinkSubmit = useCallback(async (
formEl?: HTMLFormElement | null,
) => {
setCommunitySaveMagicLinkError(null); setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false); setCommunitySaveMagicLinkSuccess(false);
const raw = state.communitySaveEmail; const namedEmail = formEl?.elements.namedItem("email");
const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : ""; const emailField =
if (!isValidCreateFlowSaveEmail(trimmed)) return; namedEmail instanceof HTMLInputElement
? namedEmail
: typeof document === "undefined"
? null
: document.querySelector<HTMLInputElement>(
`input[name="email"][form="${CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}"]`,
);
if (emailField != null && !emailField.checkValidity()) {
emailField.reportValidity();
return;
}
const raw =
emailField != null ? emailField.value : state.communitySaveEmail;
if (typeof raw !== "string" || !isValidCreateFlowSaveEmail(raw)) return;
const trimmed = raw.trim().toLowerCase();
setCommunitySaveMagicLinkSubmitting(true); setCommunitySaveMagicLinkSubmitting(true);
try { try {
@@ -486,16 +561,17 @@ function CreateFlowLayoutContent({
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll( const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
currentStep, currentStep,
); );
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
/** Lockup+card / card-stack: `items-start` + shell `my-auto` so overflow scrolls from the top. */ /** Lockup+card / card-stack: `items-start` + shell `my-auto` so overflow scrolls from the top. */
const mainContentClass = isCompletedStep const mainContentClass = isCompletedStep
? "items-stretch overflow-y-auto md:overflow-hidden" ? "items-stretch overflow-y-auto md:overflow-hidden"
: isSelectSplitScrollStep : isSelectSplitScrollStep
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden" ? "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 : isCreateTemplatesPickerRoute
? CREATE_FLOW_MD_CENTERED_MAIN_CLASS ? "items-start justify-start overflow-y-auto"
: "items-start justify-center overflow-y-auto md:items-center"; : isTemplateReviewRoute || isFinalReviewLike || isCardLayoutStep
? CREATE_FLOW_MD_CENTERED_MAIN_CLASS
: "items-start justify-center overflow-y-auto md:items-center";
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep); const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
const mainMaxMdJustify = const mainMaxMdJustify =
@@ -507,8 +583,8 @@ function CreateFlowLayoutContent({
: "max-md:flex-col max-md:items-center"; : "max-md:flex-col max-md:items-center";
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`; const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
const saveDraftOnExit = const saveDraftOnExit =
Boolean(sessionUser) && shouldOfferCreateFlowSaveAndExit(currentStep, sessionUser) ||
(stepIdx >= SAVE_EXIT_FROM_STEP_INDEX || currentStep === "edit-rule"); isCreateTemplatesPickerRoute;
const proportionBarProgress = getProportionBarProgressForCreateFlowStep( const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
currentStep, currentStep,
@@ -556,7 +632,7 @@ function CreateFlowLayoutContent({
key: "publish", key: "publish",
status: "danger" as const, status: "danger" as const,
title: title:
messages.create.reviewAndComplete.publish.finalizeBannerTitle, messages.create.reviewAndComplete.publish.publishBannerTitle,
description: publishBannerMessage, description: publishBannerMessage,
onClose: () => setPublishBannerMessage(null), onClose: () => setPublishBannerMessage(null),
} }
@@ -600,10 +676,10 @@ function CreateFlowLayoutContent({
].filter((b): b is NonNullable<typeof b> => b !== null); ].filter((b): b is NonNullable<typeof b> => b !== null);
return ( return (
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black"> <div className={`${CREATE_FLOW_VIEWPORT_FRAME_CLASS} bg-black`}>
{topBanners.length > 0 ? ( {topBanners.length > 0 ? (
<div <div
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]" className={`pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-[var(--spacing-measures-spacing-300,12px)]`}
aria-live="polite" aria-live="polite"
> >
{topBanners.map((b) => ( {topBanners.map((b) => (
@@ -674,7 +750,10 @@ function CreateFlowLayoutContent({
<CreateFlowTopNav <CreateFlowTopNav
hasShare={isCompletedStep} hasShare={isCompletedStep}
hasExport={isCompletedStep} hasExport={isCompletedStep}
hasEdit={isCompletedStep && Boolean(sessionUser)} hasEdit={
isCompletedStep &&
(Boolean(sessionUser) || completedHasPublishedRule)
}
hasManageStakeholders={isEditRuleStep} hasManageStakeholders={isEditRuleStep}
saveDraftOnExit={saveDraftOnExit} saveDraftOnExit={saveDraftOnExit}
onShare={ onShare={
@@ -716,16 +795,32 @@ function CreateFlowLayoutContent({
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : "" isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
}`.trim()} }`.trim()}
/> />
{currentStep === "community-save" ? (
<form
id={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
className="hidden"
onSubmit={(e) => {
e.preventDefault();
void handleCommunitySaveMagicLinkSubmit(e.currentTarget);
}}
/>
) : null}
<main <main
className={`flex min-h-0 min-w-0 flex-1 w-full max-w-full overflow-x-hidden ${mainContentClass} ${mainResponsiveLayout}`} className={`flex min-h-0 min-w-0 flex-1 w-full max-w-full overflow-x-hidden ${CREATE_FLOW_SCROLL_REGION_CLASS} ${mainContentClass} ${mainResponsiveLayout}`}
> >
{children} {children}
</main> </main>
{!isCompletedStep && ( {!isCompletedStep && (
<CreateFlowFooter <CreateFlowFooter
className="shrink-0" className="shrink-0"
contentMaxClass={getCreateFlowContentMaxClass({
step: currentStep,
isTemplateReview: isTemplateReviewRoute,
isTemplatesPicker: isCreateTemplatesPickerRoute,
})}
progressBar={ progressBar={
!isTemplateReviewRoute && !isTemplateReviewRoute &&
!isCreateTemplatesPickerRoute &&
!isFinalReviewLike && !isFinalReviewLike &&
reviewReturnTarget !== "edit-rule" reviewReturnTarget !== "edit-rule"
} }
@@ -787,6 +882,8 @@ function CreateFlowLayoutContent({
{footer.saveLater} {footer.saveLater}
</Button> </Button>
<Button <Button
type="submit"
form={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
buttonType="filled" buttonType="filled"
palette="default" palette="default"
size="xsmall" size="xsmall"
@@ -797,9 +894,6 @@ function CreateFlowLayoutContent({
!isValidCreateFlowSaveEmail(state.communitySaveEmail) !isValidCreateFlowSaveEmail(state.communitySaveEmail)
} }
className={CREATE_FLOW_FOOTER_BUTTON_CLASS} className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
void handleCommunitySaveMagicLinkSubmit();
}}
> >
{communitySaveMagicLinkSubmitting {communitySaveMagicLinkSubmitting
? footer.submitEmailSending ? footer.submitEmailSending
@@ -830,15 +924,7 @@ function CreateFlowLayoutContent({
disabled={isPublishing} disabled={isPublishing}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS} className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => { onClick={() => {
// `fromFlow=1` tells `/templates` to skip the fresh-slate router.push(CREATE_ROUTES.templatesPicker);
// 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}`,
);
}} }}
> >
{footer.createFromTemplate} {footer.createFromTemplate}
@@ -916,14 +1002,18 @@ function CreateFlowLayoutContent({
{isFinalReviewLike {isFinalReviewLike
? isPublishing ? isPublishing
? messages.create.reviewAndComplete.publish ? messages.create.reviewAndComplete.publish
.finalizeButtonPublishing .publishButtonPublishing
: footer.finalizeCommunityRule : footer.publishCommunityRule
: getDefaultFooterLabel(currentStep, footer)} : getDefaultFooterLabel(currentStep, footer)}
</Button> </Button>
) : null ) : null
} }
onBackClick={ onBackClick={
isTemplateReviewRoute isCreateTemplatesPickerRoute
? () => {
router.push(CREATE_ROUTES.review);
}
: isTemplateReviewRoute
? () => ? () =>
router.push( router.push(
templateReviewFooterBackToCreateReview templateReviewFooterBackToCreateReview
@@ -939,7 +1029,7 @@ function CreateFlowLayoutContent({
), ),
); );
} }
: previousStep : canGoBack()
? goToPreviousStep ? goToPreviousStep
: undefined : undefined
} }
+18 -1
View File
@@ -11,9 +11,11 @@ import {
import { useCreateFlow } from "./context/CreateFlowContext"; import { useCreateFlow } from "./context/CreateFlowContext";
import { fetchDraftFromServer } from "../../../lib/create/api"; import { fetchDraftFromServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./components/createFlowLayoutTokens";
import Alert from "../../components/modals/Alert"; import Alert from "../../components/modals/Alert";
import { import {
isValidStep, isValidStep,
isCreateFlowTemplatesPickerPath,
parseCreateFlowScreenFromPathname, parseCreateFlowScreenFromPathname,
} from "./utils/flowSteps"; } from "./utils/flowSteps";
import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry"; import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry";
@@ -102,6 +104,21 @@ export function SignedInDraftHydration({
if (urlStep === "completed") { if (urlStep === "completed") {
return; return;
} }
/**
* Direct template preview is a new-rule entry, not “continue draft”.
* Applying a saved `currentStep` here is what yanked Use without changes
* past name / description / photo to stakeholders.
*/
if (pathname?.includes("/create/review-template/")) {
return;
}
if (isCreateFlowTemplatesPickerPath(pathname)) {
return;
}
if (touchedRef.current) {
finishedUserIdRef.current = userId;
return;
}
let cancelled = false; let cancelled = false;
setLoadingHydration(true); setLoadingHydration(true);
@@ -149,7 +166,7 @@ export function SignedInDraftHydration({
if (!loadingHydration) return null; if (!loadingHydration) return null;
return ( return (
<div className="pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center px-[var(--spacing-measures-spacing-500,20px)] pt-2 md:top-16 md:px-[var(--measures-spacing-1800,64px)]"> <div className={`pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-2 md:top-16`}>
<div className="pointer-events-auto w-full max-w-[960px]"> <div className="pointer-events-auto w-full max-w-[960px]">
<Alert <Alert
type="banner" type="banner"
+11
View File
@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView"; import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
import { createFlowStepPath, CREATE_ROUTES } from "../utils/createFlowPaths";
import { isValidStep } from "../utils/flowSteps"; import { isValidStep } from "../utils/flowSteps";
import type { CreateFlowStep } from "../types"; import type { CreateFlowStep } from "../types";
import { routeMetadata } from "../../../../lib/siteMetadata";
/** /**
* Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`). * Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`).
@@ -14,6 +17,14 @@ interface PageProps {
params: Promise<{ screenId: string }>; params: Promise<{ screenId: string }>;
} }
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { screenId: raw } = await params;
const path = isValidStep(raw)
? createFlowStepPath(raw)
: `${CREATE_ROUTES.createRoot}/${raw}`;
return routeMetadata(path);
}
export default async function CreateFlowScreenPage({ params }: PageProps) { export default async function CreateFlowScreenPage({ params }: PageProps) {
const { screenId: raw } = await params; const { screenId: raw } = await params;
@@ -74,11 +74,16 @@ function ApplicableScopeFieldComponent({
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{scopes.map((scope) => { {scopes.map((scope) => {
const isSelected = selectedScopes.includes(scope); const isSelected = selectedScopes.includes(scope);
const chipState = isSelected
? "selected"
: readOnly
? "disabled"
: "unselected";
return ( return (
<Chip <Chip
key={scope} key={scope}
label={scope} label={scope}
state={isSelected ? "selected" : "disabled"} state={chipState}
palette="default" palette="default"
size="s" size="s"
disabled={readOnly} disabled={readOnly}
@@ -0,0 +1,56 @@
"use client";
import type { ReactNode } from "react";
import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "./CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
CREATE_FLOW_WIDE_MAX_CLASS,
} from "./createFlowLayoutTokens";
type CreateFlowCardStackStepShellProps = {
lockupTitle: string;
lockupDescription?: ReactNode;
/** When true, the stack region exposes `aria-busy` while recommendations load. */
stackBusy?: boolean;
children: ReactNode;
};
/**
* Centered lockup + card-stack layout shared by communication, membership, and
* conflict-management steps (Figma compact card stack).
*/
export function CreateFlowCardStackStepShell({
lockupTitle,
lockupDescription,
stackBusy = false,
children,
}: CreateFlowCardStackStepShellProps) {
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
>
<div
className={`flex w-full min-w-0 flex-col items-center gap-6 ${CREATE_FLOW_WIDE_MAX_CLASS}`}
>
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={lockupTitle}
description={lockupDescription}
justification="center"
/>
</div>
<div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={stackBusy}
>
{children}
</div>
</div>
</CreateFlowStepShell>
);
}
@@ -2,7 +2,10 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useCreateFlow } from "../context/CreateFlowContext"; import { useCreateFlow } from "../context/CreateFlowContext";
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer"; import {
CreateFlowUploadValidationError,
uploadCreateFlowFile,
} from "../../../../lib/create/uploadToServer";
import { import {
clearPendingCommunityAvatarFile, clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile, readPendingCommunityAvatarFile,
@@ -19,13 +22,17 @@ export function CreateFlowPendingAvatarFlush({
sessionUser: { id: string; email: string } | null | undefined; sessionUser: { id: string; email: string } | null | undefined;
sessionResolved: boolean; sessionResolved: boolean;
}) { }) {
const { updateState } = useCreateFlow(); const { state, updateState } = useCreateFlow();
/** One successful flush per signed-in user id (survives React StrictMode remounts). */ /** One successful flush per signed-in user id (survives React StrictMode remounts). */
const lastFlushedUserIdRef = useRef<string | null>(null); const lastFlushedUserIdRef = useRef<string | null>(null);
const hasServerAvatar =
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0;
useEffect(() => { useEffect(() => {
if (!sessionResolved || !sessionUser) return; if (!sessionResolved || !sessionUser) return;
if (lastFlushedUserIdRef.current === sessionUser.id) return; if (lastFlushedUserIdRef.current === sessionUser.id) return;
if (hasServerAvatar) return;
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
@@ -37,15 +44,18 @@ export function CreateFlowPendingAvatarFlush({
await clearPendingCommunityAvatarFile(); await clearPendingCommunityAvatarFile();
updateState({ communityAvatarUrl: url }); updateState({ communityAvatarUrl: url });
lastFlushedUserIdRef.current = sessionUser.id; lastFlushedUserIdRef.current = sessionUser.id;
} catch { } catch (err) {
// Leave pending blob in place so the user can retry after fixing auth / UPLOAD_ROOT. if (err instanceof CreateFlowUploadValidationError) {
await clearPendingCommunityAvatarFile();
}
// Leave a transient (auth / UPLOAD_ROOT) failure in place to retry.
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [sessionResolved, sessionUser, updateState]); }, [hasServerAvatar, sessionResolved, sessionUser, updateState]);
return null; return null;
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./createFlowLayoutTokens";
export type CreateFlowStepShellVariant = export type CreateFlowStepShellVariant =
| "centeredNarrow" | "centeredNarrow"
@@ -13,15 +14,12 @@ export type CreateFlowStepShellVariant =
export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800"; export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800";
const outerByVariant: Record<CreateFlowStepShellVariant, string> = { const outerByVariant: Record<CreateFlowStepShellVariant, string> = {
centeredNarrow: centeredNarrow: `flex w-full min-w-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
"flex w-full min-w-0 flex-col items-center px-5 md:px-16", centeredNarrowBottomPad: `flex w-full min-w-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS} pb-28 md:pb-32`,
centeredNarrowBottomPad: /** Wide two-column steps; inner content supplies the 1328px cap. */
"flex w-full min-w-0 flex-col items-center px-5 pb-28 md:px-[var(--measures-spacing-1800,64px)] md:pb-32", wideGrid: `flex w-full min-w-0 shrink-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
/** Wide two-column steps; 1328px = two 640px columns + 48px gutter. */ /** Create Community review + card grid; inner content supplies the 1440px cap. */
wideGrid: "w-full min-w-0 max-w-[1328px] shrink-0 px-5 md:px-12", wideGridLoosePadding: `flex w-full min-w-0 shrink-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
/** Create Community review + card grid (Figma Flow — Review `19706:12135`): max width 1440. */
wideGridLoosePadding:
"w-full min-w-0 max-w-[1440px] shrink-0 px-5 md:px-16",
bare: "w-full min-w-0", bare: "w-full min-w-0",
}; };
@@ -41,7 +39,8 @@ interface CreateFlowStepShellProps {
/** /**
* Shared horizontal padding and width constraints for create-flow step pages. * Shared horizontal padding and width constraints for create-flow step pages.
* Horizontal padding uses Tailwind `md:` so it tracks `--breakpoint-md` (640px in `app/tailwind.css`). * Gutters come from {@link CREATE_FLOW_PAGE_GUTTER_CLASS} (`md` / `lg` track
* `--breakpoint-md` and `--breakpoint-lg` in `app/tailwind.css`).
*/ */
export function CreateFlowStepShell({ export function CreateFlowStepShell({
children, children,
@@ -5,7 +5,11 @@ import {
CreateFlowStepShell, CreateFlowStepShell,
type CreateFlowContentTopBelowMd, type CreateFlowContentTopBelowMd,
} from "./CreateFlowStepShell"; } from "./CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens"; import {
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
CREATE_FLOW_SCROLL_REGION_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "./createFlowLayoutTokens";
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start"; export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
@@ -26,10 +30,10 @@ interface CreateFlowTwoColumnSelectShellProps {
} }
/** /**
* Two-column layout for create-flow select steps (community size/structure, core values) and * Question-and-options layout for create-flow select steps (community size/structure,
* {@link DecisionApproachesScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls. * core values, stakeholders) and {@link DecisionApproachesScreen}.
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column * Below `lg` (1024px), one column uses available width and main scrolls.
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail). * At `lg+`, static header column + scrollable options column (`min-h-0` + overflow).
*/ */
export function CreateFlowTwoColumnSelectShell({ export function CreateFlowTwoColumnSelectShell({
header, header,
@@ -56,24 +60,24 @@ export function CreateFlowTwoColumnSelectShell({
> >
<div <div
className={ className={
"flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] " + "flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] " +
"max-lg:flex-none lg:max-h-full lg:max-w-[1328px] lg:min-h-0 lg:flex-1 lg:flex-row lg:flex-nowrap " + "max-lg:flex-none lg:max-h-full lg:min-h-0 lg:flex-1 lg:flex-row lg:flex-nowrap " +
`${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden` `${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`
} }
> >
<div <div
className={ className={
`flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` + `flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` +
`lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] lg:max-w-[640px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}` `lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
} }
> >
{header} {header}
</div> </div>
<div <div
className={ className={
`scrollbar-hide relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` + `relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` +
`overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-[var(--measures-spacing-300,12px)] ` + `overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-[var(--measures-spacing-300,12px)] ` +
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS `${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
} }
> >
{children} {children}
@@ -3,7 +3,10 @@
import { memo, useCallback, useRef, useState } from "react"; import { memo, useCallback, useRef, useState } from "react";
import { useTranslation } from "../../../../contexts/MessagesContext"; import { useTranslation } from "../../../../contexts/MessagesContext";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import {
createFlowUploadFailureMessageKey,
uploadCreateFlowFile,
} from "../../../../../lib/create/uploadToServer";
import { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view"; import { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view";
import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types"; import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types";
@@ -68,8 +71,8 @@ function CustomMethodCardUploadBlockRowContainerComponent({
: b, : b,
), ),
); );
} catch { } catch (err) {
setErrorMessage(tUpload("errors.generic")); setErrorMessage(tUpload(createFlowUploadFailureMessageKey(err)));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -4,6 +4,7 @@ import { memo } from "react";
import Upload from "../../../../components/controls/Upload"; import Upload from "../../../../components/controls/Upload";
import InputLabel from "../../../../components/type/InputLabel"; import InputLabel from "../../../../components/type/InputLabel";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils"; import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation";
import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types"; import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types";
function CustomMethodCardUploadBlockRowViewComponent({ function CustomMethodCardUploadBlockRowViewComponent({
@@ -43,7 +44,7 @@ function CustomMethodCardUploadBlockRowViewComponent({
type="file" type="file"
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
accept="image/jpeg,image/png,image/webp,image/gif,application/pdf" accept={CUSTOM_ATTACHMENT_ACCEPT}
aria-label={uploadFileInputAriaLabel} aria-label={uploadFileInputAriaLabel}
onChange={onFileInputChange} onChange={onFileInputChange}
/> />
@@ -8,6 +8,7 @@ import {
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm"; import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { createFlowUploadFailureMessageKey } from "../../../../../lib/create/uploadToServer";
import { import {
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS, CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS,
CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS, CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS,
@@ -462,8 +463,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
try { try {
const { url } = await onPersistCustomUploadFile(file); const { url } = await onPersistCustomUploadFile(file);
setUploadAssetUrl(url); setUploadAssetUrl(url);
} catch { } catch (err) {
setUploadFieldError(tUpload("errors.generic")); setUploadFileName(undefined);
setUploadFieldError(tUpload(createFlowUploadFailureMessageKey(err)));
} finally { } finally {
setUploadFieldBusy(false); setUploadFieldBusy(false);
} }
@@ -80,6 +80,8 @@ function CustomMethodCardWizardViewComponent({
<TextArea <TextArea
appearance="default" appearance="default"
formHeader={false} formHeader={false}
showHelpIcon={false}
label={copy.step2.title}
placeholder={copy.step2.fieldPlaceholder} placeholder={copy.step2.fieldPlaceholder}
value={policyDescription} value={policyDescription}
maxLength={maxDescriptionChars} maxLength={maxDescriptionChars}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { memo } from "react"; import { memo, useId } from "react";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils"; import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import InputWithCounter from "../../../../components/controls/InputWithCounter"; import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea"; import TextArea from "../../../../components/controls/TextArea";
@@ -10,6 +10,7 @@ import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import InputLabel from "../../../../components/type/InputLabel"; import InputLabel from "../../../../components/type/InputLabel";
import ApplicableScopeField from "../ApplicableScopeField"; import ApplicableScopeField from "../ApplicableScopeField";
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants"; import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation";
import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types"; import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types";
const TEXT_PLACEHOLDER_MAX = 8000; const TEXT_PLACEHOLDER_MAX = 8000;
@@ -39,6 +40,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
onProportionBlockTitleChange, onProportionBlockTitleChange,
onProportionDefaultChange, onProportionDefaultChange,
}: CustomMethodCardWizardFieldBodiesViewProps) { }: CustomMethodCardWizardFieldBodiesViewProps) {
const placeholderFieldId = useId();
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? ""; const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
const hasUploadPreview = uploadPreviewTrimmed.length > 0; const hasUploadPreview = uploadPreviewTrimmed.length > 0;
@@ -53,10 +55,14 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS} maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
/> />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"> <label
htmlFor={placeholderFieldId}
className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"
>
{copy.text.placeholderLabel} {copy.text.placeholderLabel}
</label> </label>
<TextArea <TextArea
id={placeholderFieldId}
formHeader={false} formHeader={false}
appearance="embedded" appearance="embedded"
value={textPlaceholderBody} value={textPlaceholderBody}
@@ -84,6 +90,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
/> />
<TextInput <TextInput
formHeader={false} formHeader={false}
label={copy.badges.blockTitleLabel}
placeholder={copy.badges.blockTitlePlaceholder} placeholder={copy.badges.blockTitlePlaceholder}
value={badgeBlockTitle} value={badgeBlockTitle}
onChange={(e) => onBadgeBlockTitleChange(e.target.value)} onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
@@ -113,6 +120,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
type="file" type="file"
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
accept={CUSTOM_ATTACHMENT_ACCEPT}
aria-label={copy.upload.uploadFileInputAriaLabel} aria-label={copy.upload.uploadFileInputAriaLabel}
onChange={onFileChosen} onChange={onFileChosen}
/> />
@@ -4,7 +4,8 @@
* Final-review chip modal: **Core values** and **method** facets share the * Final-review chip modal: **Core values** and **method** facets share the
* kebab → **Duplicate** (values only when under the cap) / **Remove** pattern * kebab → **Duplicate** (values only when under the cap) / **Remove** pattern
* from the create-card facet modals (`Create` + * from the create-card facet modals (`Create` +
* {@link buildCustomRuleModalKebabMenu}). Values and method chips also offer * {@link buildCustomRuleModalKebabMenu}), plus a footer **Remove** when the
* chip is already in the selection. Values and method chips also offer
* **Customize**, which opens {@link CustomMethodCardWizard} prefilled from the * **Customize**, which opens {@link CustomMethodCardWizard} prefilled from the
* chip. Fields are editable on open; Save persists body edits without renaming. * chip. Fields are editable on open; Save persists body edits without renaming.
* *
@@ -1073,12 +1074,23 @@ export function FinalReviewChipEditModal({
); );
}, [subtitle, target]); }, [subtitle, target]);
const showFooterRemove =
target != null &&
(target.groupKey === "coreValues" || isChipInSelection);
return ( return (
<> <>
<Create <Create
isOpen={isOpen && !addCustomWizardOpen} isOpen={isOpen && !addCustomWizardOpen}
onClose={handleModalClose} onClose={handleModalClose}
onBack={handleModalClose} onBack={handleModalClose}
showBackButton={!showFooterRemove}
showRemoveButton={showFooterRemove}
onRemove={
target?.groupKey === "coreValues"
? handleRemoveCoreValueFromModal
: handleRemoveSelectedFromModal
}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
headerContent={headerContent} headerContent={headerContent}
showNextButton={true} showNextButton={true}
@@ -8,7 +8,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard"; import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
import Create from "../../../components/modals/Create"; import Create from "../../../components/modals/Create";
import TextInput from "../../../components/controls/TextInput"; import TextArea from "../../../components/controls/TextArea";
import ContentLockup from "../../../components/type/ContentLockup"; import ContentLockup from "../../../components/type/ContentLockup";
import { useTranslation } from "../../../contexts/MessagesContext"; import { useTranslation } from "../../../contexts/MessagesContext";
@@ -95,18 +95,20 @@ export function FinalReviewCommunityContextEditModal({
ariaLabel={tModal("title")} ariaLabel={tModal("title")}
> >
<div className="pb-2"> <div className="pb-2">
<TextInput <TextArea
className="!transition-none" className="!transition-none"
type="text" label={tModal("title")}
placeholder={tField("placeholder")} placeholder={tField("placeholder")}
value={draft} value={draft}
onChange={(e) => { onChange={(e) => {
setDraft(e.target.value); setDraft(e.target.value);
}} }}
inputSize="medium" size="medium"
formHeader={false} formHeader={false}
showHelpIcon={false}
textHint={characterHint} textHint={characterHint}
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH} maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
rows={4}
/> />
</div> </div>
</Create> </Create>
@@ -93,6 +93,7 @@ export function FinalReviewTitleEditModal({
<TextInput <TextInput
className="!transition-none" className="!transition-none"
type="text" type="text"
label={tModal("title")}
placeholder={tField("placeholder")} placeholder={tField("placeholder")}
value={draft} value={draft}
onChange={(e) => { onChange={(e) => {
@@ -100,8 +101,10 @@ export function FinalReviewTitleEditModal({
}} }}
inputSize="medium" inputSize="medium"
formHeader={false} formHeader={false}
showHelpIcon={false}
textHint={characterHint} textHint={characterHint}
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH} maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
required
/> />
</div> </div>
</Create> </Create>
@@ -1,13 +1,47 @@
/** Single column/section: full width under `md`, max 640px from `--breakpoint-md` up. */ import type { CreateFlowStep } from "../types";
import { CREATE_FLOW_SCREEN_REGISTRY } from "../utils/createFlowScreenRegistry";
/**
* Shared page gutters for create-flow chrome and step shells.
* Six viewport bands collapse to three values: 20px below `md`, 48px from `md`,
* 64px from `lg` (320639 / 6401023 / 1024+).
*/
export const CREATE_FLOW_PAGE_GUTTER_CLASS = "px-5 md:px-12 lg:px-16";
/**
* Wizard viewport frame. `dvh` tracks mobile browser chrome so the action bar
* stays on-screen; `max-h-dvh` keeps the flex column from overflowing it.
*/
export const CREATE_FLOW_VIEWPORT_FRAME_CLASS =
"relative flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden";
/** Design-system scrollbar for create-flow scroll regions (not `scrollbar-hide`). */
export const CREATE_FLOW_SCROLL_REGION_CLASS = "scrollbar-design";
/**
* Fade above the fixed action bar so content that continues below the fold is
* visible as a continuation cue (Figma Scrim / footer overlay).
*/
export const CREATE_FLOW_FOOTER_SCRIM_CLASS =
"pointer-events-none absolute inset-x-0 top-0 z-[1] h-8 -translate-y-full bg-gradient-to-t from-[var(--color-surface-default-primary,#000)] to-transparent";
/**
* Single column: full width below `lg` so tablet widths use available space;
* 640px from `--breakpoint-lg` up (two-column layouts start at `lg`).
*/
export const CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS = export const CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS =
"w-full min-w-0 md:max-w-[640px]"; "w-full min-w-0 lg:max-w-[640px]";
/** Grid cell: same cap as column max, centered when the track is wider than 640px. */ /** Grid cell: same cap as column max, centered when the track is wider than 640px. */
export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS = export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS =
"w-full min-w-0 md:mx-auto md:max-w-[640px]"; "w-full min-w-0 lg:mx-auto lg:max-w-[640px]";
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */ /** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]"; export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS =
"w-full min-w-0 lg:max-w-[1328px]";
/** Review / card-stack frames (Figma Flow — Review max 1440). */
export const CREATE_FLOW_WIDE_MAX_CLASS = "w-full min-w-0 lg:max-w-[1440px]";
/** /**
* Lockup+card and card-stack `<main>`: keep `items-start` so a tall card can * Lockup+card and card-stack `<main>`: keep `items-start` so a tall card can
@@ -29,3 +63,31 @@ export const CREATE_FLOW_MD_CENTERED_SHELL_CLASS = "md:my-auto md:pt-0";
*/ */
export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS = export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
"w-full min-w-0 md:max-w-[min(100%,860px)]"; "w-full min-w-0 md:max-w-[min(100%,860px)]";
/**
* Inner max-width for step content and the action bar, so the primary button
* lines up with the nearest content column above 1440.
*/
export function getCreateFlowContentMaxClass(options: {
step: CreateFlowStep | null | undefined;
isTemplateReview?: boolean;
isTemplatesPicker?: boolean;
}): string {
if (options.isTemplateReview || options.isTemplatesPicker) {
return CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS;
}
if (!options.step) {
return CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS;
}
switch (CREATE_FLOW_SCREEN_REGISTRY[options.step].layoutKind) {
case "select":
case "right-rail":
case "completed":
case "review":
return CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS;
case "card":
return CREATE_FLOW_WIDE_MAX_CLASS;
default:
return CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS;
}
}
@@ -96,7 +96,7 @@ export function useCompletedRuleShareExport({
}: { }: {
setActionBanner: (_: CompletedFlowActionBanner | null) => void; setActionBanner: (_: CompletedFlowActionBanner | null) => void;
}): { }): {
copyPublishedRuleLink: () => Promise<void>; copyPublishedRuleLink: () => Promise<boolean>;
mailtoPublishedRule: () => void; mailtoPublishedRule: () => void;
sharePublishedRuleViaSignal: () => Promise<void>; sharePublishedRuleViaSignal: () => Promise<void>;
sharePublishedRuleViaSlack: () => Promise<void>; sharePublishedRuleViaSlack: () => Promise<void>;
@@ -137,32 +137,34 @@ export function useCompletedRuleShareExport({
url: string, url: string,
banner?: () => void, banner?: () => void,
options?: { suppressFailureWhenDocumentNotFocused?: boolean }, options?: { suppressFailureWhenDocumentNotFocused?: boolean },
) => { ): Promise<boolean> => {
try { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
(banner ?? bannerCopied)(); (banner ?? bannerCopied)();
return true;
} catch { } catch {
if ( if (
options?.suppressFailureWhenDocumentNotFocused === true && options?.suppressFailureWhenDocumentNotFocused === true &&
typeof window !== "undefined" && typeof window !== "undefined" &&
shouldSkipShareClipboardFallback(window) shouldSkipShareClipboardFallback(window)
) { ) {
return; return true;
} }
bannerCopyFailed(); bannerCopyFailed();
return false;
} }
}, },
[bannerCopied, bannerCopyFailed], [bannerCopied, bannerCopyFailed],
); );
const copyPublishedRuleLink = useCallback(async () => { const copyPublishedRuleLink = useCallback(async () => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return false;
const ctx = resolvePublishedRuleShareContext(window); const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) { if (!ctx) {
bannerNoRule(); bannerNoRule();
return; return false;
} }
await copyUrlToClipboard(ctx.url); return copyUrlToClipboard(ctx.url);
}, [bannerNoRule, copyUrlToClipboard]); }, [bannerNoRule, copyUrlToClipboard]);
const mailtoPublishedRule = useCallback(() => { const mailtoPublishedRule = useCallback(() => {
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload"; import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
import { publishRule, updatePublishedRule } from "../../../../lib/create/api"; import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule"; import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { writePendingKeepRuleLogin } from "../../../../lib/create/pendingKeepRuleLogin";
import messages from "../../../../messages/en/index"; import messages from "../../../../messages/en/index";
import type { CreateFlowState } from "../types"; import type { CreateFlowState } from "../types";
import { import {
@@ -15,7 +16,7 @@ import { createFlowStepPath } from "../utils/createFlowPaths";
type AppRouterLike = { push: (_href: string) => void }; type AppRouterLike = { push: (_href: string) => void };
type OpenLogin = (args: { type OpenLogin = (args: {
variant: "default" | "saveProgress"; variant: "default" | "saveProgress" | "keepRule";
nextPath: string; nextPath: string;
backdropVariant: "blurredYellow"; backdropVariant: "blurredYellow";
}) => void; }) => void;
@@ -46,7 +47,7 @@ export function useCreateFlowFinalize({
/** Session gate return path (`?syncDraft=1`) — differs for `/create/edit-rule` vs `/create/final-review`. */ /** Session gate return path (`?syncDraft=1`) — differs for `/create/edit-rule` vs `/create/final-review`. */
loginReturnPath: string; loginReturnPath: string;
/** /**
* `undefined` while `/api/auth/session` is in flight — finalize is a no-op * `undefined` while `/api/auth/session` is in flight — publish is a no-op
* until it resolves. `null` is a guest (anonymous publish). * until it resolves. `null` is a guest (anonymous publish).
*/ */
sessionUser: SessionUser | null | undefined; sessionUser: SessionUser | null | undefined;
@@ -79,6 +80,21 @@ export function useCreateFlowFinalize({
? state.editingPublishedRuleId.trim() ? state.editingPublishedRuleId.trim()
: ""; : "";
if (editingId.length > 0 && sessionUser === null) {
setIsPublishing(false);
writeLastPublishedRule({
id: editingId,
title,
summary: summary ?? null,
document: ruleDocument,
});
updateState({ editingPublishedRuleId: undefined });
writePendingKeepRuleLogin();
onGuestPublished?.({ skippedInvites: false });
router.push(createFlowStepPath("completed"));
return;
}
if (editingId.length > 0) { if (editingId.length > 0) {
const updateResult = await updatePublishedRule(editingId, { const updateResult = await updatePublishedRule(editingId, {
title, title,
@@ -99,8 +115,11 @@ export function useCreateFlowFinalize({
} }
if (updateResult.status === 401) { if (updateResult.status === 401) {
openLogin({ openLogin({
variant: "default", variant: sessionUser === null ? "keepRule" : "default",
nextPath: loginReturnPath, nextPath:
sessionUser === null
? createFlowStepPath("completed")
: loginReturnPath,
backdropVariant: "blurredYellow", backdropVariant: "blurredYellow",
}); });
return; return;
@@ -135,6 +154,7 @@ export function useCreateFlowFinalize({
document: ruleDocument, document: ruleDocument,
}); });
if (isGuest) { if (isGuest) {
writePendingKeepRuleLogin();
onGuestPublished?.({ skippedInvites: skippedGuestInvites }); onGuestPublished?.({ skippedInvites: skippedGuestInvites });
} }
router.push( router.push(
@@ -147,8 +167,11 @@ export function useCreateFlowFinalize({
} }
if (publishResult.status === 401) { if (publishResult.status === 401) {
openLogin({ openLogin({
variant: "default", variant: sessionUser === null ? "keepRule" : "default",
nextPath: loginReturnPath, nextPath:
sessionUser === null
? createFlowStepPath("completed")
: loginReturnPath,
backdropVariant: "blurredYellow", backdropVariant: "blurredYellow",
}); });
return; return;
@@ -34,13 +34,17 @@ const blurActiveElement = (): void => {
* *
* Resolves the active step from `/create/{screenId}` via * Resolves the active step from `/create/{screenId}` via
* {@link parseCreateFlowScreenFromPathname} (flowSteps). Footer Back uses * {@link parseCreateFlowScreenFromPathname} (flowSteps). Footer Back uses
* {@link resolveCreateFlowBackTarget} so template **Use without changes** * {@link resolveCreateFlowBackTarget}: template **Use without changes** that
* (which skips the custom-rule segment) returns to `/create/review-template/{slug}` * still needs identity (direct from a template) walks name / description /
* from `confirm-stakeholders` instead of `conflict-management`. * photo, then Back from `community-name` returns to
* `/create/review-template/{slug}`. In-flow picks (`?fromFlow=1`) skip
* identity and land on `confirm-stakeholders`; Back from that step returns
* to the template instead of `conflict-management`.
* *
* Template review footer Back uses {@link buildTemplateReviewHref}s * Template review footer Back uses {@link buildTemplateReviewHref}s
* `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so * `?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( export function useCreateFlowNavigation(
options?: CreateFlowNavigationOptions, options?: CreateFlowNavigationOptions,
@@ -83,17 +87,27 @@ export function useCreateFlowNavigation(
updateState, updateState,
]); ]);
const nextStep = getNextStep(validStep, options); const identityOnly =
const previousStep = getPreviousStep(validStep, options); state.pendingTemplateAction?.mode === "useWithoutChanges";
const navigationOptions = useMemo(
(): CreateFlowNavigationOptions => ({
skipCommunitySave: options?.skipCommunitySave,
useWithoutChangesIdentityOnly: identityOnly,
}),
[options?.skipCommunitySave, identityOnly],
);
const nextStep = getNextStep(validStep, navigationOptions);
const previousStep = getPreviousStep(validStep, navigationOptions);
const backTarget = useMemo( const backTarget = useMemo(
() => () =>
resolveCreateFlowBackTarget( resolveCreateFlowBackTarget(
validStep, validStep,
options, navigationOptions,
state.templateReviewBackSlug, state.templateReviewBackSlug,
), ),
[validStep, options?.skipCommunitySave, state.templateReviewBackSlug], [validStep, navigationOptions, state.templateReviewBackSlug],
); );
const goToNextStep = useCallback(() => { const goToNextStep = useCallback(() => {
@@ -10,6 +10,7 @@ import type {
CreateFlowContextValue, CreateFlowContextValue,
CreateFlowState, CreateFlowState,
} from "../types"; } from "../types";
import { createFlowStepPath } from "../utils/createFlowPaths";
type AppRouterLike = { push: (_href: string) => void }; type AppRouterLike = { push: (_href: string) => void };
type UpdateState = CreateFlowContextValue["updateState"]; type UpdateState = CreateFlowContextValue["updateState"];
@@ -36,9 +37,10 @@ export type UseTemplateReviewActionsResult = {
* Use without changes: scrub any prior customize picks, seed core values + * Use without changes: scrub any prior customize picks, seed core values +
* method-card selections from the template body (same id mapping as * method-card selections from the template body (same id mapping as
* Customize) so drilling from final-review via + shows selected cards, drop * Customize) so drilling from final-review via + shows selected cards, drop
* the Values row from `state.sections`, and route to * the Values row from `state.sections`. Direct template entry routes to
* `/create/confirm-stakeholders` (or `/create/informational` with a pin to * `/create/community-name` with a pin so Next/Back collect only name →
* skip past `/create/review` to `/create/confirm-stakeholders` later). * description → photo. In-flow (`?fromFlow=1`, community steps already done)
* routes to `/create/confirm-stakeholders`.
*/ */
handleUseWithoutChanges: () => Promise<void>; handleUseWithoutChanges: () => Promise<void>;
}; };
@@ -59,7 +61,10 @@ export type UseTemplateReviewActionsResult = {
* setTemplateReviewApplyError, * setTemplateReviewApplyError,
* handleCustomize, * handleCustomize,
* handleUseWithoutChanges, * handleUseWithoutChanges,
* } = useTemplateReviewActions({ pathname, state, updateState, replaceState, router }); * } = useTemplateReviewActions({
* pathname, state, updateState, replaceState, router,
* fromCreateWizard, markCreateFlowInteraction,
* });
*/ */
export function useTemplateReviewActions({ export function useTemplateReviewActions({
pathname, pathname,
@@ -67,12 +72,21 @@ export function useTemplateReviewActions({
updateState, updateState,
replaceState, replaceState,
router, router,
fromCreateWizard = false,
markCreateFlowInteraction,
}: { }: {
pathname: string | null | undefined; pathname: string | null | undefined;
state: CreateFlowState; state: CreateFlowState;
updateState: UpdateState; updateState: UpdateState;
replaceState: ReplaceStateFn; replaceState: ReplaceStateFn;
router: AppRouterLike; router: AppRouterLike;
/**
* True when this template-review URL was opened from the create wizard
* (`?fromFlow=1`). Direct catalog / marketing entry is false — a leftover
* draft title must not count as “community steps already done”.
*/
fromCreateWizard?: boolean;
markCreateFlowInteraction: () => void;
}): UseTemplateReviewActionsResult { }): UseTemplateReviewActionsResult {
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false); const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
const [templateReviewApplyError, setTemplateReviewApplyError] = useState< const [templateReviewApplyError, setTemplateReviewApplyError] = useState<
@@ -90,6 +104,7 @@ export function useTemplateReviewActions({
const handleCustomize = useCallback(async () => { const handleCustomize = useCallback(async () => {
if (!templateReviewSlug) return; if (!templateReviewSlug) return;
markCreateFlowInteraction();
setTemplateReviewApplyError(null); setTemplateReviewApplyError(null);
setIsApplyingTemplate(true); setIsApplyingTemplate(true);
const loaded = await loadTemplateReviewBySlug(templateReviewSlug); const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
@@ -119,7 +134,9 @@ export function useTemplateReviewActions({
}), }),
}); });
router.push( router.push(
hasCommunityName ? "/create/core-values" : "/create/informational", hasCommunityName
? createFlowStepPath("core-values")
: createFlowStepPath("informational"),
); );
}, [ }, [
router, router,
@@ -127,10 +144,12 @@ export function useTemplateReviewActions({
state.title, state.title,
templateReviewSlug, templateReviewSlug,
updateState, updateState,
markCreateFlowInteraction,
]); ]);
const handleUseWithoutChanges = useCallback(async () => { const handleUseWithoutChanges = useCallback(async () => {
if (!templateReviewSlug) return; if (!templateReviewSlug) return;
markCreateFlowInteraction();
setTemplateReviewApplyError(null); setTemplateReviewApplyError(null);
setIsApplyingTemplate(true); setIsApplyingTemplate(true);
const loaded = await loadTemplateReviewBySlug(templateReviewSlug); const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
@@ -158,9 +177,6 @@ export function useTemplateReviewActions({
return; return;
} }
const hasCommunityName =
typeof state.title === "string" && state.title.trim().length > 0;
// Atomic read-modify-write: strip prior custom-rule picks and merge template // Atomic read-modify-write: strip prior custom-rule picks and merge template
// body in one replaceState so method ids are never lost across React batching // body in one replaceState so method ids are never lost across React batching
// (reset + update separately could leave selections undefined in Strict Mode). // (reset + update separately could leave selections undefined in Strict Mode).
@@ -179,13 +195,10 @@ export function useTemplateReviewActions({
}) })
: sections; : sections;
const hasCommunityName =
typeof prev.title === "string" && prev.title.trim().length > 0;
const pinPatch = const pinPatch =
methodSectionsPinsForHydratedSelections(customizePrefill); methodSectionsPinsForHydratedSelections(customizePrefill);
return { const seeded: CreateFlowState = {
...base, ...base,
...(hasValuesSeed ...(hasValuesSeed
? { ? {
@@ -221,22 +234,40 @@ export function useTemplateReviewActions({
sections: sectionsWithoutValues, sections: sectionsWithoutValues,
methodSectionsPinCommitted: pinPatch, methodSectionsPinCommitted: pinPatch,
templateReviewBackSlug: templateReviewSlug, templateReviewBackSlug: templateReviewSlug,
...(hasCommunityName };
? { pendingTemplateAction: undefined }
: { if (fromCreateWizard) {
pendingTemplateAction: { return {
slug: templateReviewSlug, ...seeded,
mode: "useWithoutChanges", pendingTemplateAction: undefined,
}, };
}), }
return {
...seeded,
title: undefined,
communityContext: undefined,
communityAvatarUrl: undefined,
currentStep: undefined,
templateReviewEntryFromCreateFlow: undefined,
pendingTemplateAction: {
slug: templateReviewSlug,
mode: "useWithoutChanges",
},
}; };
}); });
router.push( router.push(
hasCommunityName fromCreateWizard
? "/create/confirm-stakeholders" ? createFlowStepPath("confirm-stakeholders")
: "/create/informational", : createFlowStepPath("community-name"),
); );
}, [replaceState, router, state.title, templateReviewSlug]); }, [
fromCreateWizard,
markCreateFlowInteraction,
replaceState,
router,
templateReviewSlug,
]);
return { return {
isTemplateReviewRoute, isTemplateReviewRoute,
+7
View File
@@ -1,5 +1,12 @@
import type { Metadata } from "next";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import CreateFlowLayoutGate from "./CreateFlowLayoutGate"; import CreateFlowLayoutGate from "./CreateFlowLayoutGate";
import messages from "../../../messages/en/index";
import { routeMetadata } from "../../../lib/siteMetadata";
export const metadata: Metadata = routeMetadata("/create", {
title: messages.metadata.create.title,
});
export default function CreateFlowLayout({ children }: { children: ReactNode }) { export default function CreateFlowLayout({ children }: { children: ReactNode }) {
return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>; return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>;
+1 -1
View File
@@ -6,7 +6,7 @@
export default function CreateFlowRouteLoading() { export default function CreateFlowRouteLoading() {
return ( return (
<div <div
className="flex h-screen min-h-0 flex-col overflow-hidden bg-[var(--color-surface-default-primary)]" className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-[var(--color-surface-default-primary)]"
aria-busy="true" aria-busy="true"
aria-live="polite" aria-live="polite"
> >
@@ -6,13 +6,13 @@
* *
* Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from * Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and * two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
* reuse `CardStack` / `CreateFlowStepShell` as needed. * reuse `CardStack` / `CreateFlowCardStackStepShell` as needed.
* *
* Card click opens the Figma create modal (node `20246-15829`) with three * Card click opens the Figma create modal (node `20246-15829`) with three
* editable sections rendered by {@link CommunicationMethodEditFields}. The primary * editable sections rendered by {@link CommunicationMethodEditFields}. The
* action is **Add Platform** for an unselected card and **Save** for a selected * primary action is **Add Platform** for an unselected card and **Save** for a
* card. **Remove** is available from the kebab (same behavior as legacy * selected card. **Remove** is a danger footer button on selected cards (and
* footer remove via {@link removeMethodCardFromFacetSelection}). * remains in the kebab) via {@link removeMethodCardFromFacetSelection}.
*/ */
import { useState, useCallback, useMemo, useRef } from "react"; import { useState, useCallback, useMemo, useRef } from "react";
@@ -22,16 +22,10 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create"; import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { CommunicationMethodEditFields } from "../../components/methodEditFields"; import { CommunicationMethodEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
@@ -700,44 +694,30 @@ export function CommunicationMethodsScreen() {
return ( return (
<> <>
<CreateFlowStepShell <CreateFlowCardStackStepShell
variant="wideGridLoosePadding" lockupTitle={title}
contentTopBelowMd="space-800" lockupDescription={description}
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS} stackBusy={!recommendationsReady}
> >
<div className="flex w-full min-w-0 flex-col items-center gap-6"> {recommendationsReady && (
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}> <CardStack
<CreateFlowHeaderLockup cards={sampleCards}
title={title} selectedIds={selectedIds}
description={description} onCardSelect={handleCardClick}
justification="center" expanded={expanded}
/> onToggleExpand={() => {
</div> markCreateFlowInteraction();
<div setExpanded((prev) => !prev);
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS} }}
aria-busy={!recommendationsReady} hasMore={true}
> toggleLabel={comm.page.seeAllLink}
{recommendationsReady && ( compactRecommendedLimit={5}
<CardStack compactCardIds={compactCardIds}
cards={sampleCards} compactDesktopLayout="flexWrap"
selectedIds={selectedIds} headerLockupSize={mdUp ? "L" : "M"}
onCardSelect={handleCardClick} />
expanded={expanded} )}
onToggleExpand={() => { </CreateFlowCardStackStepShell>
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={comm.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="flexWrap"
headerLockupSize={mdUp ? "L" : "M"}
/>
)}
</div>
</div>
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
@@ -747,6 +727,8 @@ export function CommunicationMethodsScreen() {
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={false} showBackButton={false}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -778,7 +760,6 @@ export function CommunicationMethodsScreen() {
) )
) : null} ) : null}
</Create> </Create>
</CreateFlowStepShell>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
@@ -19,16 +19,10 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create"; import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { ConflictManagementEditFields } from "../../components/methodEditFields"; import { ConflictManagementEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
@@ -701,44 +695,30 @@ export function ConflictManagementScreen() {
return ( return (
<> <>
<CreateFlowStepShell <CreateFlowCardStackStepShell
variant="wideGridLoosePadding" lockupTitle={title}
contentTopBelowMd="space-800" lockupDescription={description}
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS} stackBusy={!recommendationsReady}
> >
<div className="flex w-full min-w-0 flex-col items-center gap-6"> {recommendationsReady && (
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}> <CardStack
<CreateFlowHeaderLockup cards={sampleCards}
title={title} selectedIds={selectedIds}
description={description} onCardSelect={handleCardClick}
justification="center" expanded={expanded}
/> onToggleExpand={() => {
</div> markCreateFlowInteraction();
<div setExpanded((prev) => !prev);
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS} }}
aria-busy={!recommendationsReady} hasMore={true}
> toggleLabel={cm.page.seeAllLink}
{recommendationsReady && ( compactRecommendedLimit={5}
<CardStack compactCardIds={compactCardIds}
cards={sampleCards} compactDesktopLayout="pyramidFive"
selectedIds={selectedIds} headerLockupSize={mdUp ? "L" : "M"}
onCardSelect={handleCardClick} />
expanded={expanded} )}
onToggleExpand={() => { </CreateFlowCardStackStepShell>
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={cm.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
)}
</div>
</div>
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
@@ -748,6 +728,8 @@ export function ConflictManagementScreen() {
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={false} showBackButton={false}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -779,7 +761,6 @@ export function ConflictManagementScreen() {
) )
) : null} ) : null}
</Create> </Create>
</CreateFlowStepShell>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
@@ -20,16 +20,10 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create"; import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { MembershipMethodEditFields } from "../../components/methodEditFields"; import { MembershipMethodEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
@@ -694,44 +688,30 @@ export function MembershipMethodsScreen() {
return ( return (
<> <>
<CreateFlowStepShell <CreateFlowCardStackStepShell
variant="wideGridLoosePadding" lockupTitle={title}
contentTopBelowMd="space-800" lockupDescription={description}
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS} stackBusy={!recommendationsReady}
> >
<div className="flex w-full min-w-0 flex-col items-center gap-6"> {recommendationsReady && (
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}> <CardStack
<CreateFlowHeaderLockup cards={sampleCards}
title={title} selectedIds={selectedIds}
description={description} onCardSelect={handleCardClick}
justification="center" expanded={expanded}
/> onToggleExpand={() => {
</div> markCreateFlowInteraction();
<div setExpanded((prev) => !prev);
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS} }}
aria-busy={!recommendationsReady} hasMore={true}
> toggleLabel={mem.page.seeAllLink}
{recommendationsReady && ( compactRecommendedLimit={5}
<CardStack compactCardIds={compactCardIds}
cards={sampleCards} compactDesktopLayout="pyramidFive"
selectedIds={selectedIds} headerLockupSize={mdUp ? "L" : "M"}
onCardSelect={handleCardClick} />
expanded={expanded} )}
onToggleExpand={() => { </CreateFlowCardStackStepShell>
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={mem.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
)}
</div>
</div>
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
@@ -741,6 +721,8 @@ export function MembershipMethodsScreen() {
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={false} showBackButton={false}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -772,7 +754,6 @@ export function MembershipMethodsScreen() {
) )
) : null} ) : null}
</Create> </Create>
</CreateFlowStepShell>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
@@ -16,6 +16,8 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS, CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_PAGE_GUTTER_CLASS,
CREATE_FLOW_SCROLL_REGION_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS, CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../components/createFlowLayoutTokens"; } from "../../components/createFlowLayoutTokens";
import { import {
@@ -163,7 +165,7 @@ export function CompletedScreen() {
<> <>
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full"> <div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
<div <div
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`} className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 ${CREATE_FLOW_PAGE_GUTTER_CLASS} max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
> >
<div <div
className={`flex flex-col justify-start max-md:min-h-min max-md:overflow-visible min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`} className={`flex flex-col justify-start max-md:min-h-min max-md:overflow-visible min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
@@ -177,7 +179,7 @@ export function CompletedScreen() {
/> />
</div> </div>
<div <div
className={`scrollbar-hide relative flex min-h-0 flex-col self-stretch overflow-x-hidden md:max-h-full md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`} className={`relative flex min-h-0 flex-col self-stretch overflow-x-hidden md:max-h-full md:overflow-y-auto ${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
> >
<div <div
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block" className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
@@ -115,6 +115,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
messageNamespace="create.community.communityName" messageNamespace="create.community.communityName"
stateField="title" stateField="title"
maxLength={48} maxLength={48}
required
/> />
); );
case "community-structure": case "community-structure":
@@ -126,6 +127,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
stateField="communityContext" stateField="communityContext"
maxLength={200} maxLength={200}
mainAlign="center" mainAlign="center"
multiline
/> />
); );
case "community-size": case "community-size":
@@ -1,6 +1,5 @@
"use client"; "use client";
import type { ReactNode } from "react";
import NumberedList from "../../../../components/type/NumberedList"; import NumberedList from "../../../../components/type/NumberedList";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
@@ -31,22 +30,6 @@ export function InformationalScreen() {
}, },
]; ];
const description: ReactNode = (
<>
{copy.descriptionLead}{" "}
<a
href="#"
className="font-normal text-[var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] cursor-pointer"
onClick={(e) => {
e.preventDefault();
}}
>
{copy.workshopLabel}
</a>{" "}
{copy.descriptionTrail}
</>
);
return ( return (
<CreateFlowStepShell <CreateFlowStepShell
variant="centeredNarrow" variant="centeredNarrow"
@@ -57,7 +40,7 @@ export function InformationalScreen() {
> >
<CreateFlowHeaderLockup <CreateFlowHeaderLockup
title={copy.title} title={copy.title}
description={description} description={copy.description}
justification="left" justification="left"
/> />
<NumberedList items={items} size={mdUp ? "M" : "S"} /> <NumberedList items={items} size={mdUp ? "M" : "S"} />
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Rule from "../../../../components/cards/Rule"; import Rule from "../../../../components/cards/Rule";
import Button from "../../../../components/buttons/Button";
import { useTranslation } from "../../../../contexts/MessagesContext"; import { useTranslation } from "../../../../contexts/MessagesContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -17,7 +18,7 @@ import {
vectorMarkPath, vectorMarkPath,
} from "../../../../../lib/assetUtils"; } from "../../../../../lib/assetUtils";
import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState"; 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`). */ /** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
export function CommunityReviewScreen() { export function CommunityReviewScreen() {
@@ -25,16 +26,22 @@ export function CommunityReviewScreen() {
const lgUp = useCreateFlowLgUp(); const lgUp = useCreateFlowLgUp();
const t = useTranslation("create.community.review"); const t = useTranslation("create.community.review");
const { state, updateState } = useCreateFlow(); 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" or "Use without changes" from a template * If the user picked **Customize** from a template before finishing community
* before entering community stage, we pinned `pendingTemplateAction` so * stage, we pinned `pendingTemplateAction` so this screen can skip itself
* this screen can skip itself — they already expressed their intent, no * and `replace` to `core-values`. **Use without changes** does not skip
* reason to make them re-pick from the review footer. We `replace` (not * identity: if they land here with that pin, send them to `community-name`
* `push`) so Back from the destination goes to `community-save` instead of * and leave the pin so name → description → photo stay in the skip list.
* bouncing through here again. The action is cleared synchronously via
* `updateState` to guarantee the redirect only fires once: later visits to
* `/create/review` (e.g. navigating here directly) render normally.
* *
* Ref guard covers React 18 StrictMode's double-mount in dev so we don't * Ref guard covers React 18 StrictMode's double-mount in dev so we don't
* fire `router.replace` twice on the same transition. * fire `router.replace` twice on the same transition.
@@ -44,20 +51,19 @@ export function CommunityReviewScreen() {
if (firedRedirectRef.current) return; if (firedRedirectRef.current) return;
const pending = state.pendingTemplateAction; const pending = state.pendingTemplateAction;
if (!pending) return; if (!pending) return;
const target = if (pending.mode !== "customize") {
pending.mode === "customize" firedRedirectRef.current = true;
? createFlowStepPath("core-values") router.replace(createFlowStepPath("community-name"));
: createFlowStepPath("confirm-stakeholders"); return;
}
const target = createFlowStepPath("core-values");
firedRedirectRef.current = true; firedRedirectRef.current = true;
const pinMerge = const pinMerge = {
pending.mode === "customize" methodSectionsPinCommitted: {
? { ...state.methodSectionsPinCommitted,
methodSectionsPinCommitted: { ...methodSectionsPinsForHydratedSelections(state),
...state.methodSectionsPinCommitted, },
...methodSectionsPinsForHydratedSelections(state), };
},
}
: {};
updateState({ pendingTemplateAction: undefined, ...pinMerge }); updateState({ pendingTemplateAction: undefined, ...pinMerge });
router.replace(target); router.replace(target);
}, [ }, [
@@ -74,11 +80,10 @@ export function CommunityReviewScreen() {
const cardTitle = const cardTitle =
typeof state.title === "string" && state.title.trim().length > 0 typeof state.title === "string" && state.title.trim().length > 0
? state.title.trim() ? state.title.trim()
: t("ruleCard.title"); : "";
/** /**
* No placeholder fallback: if the user skipped `community-context`, leave * No placeholder fallback: if the user skipped `community-context`, leave
* the card description off rather than render the old "Mutual Aid Monday * the card description off rather than render sample copy as real user data.
* is a grassroots community…" sample, which read as real user copy.
*/ */
const cardDescription = const cardDescription =
typeof state.communityContext === "string" && typeof state.communityContext === "string" &&
@@ -92,6 +97,36 @@ export function CommunityReviewScreen() {
? state.communityAvatarUrl.trim() ? state.communityAvatarUrl.trim()
: null; : 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 ( return (
<CreateFlowStepShell <CreateFlowStepShell
variant="wideGridLoosePadding" variant="wideGridLoosePadding"
@@ -97,8 +97,9 @@ export function FinalReviewScreen({
/** /**
* Two modals coexist on this screen: * Two modals coexist on this screen:
* *
* - {@link FinalReviewChipEditModal} — core values + method chips: kebab * - {@link FinalReviewChipEditModal} — core values + method chips: footer
* Remove; values also offer Duplicate under the five-chip cap; method chips * **Remove** when the chip is already selected, plus kebab **Remove**;
* values also offer Duplicate under the five-chip cap; method chips
* offer Customize (prefilled custom-policy wizard). Fields are editable on * offer Customize (prefilled custom-policy wizard). Fields are editable on
* open. Save writes `{group}DetailsById` and field blocks; wizard Finalize * open. Save writes `{group}DetailsById` and field blocks; wizard Finalize
* also writes `customMethodCardMetaById`. * also writes `customMethodCardMetaById`.
@@ -811,6 +811,8 @@ export function DecisionApproachesScreen() {
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={false} showBackButton={false}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -303,7 +303,6 @@ export function CommunityStructureSelectScreen() {
<> <>
<MultiSelect <MultiSelect
label={cs.organizationMultiSelect.label} label={cs.organizationMultiSelect.label}
showHelpIcon
size="s" size="s"
options={organizationTypeOptions} options={organizationTypeOptions}
onChipClick={handleOrganizationTypeClick} onChipClick={handleOrganizationTypeClick}
@@ -313,7 +312,6 @@ export function CommunityStructureSelectScreen() {
/> />
<MultiSelect <MultiSelect
label={cs.scaleMultiSelect.label} label={cs.scaleMultiSelect.label}
showHelpIcon
size="s" size="s"
options={scaleOptions} options={scaleOptions}
onChipClick={handleScaleClick} onChipClick={handleScaleClick}
@@ -323,7 +321,6 @@ export function CommunityStructureSelectScreen() {
/> />
<MultiSelect <MultiSelect
label={cs.maturityMultiSelect.label} label={cs.maturityMultiSelect.label}
showHelpIcon
size="s" size="s"
options={maturityOptions} options={maturityOptions}
onChipClick={handleMaturityClick} onChipClick={handleMaturityClick}
@@ -9,8 +9,7 @@ import { useTranslation } from "../../../../contexts/MessagesContext";
import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits"; import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY, CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE, CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
@@ -131,64 +130,52 @@ export function ConfirmStakeholdersScreen() {
if (managePublishedMode) { if (managePublishedMode) {
return ( return (
<CreateFlowStepShell <CreateFlowTwoColumnSelectShell
variant="centeredNarrowBottomPad" header={
contentTopBelowMd="space-1400" <CreateFlowHeaderLockup
title={t("managePublished.lockupTitle")}
description={t("managePublished.lockupDescription")}
justification="left"
/>
}
> >
<div <PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`} </CreateFlowTwoColumnSelectShell>
>
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
<CreateFlowHeaderLockup
title={t("managePublished.lockupTitle")}
description={t("managePublished.lockupDescription")}
justification="left"
/>
</div>
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
</div>
</CreateFlowStepShell>
); );
} }
return ( return (
<> <>
<CreateFlowStepShell <CreateFlowTwoColumnSelectShell
variant="centeredNarrowBottomPad" header={
contentTopBelowMd="space-1400" <CreateFlowHeaderLockup
> title={t("title")}
<div description={t("description")}
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`} justification="left"
>
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
<CreateFlowHeaderLockup
title={t("title")}
description={t("description")}
justification="left"
/>
</div>
{chipError ? (
<p
className="text-small-paragraph text-[var(--color-border-default-utility-negative)]"
role="alert"
>
{chipError}
</p>
) : null}
<MultiSelect
formHeader={false}
showHelpIcon={false}
size="s"
options={stakeholderOptions}
onChipClick={handleChipClick}
onAddClick={handleAddStakeholder}
onCustomChipConfirm={handleCustomChipConfirm}
onCustomChipClose={handleCustomChipClose}
addButton
addButtonText={t("addStakeholder")}
/> />
</div> }
</CreateFlowStepShell> >
{chipError ? (
<p
className="text-small-paragraph text-[var(--color-border-default-utility-negative)]"
role="alert"
>
{chipError}
</p>
) : null}
<MultiSelect
formHeader={false}
showHelpIcon={false}
size="s"
options={stakeholderOptions}
onChipClick={handleChipClick}
onAddClick={handleAddStakeholder}
onCustomChipConfirm={handleCustomChipConfirm}
onCustomChipClose={handleCustomChipClose}
addButton
addButtonText={t("addStakeholder")}
/>
</CreateFlowTwoColumnSelectShell>
{!toastDismissed && ( {!toastDismissed && (
<div <div
@@ -587,17 +587,23 @@ export function CoreValuesSelectScreen() {
[draft, markCreateFlowInteraction, replaceState, wizardCustomizeChipId], [draft, markCreateFlowInteraction, replaceState, wizardCustomizeChipId],
); );
const selectedCount = useMemo(
() => coreValueOptions.filter((o) => o.state === "selected").length,
[coreValueOptions],
);
const atSelectionLimit = selectedCount >= MAX_CORE_VALUES;
const selectionCountText = cv.multiSelect.selectionCount
.replace("{count}", String(selectedCount))
.replace("{max}", String(MAX_CORE_VALUES));
const kebabMenuItems = useMemo(() => { const kebabMenuItems = useMemo(() => {
if (!modalSession || !activeModalChipId) return []; if (!modalSession || !activeModalChipId) return [];
const selectedCount = coreValueOptions.filter(
(o) => o.state === "selected",
).length;
return buildCustomRuleModalKebabMenu(modalKebabMenu, { return buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: true, showCustomize: true,
onCustomize: handleCustomize, onCustomize: handleCustomize,
onDuplicate: onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || (state.editingPublishedRuleId?.trim() ?? "") !== "" ||
selectedCount >= MAX_CORE_VALUES atSelectionLimit
? undefined ? undefined
: handleDuplicateCoreChip, : handleDuplicateCoreChip,
showRemove: modalSession === "editing", showRemove: modalSession === "editing",
@@ -605,7 +611,7 @@ export function CoreValuesSelectScreen() {
}); });
}, [ }, [
activeModalChipId, activeModalChipId,
coreValueOptions, atSelectionLimit,
handleCustomize, handleCustomize,
handleDuplicateCoreChip, handleDuplicateCoreChip,
handleRemoveFromKebab, handleRemoveFromKebab,
@@ -695,7 +701,8 @@ export function CoreValuesSelectScreen() {
<button <button
type="button" type="button"
onClick={addHandlers.onAddClick} onClick={addHandlers.onAddClick}
className="cursor-pointer font-normal leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90" disabled={atSelectionLimit}
className="cursor-pointer font-normal leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90 disabled:cursor-not-allowed disabled:no-underline disabled:opacity-60 disabled:hover:opacity-60"
> >
{cv.header.addLink} {cv.header.addLink}
</button> </button>
@@ -730,6 +737,9 @@ export function CoreValuesSelectScreen() {
onCustomChipClose={addHandlers.onCustomChipClose} onCustomChipClose={addHandlers.onCustomChipClose}
addButton addButton
addButtonText={cv.multiSelect.addButtonText} addButtonText={cv.multiSelect.addButtonText}
maxSelections={MAX_CORE_VALUES}
selectionCountText={selectionCountText}
limitReachedAnnouncement={cv.multiSelect.limitReached}
/> />
{detailModal && ( {detailModal && (
@@ -751,6 +761,8 @@ export function CoreValuesSelectScreen() {
} }
showBackButton={false} showBackButton={false}
showNextButton={showFooterPrimary} showNextButton={showFooterPrimary}
showRemoveButton={modalSession === "editing"}
onRemove={handleRemoveFromKebab}
onNext={handleModalConfirm} onNext={handleModalConfirm}
nextButtonText={ nextButtonText={
modalSession === "editing" modalSession === "editing"
@@ -2,6 +2,7 @@
import { useState, useEffect, type HTMLInputTypeAttribute } from "react"; import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
import TextInput from "../../../../components/controls/TextInput"; import TextInput from "../../../../components/controls/TextInput";
import TextArea from "../../../../components/controls/TextArea";
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types"; import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
import { useTranslation } from "../../../../contexts/MessagesContext"; import { useTranslation } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -12,6 +13,7 @@ import {
type CreateFlowContentTopBelowMd, type CreateFlowContentTopBelowMd,
} from "../../components/CreateFlowStepShell"; } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../utils/createFlowPaths";
import type { CreateFlowTextStateField } from "../../types"; import type { CreateFlowTextStateField } from "../../types";
type Props = { type Props = {
@@ -21,6 +23,10 @@ type Props = {
/** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */ /** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */
mainAlign?: "start" | "center"; mainAlign?: "start" | "center";
inputType?: HTMLInputTypeAttribute; inputType?: HTMLInputTypeAttribute;
/** Multi-line control (community description). */
multiline?: boolean;
/** Native `required` (community name and save-progress email). */
required?: boolean;
showCharacterCount?: boolean; showCharacterCount?: boolean;
headerJustification?: HeaderLockupJustificationValue; headerJustification?: HeaderLockupJustificationValue;
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */ /** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
@@ -28,7 +34,7 @@ type Props = {
}; };
/** /**
* Shared narrow-column + TextInput pattern for Create Community text frames. * Shared narrow-column + labelled field pattern for Create Community text frames.
*/ */
export function CreateFlowTextFieldScreen({ export function CreateFlowTextFieldScreen({
messageNamespace, messageNamespace,
@@ -36,6 +42,8 @@ export function CreateFlowTextFieldScreen({
maxLength, maxLength,
mainAlign = "start", mainAlign = "start",
inputType = "text", inputType = "text",
multiline = false,
required = false,
showCharacterCount = true, showCharacterCount = true,
headerJustification = "left", headerJustification = "left",
contentTopBelowMd = "space-1400", contentTopBelowMd = "space-1400",
@@ -69,6 +77,15 @@ export function CreateFlowTextFieldScreen({
const mainItems = const mainItems =
mainAlign === "center" ? "items-center" : "items-start"; mainAlign === "center" ? "items-center" : "items-start";
const isEmail = inputType === "email";
const persistValue = (next: string) => {
setValue(next);
markCreateFlowInteraction();
updateState({ [stateField]: next } as Record<string, string>);
};
const fieldLabel = t("inputLabel");
return ( return (
<CreateFlowStepShell <CreateFlowStepShell
variant="centeredNarrow" variant="centeredNarrow"
@@ -85,22 +102,60 @@ export function CreateFlowTextFieldScreen({
/> />
</div> </div>
<div className="w-full"> <div className="w-full">
<TextInput {multiline ? (
className="!transition-none" <TextArea
type={inputType} className="!transition-none"
placeholder={t("placeholder")} label={fieldLabel}
value={value} placeholder={t("placeholder")}
onChange={(e) => { value={value}
const v = e.target.value; onChange={(e) => persistValue(e.target.value)}
setValue(v); onBlur={() => {
markCreateFlowInteraction(); const trimmed = value.trim();
updateState({ [stateField]: v } as Record<string, string>); if (trimmed !== value) persistValue(trimmed);
}} }}
inputSize={mdUp ? "medium" : "small"} size={mdUp ? "medium" : "small"}
formHeader={false} formHeader={false}
textHint={hint} showHelpIcon={false}
maxLength={maxLength} textHint={hint}
/> maxLength={maxLength}
required={required}
rows={4}
/>
) : (
<TextInput
className="!transition-none"
type={inputType}
name={isEmail ? "email" : undefined}
label={fieldLabel}
placeholder={t("placeholder")}
value={value}
onChange={(e) => persistValue(e.target.value)}
onBlur={() => {
const trimmed = isEmail
? value.trim().toLowerCase()
: value.trim();
if (trimmed !== value) persistValue(trimmed);
}}
onKeyDown={
isEmail
? (e) => {
if (e.key !== "Enter") return;
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
: undefined
}
inputSize={mdUp ? "medium" : "small"}
formHeader={false}
showHelpIcon={false}
textHint={hint}
maxLength={maxLength}
required={required || isEmail}
autoComplete={isEmail ? "email" : undefined}
inputMode={isEmail ? "email" : undefined}
form={isEmail ? CREATE_FLOW_COMMUNITY_SAVE_FORM_ID : undefined}
/>
)}
</div> </div>
</div> </div>
</CreateFlowStepShell> </CreateFlowStepShell>
@@ -16,14 +16,24 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
import { fetchAuthSession } from "../../../../../lib/create/api"; import { fetchAuthSession } from "../../../../../lib/create/api";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils"; import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import { import {
UploadToServerError, createFlowUploadFailureMessageKey,
uploadCreateFlowFile, uploadCreateFlowFile,
} from "../../../../../lib/create/uploadToServer"; } from "../../../../../lib/create/uploadToServer";
import {
COMMUNITY_AVATAR_ACCEPT,
messageKeyForCreateFlowUploadReason,
validateCreateFlowUploadFile,
} from "../../../../../lib/create/createFlowUploadValidation";
import { import {
clearPendingCommunityAvatarFile, clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile,
storePendingCommunityAvatarFile, storePendingCommunityAvatarFile,
} from "../../../../../lib/create/pendingCommunityAvatarUpload"; } from "../../../../../lib/create/pendingCommunityAvatarUpload";
function hasCommunityAvatarUrl(url: string | undefined): boolean {
return typeof url === "string" && url.trim().length > 0;
}
/** Create Community — Figma Flow — Upload `20094:41524`. */ /** Create Community — Figma Flow — Upload `20094:41524`. */
export function CommunityUploadScreen() { export function CommunityUploadScreen() {
const m = useMessages(); const m = useMessages();
@@ -54,17 +64,57 @@ export function CommunityUploadScreen() {
[localPreviewUrl], [localPreviewUrl],
); );
const resolveUploadError = useCallback( const serverAvatarUrl = hasCommunityAvatarUrl(state.communityAvatarUrl)
(err: unknown) => { ? state.communityAvatarUrl!.trim()
if (err instanceof UploadToServerError) { : null;
if (err.status === 413) return tUpload("errors.tooLarge"); const serverAvatarUrlRef = useRef(serverAvatarUrl);
if (err.status === 401) return tUpload("errors.unauthorized"); serverAvatarUrlRef.current = serverAvatarUrl;
if (err.code === "server_misconfigured") {
return tUpload("errors.misconfigured"); useEffect(() => {
if (serverAvatarUrl) {
setLocalPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
}
}, [serverAvatarUrl]);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const file = await readPendingCommunityAvatarFile();
if (cancelled || !file) return;
const validated = await validateCreateFlowUploadFile(
file,
"communityAvatar",
);
if (validated.ok === false) {
await clearPendingCommunityAvatarFile();
return;
} }
if (cancelled) return;
if (serverAvatarUrlRef.current) return;
const objectUrl = URL.createObjectURL(file);
if (cancelled) {
URL.revokeObjectURL(objectUrl);
return;
}
setLocalPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return objectUrl;
});
} catch {
// Missing IndexedDB / quota: leave the picker empty.
} }
return tUpload("errors.generic"); })();
}, return () => {
cancelled = true;
};
}, []);
const resolveUploadError = useCallback(
(err: unknown) => tUpload(createFlowUploadFailureMessageKey(err)),
[tUpload], [tUpload],
); );
@@ -94,6 +144,16 @@ export function CommunityUploadScreen() {
} }
if (signedIn === false) { if (signedIn === false) {
const validated = await validateCreateFlowUploadFile(
file,
"communityAvatar",
);
if (validated.ok === false) {
setErrorMessage(
tUpload(messageKeyForCreateFlowUploadReason(validated.reason)),
);
return;
}
try { try {
await storePendingCommunityAvatarFile(file); await storePendingCommunityAvatarFile(file);
setLocalPreviewUrl((prev) => { setLocalPreviewUrl((prev) => {
@@ -121,24 +181,16 @@ export function CommunityUploadScreen() {
if (prev) URL.revokeObjectURL(prev); if (prev) URL.revokeObjectURL(prev);
return null; return null;
}); });
if ( if (hasCommunityAvatarUrl(state.communityAvatarUrl)) {
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
) {
updateState({ communityAvatarUrl: undefined }); updateState({ communityAvatarUrl: undefined });
} }
// Clear any anonymous staged blob so the post-sign-in flush won't resurrect it.
void clearPendingCommunityAvatarFile(); void clearPendingCommunityAvatarFile();
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ""; fileInputRef.current.value = "";
} }
}, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]); }, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]);
const displaySrc = const displaySrc = serverAvatarUrl ?? localPreviewUrl;
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
? state.communityAvatarUrl.trim()
: localPreviewUrl;
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0; const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
return ( return (
@@ -161,7 +213,7 @@ export function CommunityUploadScreen() {
type="file" type="file"
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
accept="image/jpeg,image/png,image/webp,image/gif" accept={COMMUNITY_AVATAR_ACCEPT}
aria-label={u.hintText} aria-label={u.hintText}
onChange={handleFileChange} onChange={handleFileChange}
/> />
@@ -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} />;
}
+14 -16
View File
@@ -194,11 +194,13 @@ export interface CreateFlowState {
customMethodCardFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>; customMethodCardFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>;
/** /**
* Set when a user picks a template (Customize or Use without changes) before * Set when a user picks a template (Customize or Use without changes) before
* completing the community stage. The community-review screen consumes this * completing the community stage. Customize: the community-review screen
* to `router.replace` past `/create/review` to the correct downstream step * `router.replace`s past `/create/review` to `core-values`. Use without
* (`core-values` for customize; `confirm-stakeholders` for use-without-changes) * changes: navigation skips to name → description → optional photo, then
* once community data is captured. Cleared the moment the redirect fires, so * `confirm-stakeholders` (the pin keeps that skip list active). Set only for
* later visits to `/create/review` render normally. * **direct** template entry, not when community identity was already collected
* in-flow. Cleared when community-review consumes a customize pin, or when
* the flow is reset.
*/ */
pendingTemplateAction?: { pendingTemplateAction?: {
slug: string; slug: string;
@@ -206,23 +208,20 @@ export interface CreateFlowState {
}; };
/** /**
* Set when the user chooses **Use without changes** on a template-review * Set when the user chooses **Use without changes** on a template-review
* page. The custom-rule segment (`core-values` … `conflict-management`) is * page. Used so Back from `community-name` (identity-only path) returns to
* skipped, so linear `getPreviousStep("confirm-stakeholders")` would wrongly * `/create/review-template/{slug}` instead of the intro. Cleared when the user
* point at `conflict-management`. Navigation uses this slug so Back from * picks **Customize** from template review or when the flow state is cleared.
* `confirm-stakeholders` returns to `/create/review-template/{slug}`.
* Cleared when the user picks **Customize** from template review (normal
* linear back applies) or when the flow state is cleared.
*/ */
templateReviewBackSlug?: string; templateReviewBackSlug?: string;
/** /**
* True when the user opened `/create/review-template/{slug}` from the create * 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 * from template review targets `/create/review` and so returning from
* `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL. * `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL.
*/ */
templateReviewEntryFromCreateFlow?: boolean; templateReviewEntryFromCreateFlow?: boolean;
/** /**
* When set, **Finalize** and signed-in **Save & Exit** update this published * When set, **Publish** and signed-in **Save & Exit** update this published
* rule (PATCH) instead of POSTing a new rule or only saving a draft. * rule (PATCH) instead of POSTing a new rule or only saving a draft.
*/ */
editingPublishedRuleId?: string; editingPublishedRuleId?: string;
@@ -279,9 +278,8 @@ export interface CreateFlowContextValue {
* *
* Current consumer: {@link SignedInDraftHydration} — when a signed-in user * Current consumer: {@link SignedInDraftHydration} — when a signed-in user
* has already started editing, we skip replaying their server draft on top * has already started editing, we skip replaying their server draft on top
* of in-progress local state. Save & Exit visibility is driven by step * of in-progress local state. Save & Exit visibility is
* index (`SAVE_EXIT_FROM_STEP_INDEX` in `CreateFlowLayoutClient`), not this * `shouldOfferCreateFlowSaveAndExit` in `utils/flowSteps.ts`, not this flag.
* flag.
*/ */
interactionTouched: boolean; interactionTouched: boolean;
markCreateFlowInteraction: () => void; markCreateFlowInteraction: () => void;
@@ -10,8 +10,8 @@ type FooterMessages = typeof footerMessages;
* from this map fall back to `footer.next`. * from this map fall back to `footer.next`.
* *
* `final-review` is handled separately by the caller because its label * `final-review` is handled separately by the caller because its label
* also depends on the in-flight publish flag (`finalizeButtonPublishing` * also depends on the in-flight publish flag (`publishButtonPublishing`
* vs `finalizeCommunityRule`). * vs `publishCommunityRule`).
*/ */
const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap< const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap<
CreateFlowStep, CreateFlowStep,
@@ -10,12 +10,18 @@ import {
FIRST_STEP, FIRST_STEP,
} from "./flowSteps"; } from "./flowSteps";
/** Associates the save-progress email field with the layout footer submit. */
export const CREATE_FLOW_COMMUNITY_SAVE_FORM_ID =
"create-flow-community-save";
export const CREATE_ROUTES = { export const CREATE_ROUTES = {
root: "/", root: "/",
createRoot: "/create", createRoot: "/create",
/** Direct path to the first wizard step so client navigations skip the redirect hop. */ /** Direct path to the first wizard step so client navigations skip the redirect hop. */
createFirstStep: `/create/${FIRST_STEP}`, createFirstStep: `/create/${FIRST_STEP}`,
review: "/create/review", review: "/create/review",
/** In-flow template catalog (wizard chrome). Marketing catalog remains `/templates`. */
templatesPicker: "/create/templates",
finalReview: "/create/final-review", finalReview: "/create/final-review",
completed: "/create/completed", completed: "/create/completed",
editRule: "/create/edit-rule", editRule: "/create/edit-rule",
+128 -19
View File
@@ -47,8 +47,44 @@ export const FIRST_STEP: CreateFlowStep = FLOW_STEP_ORDER[0];
/** Options for navigation when the email / magic-link save step is not shown (signed-in users). */ /** Options for navigation when the email / magic-link save step is not shown (signed-in users). */
export type CreateFlowNavigationOptions = { export type CreateFlowNavigationOptions = {
skipCommunitySave?: boolean; skipCommunitySave?: boolean;
/**
* Template **Use without changes**: collect community name, description, and
* optional photo only. Skips intro, structure, size, save, community review,
* and the custom-rule authoring segment.
*/
useWithoutChangesIdentityOnly?: boolean;
}; };
/**
* Community + custom-rule steps skipped when
* {@link CreateFlowNavigationOptions.useWithoutChangesIdentityOnly} is set.
* Remaining identity steps: `community-name` → `community-context` →
* `community-upload`, then `confirm-stakeholders`.
*/
const USE_WITHOUT_CHANGES_IDENTITY_SKIP = new Set<CreateFlowStep>([
"informational",
"community-structure",
"community-size",
"community-save",
"review",
"core-values",
"communication-methods",
"membership-methods",
"decision-approaches",
"conflict-management",
]);
function shouldSkipStep(
step: CreateFlowStep,
options?: CreateFlowNavigationOptions,
): boolean {
if (options?.skipCommunitySave && step === "community-save") return true;
return Boolean(
options?.useWithoutChangesIdentityOnly &&
USE_WITHOUT_CHANGES_IDENTITY_SKIP.has(step),
);
}
/** /**
* Returns the next step in the flow, or null if current is last/invalid * Returns the next step in the flow, or null if current is last/invalid
*/ */
@@ -57,13 +93,14 @@ export function getNextStep(
options?: CreateFlowNavigationOptions, options?: CreateFlowNavigationOptions,
): CreateFlowStep | null { ): CreateFlowStep | null {
if (!currentStep) return null; if (!currentStep) return null;
const index = FLOW_STEP_ORDER.indexOf(currentStep); let index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index === -1 || index === FLOW_STEP_ORDER.length - 1) return null; if (index === -1) return null;
const next = FLOW_STEP_ORDER[index + 1] as CreateFlowStep; while (index < FLOW_STEP_ORDER.length - 1) {
if (options?.skipCommunitySave && next === "community-save") { index += 1;
return getNextStep("community-save", options); const next = FLOW_STEP_ORDER[index] as CreateFlowStep;
if (!shouldSkipStep(next, options)) return next;
} }
return next; return null;
} }
/** /**
@@ -74,20 +111,27 @@ export function getPreviousStep(
options?: CreateFlowNavigationOptions, options?: CreateFlowNavigationOptions,
): CreateFlowStep | null { ): CreateFlowStep | null {
if (!currentStep) return null; if (!currentStep) return null;
const index = FLOW_STEP_ORDER.indexOf(currentStep); let index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index <= 0) return null; if (index <= 0) return null;
const prev = FLOW_STEP_ORDER[index - 1] as CreateFlowStep; while (index > 0) {
if (options?.skipCommunitySave && prev === "community-save") { index -= 1;
return getPreviousStep("community-save", options); const prev = FLOW_STEP_ORDER[index] as CreateFlowStep;
if (!shouldSkipStep(prev, options)) return prev;
} }
return prev; return null;
} }
/** /**
* Where the create-flow footer Back action should go. Usually the previous * Where the create-flow footer Back action should go. Usually the previous
* step in {@link FLOW_STEP_ORDER}; when the user reached `confirm-stakeholders` * step in {@link FLOW_STEP_ORDER}.
* via template **Use without changes**, Back returns to template review instead *
* of `conflict-management` (that segment was skipped). * Template **Use without changes** exceptions:
* - Direct from a template (`useWithoutChangesIdentityOnly`): Back from
* `community-name` returns to template review; Back from
* `confirm-stakeholders` is `community-upload`.
* - In-flow (`?fromFlow=1`, community identity already collected): Back
* from `confirm-stakeholders` returns to template review instead of
* `conflict-management`.
*/ */
export type CreateFlowBackTarget = export type CreateFlowBackTarget =
| { kind: "step"; step: CreateFlowStep } | { kind: "step"; step: CreateFlowStep }
@@ -102,7 +146,18 @@ export function resolveCreateFlowBackTarget(
typeof templateReviewBackSlug === "string" typeof templateReviewBackSlug === "string"
? templateReviewBackSlug.trim() ? templateReviewBackSlug.trim()
: ""; : "";
if (currentStep === "confirm-stakeholders" && slug.length > 0) { if (
currentStep === "community-name" &&
options?.useWithoutChangesIdentityOnly &&
slug.length > 0
) {
return { kind: "templateReview", slug };
}
if (
currentStep === "confirm-stakeholders" &&
slug.length > 0 &&
!options?.useWithoutChangesIdentityOnly
) {
return { kind: "templateReview", slug }; return { kind: "templateReview", slug };
} }
const prev = getPreviousStep(currentStep, options); const prev = getPreviousStep(currentStep, options);
@@ -117,15 +172,37 @@ export function getStepIndex(step: CreateFlowStep | null | undefined): number {
return FLOW_STEP_ORDER.indexOf(step); return FLOW_STEP_ORDER.indexOf(step);
} }
/** First wizard step that offers Save & Exit (first Create Community select). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
/**
* Top-nav Save & Exit (vs Exit). Guests and signed-in users share this from
* `community-structure` onward and on `edit-rule`. On completed, guests still
* get Save & Exit (claim/login); signed-in users get Exit (already owned).
*
* @param sessionUser `null` is a guest. `undefined` (session in flight) is
* not a guest — completed stays Exit until the session resolves.
*/
export function shouldOfferCreateFlowSaveAndExit(
currentStep: CreateFlowStep | null | undefined,
sessionUser?: { id: string } | null,
): boolean {
if (currentStep == null) return false;
if (currentStep === "completed") return sessionUser === null;
if (currentStep === "edit-rule") return true;
return getStepIndex(currentStep) >= SAVE_EXIT_FROM_STEP_INDEX;
}
/** /**
* Steps where below `lg` the main column scrolls with split layout * Steps where below `lg` the main column scrolls with split layout
* (`CreateFlowLayoutClient` — Linear CR-92 §4). * (`CreateFlowLayoutClient`).
*/ */
export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [ export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [
"community-size", "community-size",
"community-structure", "community-structure",
"core-values", "core-values",
"decision-approaches", "decision-approaches",
"confirm-stakeholders",
] as const; ] as const;
export function createFlowStepUsesSelectSplitScroll( export function createFlowStepUsesSelectSplitScroll(
@@ -158,21 +235,53 @@ export function parseCreateFlowScreenFromPathname(
): CreateFlowStep | null { ): CreateFlowStep | null {
if (!pathname || pathname.length === 0) return null; if (!pathname || pathname.length === 0) return null;
if (pathname.includes("/create/review-template/")) return null; if (pathname.includes("/create/review-template/")) return null;
if (isCreateFlowTemplatesPickerPath(pathname)) return null;
const parts = pathname.split("/").filter(Boolean); const parts = pathname.split("/").filter(Boolean);
const createIdx = parts.indexOf("create"); const createIdx = parts.indexOf("create");
if (createIdx === -1 || createIdx >= parts.length - 1) return null; if (createIdx === -1 || createIdx >= parts.length - 1) return null;
const segment = parts[createIdx + 1]; const segment = parts[createIdx + 1];
if (segment === "review-template") return null; if (segment === "review-template" || segment === "templates") return null;
return isValidStep(segment) ? segment : 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_QUERY = "fromFlow" as const;
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const; export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
/**
* Catalog / marketing template preview (`/create/review-template/[slug]`
* without `?fromFlow=1`). There is nothing to save yet; top-nav Exit should
* leave immediately. In-flow picks keep the usual save / leave-confirm path.
*/
export function isDirectTemplateReviewEntry(
pathname: string | null | undefined,
searchParams?: { get: (name: string) => string | null } | null,
): boolean {
if (!pathname?.includes("/create/review-template/")) return false;
return (
searchParams?.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) !==
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE
);
}
/** /**
* Only set from `/create/review` “Create from template” with `fromFlow=1`. * Only set from `/create/review` “Create from template” with `fromFlow=1`.
* Enables facet-ranked `GET /api/templates` + “RECOMMENDED” on the grid; omit * Enables facet-ranked `GET /api/templates` + “RECOMMENDED” on the grid; omit
@@ -181,7 +290,7 @@ export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
export const TEMPLATES_FACET_RECOMMEND_QUERY = "recommendTemplates" as const; export const TEMPLATES_FACET_RECOMMEND_QUERY = "recommendTemplates" as const;
export const TEMPLATES_FACET_RECOMMEND_VALUE = "1" as const; export const TEMPLATES_FACET_RECOMMEND_VALUE = "1" as const;
/** `/create/completed?celebrate=1` — post-finalize toast; set only after **initial** POST publish, not PATCH updates. */ /** `/create/completed?celebrate=1` — post-publish toast; set only after **initial** POST publish, not PATCH updates. */
export const CREATE_FLOW_COMPLETED_CELEBRATE_QUERY = "celebrate" as const; export const CREATE_FLOW_COMPLETED_CELEBRATE_QUERY = "celebrate" as const;
export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const; export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const;
+16 -1
View File
@@ -1,8 +1,16 @@
import type { Metadata } from "next";
import { Suspense, type ReactNode } from "react"; import { Suspense, type ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation"; import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
import SkipToContent from "../components/navigation/SkipToContent";
import { MessagesProvider } from "../contexts/MessagesContext"; import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext"; import { AuthModalProvider } from "../contexts/AuthModalContext";
import messages from "../../messages/en/index"; import messages from "../../messages/en/index";
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
export const metadata: Metadata = {
robots: NO_INDEX_ROBOTS,
};
// `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16). // `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16).
// `ConditionalNavigation` reads `cr_session` server-side (and `usePathname()` // `ConditionalNavigation` reads `cr_session` server-side (and `usePathname()`
@@ -21,10 +29,17 @@ export default function AppLayout({ children }: { children: ReactNode }) {
return ( return (
<MessagesProvider messages={messages}> <MessagesProvider messages={messages}>
<AuthModalProvider> <AuthModalProvider>
<SkipToContent />
<Suspense fallback={null}> <Suspense fallback={null}>
<ConditionalNavigation /> <ConditionalNavigation />
</Suspense> </Suspense>
<main className="flex-1">{children}</main> <main
id={MAIN_CONTENT_ID}
tabIndex={-1}
className="flex-1 outline-none"
>
{children}
</main>
</AuthModalProvider> </AuthModalProvider>
</MessagesProvider> </MessagesProvider>
); );
+6 -4
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
export const metadata: Metadata = { export const metadata: Metadata = routeMetadata("/login", {
title: "Log in · CommunityRule", title: messages.metadata.login.title,
robots: { index: false, follow: false }, robots: NO_INDEX_ROBOTS,
}; });
export default function LoginLayout({ export default function LoginLayout({
children, children,
+6 -4
View File
@@ -1,10 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
import ProfilePageClient from "./ProfilePageClient"; import ProfilePageClient from "./ProfilePageClient";
export const metadata: Metadata = { export const metadata: Metadata = routeMetadata("/profile", {
title: "Profile · CommunityRule", title: messages.metadata.profile.title,
robots: { index: false, follow: false }, robots: NO_INDEX_ROBOTS,
}; });
export default function ProfilePage() { export default function ProfilePage() {
return <ProfilePageClient />; return <ProfilePageClient />;
+14 -1
View File
@@ -1,8 +1,15 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { MessagesProvider } from "../contexts/MessagesContext"; import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext"; import { AuthModalProvider } from "../contexts/AuthModalContext";
import messages from "../../messages/en/index"; import messages from "../../messages/en/index";
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
export const metadata: Metadata = {
robots: NO_INDEX_ROBOTS,
};
// Development-only previews (e.g. `/components-preview`) — no public chrome. // Development-only previews (e.g. `/components-preview`) — no public chrome.
export default function DevLayout({ children }: { children: ReactNode }) { export default function DevLayout({ children }: { children: ReactNode }) {
@@ -12,7 +19,13 @@ export default function DevLayout({ children }: { children: ReactNode }) {
return ( return (
<MessagesProvider messages={messages}> <MessagesProvider messages={messages}>
<AuthModalProvider> <AuthModalProvider>
<main className="flex-1">{children}</main> <main
id={MAIN_CONTENT_ID}
tabIndex={-1}
className="flex-1 outline-none"
>
{children}
</main>
</AuthModalProvider> </AuthModalProvider>
</MessagesProvider> </MessagesProvider>
); );
+8 -9
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { import {
ASSETS, ASSETS,
@@ -6,6 +7,7 @@ import {
structureBeforeCrisisPath, structureBeforeCrisisPath,
} from "../../../lib/assetUtils"; } from "../../../lib/assetUtils";
import { getTranslation } from "../../../lib/i18n/getTranslation"; import { getTranslation } from "../../../lib/i18n/getTranslation";
import { routeMetadata } from "../../../lib/siteMetadata";
import AboutHeader from "../../components/type/AboutHeader"; import AboutHeader from "../../components/type/AboutHeader";
import type { AboutHeaderSegment } from "../../components/type/AboutHeader"; import type { AboutHeaderSegment } from "../../components/type/AboutHeader";
import Stats from "../../components/sections/Stats"; import Stats from "../../components/sections/Stats";
@@ -18,6 +20,10 @@ import type { FaqAccordionItem } from "../../components/sections/Accordion";
import QuoteBlock from "../../components/sections/QuoteBlock"; import QuoteBlock from "../../components/sections/QuoteBlock";
import AskOrganizer from "../../components/sections/AskOrganizer"; import AskOrganizer from "../../components/sections/AskOrganizer";
export const metadata: Metadata = routeMetadata("/about", {
title: messages.metadata.about.title,
});
function asArray<T>(value: unknown): T[] { function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value : []; return Array.isArray(value) ? value : [];
} }
@@ -30,11 +36,6 @@ export default function AboutPage() {
const headerSegments = asArray<AboutHeaderSegment>(page.aboutHeader.segments); const headerSegments = asArray<AboutHeaderSegment>(page.aboutHeader.segments);
const statsItems = asArray<StatItem>(page.stats.items); const statsItems = asArray<StatItem>(page.stats.items);
const statsAsOf =
typeof page.stats.asOf === "string"
? page.stats.asOf
: String(page.stats.asOf ?? "");
const faqItems = asArray<FaqAccordionItem>(page.faq.items); const faqItems = asArray<FaqAccordionItem>(page.faq.items);
const tripleColumns = asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns); const tripleColumns = asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns);
@@ -51,10 +52,8 @@ export default function AboutPage() {
titlePrefix={page.stats.titlePrefix} titlePrefix={page.stats.titlePrefix}
titleEmphasis={page.stats.titleEmphasis} titleEmphasis={page.stats.titleEmphasis}
titleSuffix={page.stats.titleSuffix} titleSuffix={page.stats.titleSuffix}
items={statsItems.map((item) => ({ asOfPrefix={page.stats.asOfPrefix}
...item, items={statsItems}
asOf: statsAsOf,
}))}
/> />
<TripleTextBlock columns={tripleColumns} /> <TripleTextBlock columns={tripleColumns} />
<Book <Book
+12 -10
View File
@@ -8,6 +8,8 @@ import {
getRelatedBlogPosts, getRelatedBlogPosts,
} from "../../../../lib/content"; } from "../../../../lib/content";
import { logger } from "../../../../lib/logger"; import { logger } from "../../../../lib/logger";
import { routeMetadata } from "../../../../lib/siteMetadata";
import messages from "../../../../messages/en/index";
import ContentBanner from "../../../components/sections/ContentBanner"; import ContentBanner from "../../../components/sections/ContentBanner";
import AskOrganizer from "../../../components/sections/AskOrganizer"; import AskOrganizer from "../../../components/sections/AskOrganizer";
import ContentTemplateDecorativeShapes from "../../_components/ContentTemplateDecorativeShapes"; import ContentTemplateDecorativeShapes from "../../_components/ContentTemplateDecorativeShapes";
@@ -62,13 +64,13 @@ export async function generateMetadata({
const post = getBlogPostBySlug(slug); const post = getBlogPostBySlug(slug);
if (!post) { if (!post) {
return { return routeMetadata(`/blog/${slug}`, {
title: "Post Not Found", title: messages.metadata.blog.notFoundTitle,
description: "The requested blog post could not be found.", description: messages.metadata.blog.fallbackDescription,
}; });
} }
return { return routeMetadata(`/blog/${slug}`, {
title: post.frontmatter.title, title: post.frontmatter.title,
description: post.frontmatter.description, description: post.frontmatter.description,
authors: [{ name: post.frontmatter.author }], authors: [{ name: post.frontmatter.author }],
@@ -78,8 +80,8 @@ export async function generateMetadata({
type: "article", type: "article",
publishedTime: post.frontmatter.date, publishedTime: post.frontmatter.date,
authors: [post.frontmatter.author], authors: [post.frontmatter.author],
url: `https://communityrule.com/blog/${slug}`, url: `/blog/${slug}`,
siteName: "CommunityRule", siteName: messages.metadata.siteName,
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
@@ -87,12 +89,12 @@ export async function generateMetadata({
description: post.frontmatter.description, description: post.frontmatter.description,
creator: "@communityrule", creator: "@communityrule",
}, },
}; });
} catch (error) { } catch (error) {
logger.error("Error generating metadata:", error); logger.error("Error generating metadata:", error);
return { return {
title: "Blog Post", title: messages.metadata.blog.fallbackTitle,
description: "A blog post from our community.", description: messages.metadata.blog.fallbackDescription,
}; };
} }
} }
+16 -16
View File
@@ -1,26 +1,27 @@
import { getAllBlogPosts } from "../../../lib/content"; import { getAllBlogPosts } from "../../../lib/content";
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate"; import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { routeMetadata } from "../../../lib/siteMetadata";
export const metadata: Metadata = { const blogMeta = messages.metadata.blog;
title: "Blog - CommunityRule",
description: export const metadata: Metadata = routeMetadata("/blog", {
"Learn about community governance, decision-making, and building successful organizations.", title: blogMeta.title,
description: blogMeta.description,
openGraph: { openGraph: {
title: "Blog - CommunityRule", title: blogMeta.title,
description: description: blogMeta.description,
"Learn about community governance, decision-making, and building successful organizations.", url: "/blog",
url: "https://communityrule.com/blog", siteName: messages.metadata.siteName,
siteName: "CommunityRule",
type: "website", type: "website",
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "Blog - CommunityRule", title: blogMeta.title,
description: description: blogMeta.description,
"Learn about community governance, decision-making, and building successful organizations.",
}, },
}; });
export default function BlogPage() { export default function BlogPage() {
const posts = getAllBlogPosts(); const posts = getAllBlogPosts();
@@ -34,11 +35,10 @@ export default function BlogPage() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12"> <div className="text-center mb-12">
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-content-default-primary)] mb-4"> <h1 className="text-4xl md:text-5xl font-bold text-[var(--color-content-default-primary)] mb-4">
Blog {blogMeta.title}
</h1> </h1>
<p className="text-lg text-[var(--color-content-default-secondary)] max-w-2xl mx-auto"> <p className="text-lg text-[var(--color-content-default-secondary)] max-w-2xl mx-auto">
Learn about community governance, decision-making, and building {blogMeta.description}
successful organizations.
</p> </p>
</div> </div>
+5 -1
View File
@@ -5,6 +5,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { legalPathForSlug } from "../../../lib/legalSyntheticPost"; import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
import { routeMetadata } from "../../../lib/siteMetadata";
import LegalDocumentPage, { import LegalDocumentPage, {
legalPageMetadata, legalPageMetadata,
} from "../_components/LegalDocumentPage"; } from "../_components/LegalDocumentPage";
@@ -13,7 +14,10 @@ const SLUG = "cookies" as const;
const PATH = legalPathForSlug(SLUG); const PATH = legalPathForSlug(SLUG);
export function generateMetadata(): Metadata { export function generateMetadata(): Metadata {
return legalPageMetadata(messages.metadata.cookies, messages.pages.cookies); return routeMetadata(
PATH,
legalPageMetadata(messages.metadata.cookies, messages.pages.cookies),
);
} }
export default function CookiesPage() { export default function CookiesPage() {
+4 -3
View File
@@ -10,6 +10,7 @@ import {
buildHowItWorksSyntheticPost, buildHowItWorksSyntheticPost,
HOW_IT_WORKS_SENTINEL_SLUG, HOW_IT_WORKS_SENTINEL_SLUG,
} from "../../../lib/howItWorksSyntheticPost"; } from "../../../lib/howItWorksSyntheticPost";
import { routeMetadata } from "../../../lib/siteMetadata";
import ContentBanner from "../../components/sections/ContentBanner"; import ContentBanner from "../../components/sections/ContentBanner";
import ContentTemplateDecorativeShapes from "../_components/ContentTemplateDecorativeShapes"; import ContentTemplateDecorativeShapes from "../_components/ContentTemplateDecorativeShapes";
import AskOrganizer from "../../components/sections/AskOrganizer"; import AskOrganizer from "../../components/sections/AskOrganizer";
@@ -29,7 +30,7 @@ export async function generateMetadata(): Promise<Metadata> {
const meta = messages.metadata.howItWorks; const meta = messages.metadata.howItWorks;
const page = messages.pages.howItWorks; const page = messages.pages.howItWorks;
return { return routeMetadata("/how-it-works", {
title: meta.title, title: meta.title,
description: meta.description, description: meta.description,
keywords: meta.keywords, keywords: meta.keywords,
@@ -37,9 +38,9 @@ export async function generateMetadata(): Promise<Metadata> {
title: page.banner.title, title: page.banner.title,
description: page.banner.description, description: page.banner.description,
type: "website", type: "website",
siteName: "CommunityRule", siteName: messages.metadata.siteName,
}, },
}; });
} }
export default function HowItWorksPage() { export default function HowItWorksPage() {
+15 -6
View File
@@ -1,8 +1,10 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Suspense, type ReactNode } from "react"; import { Suspense, type ReactNode } from "react";
import MarketingNavigation from "../components/navigation/MarketingNavigation"; import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
import SkipToContent from "../components/navigation/SkipToContent";
import { MessagesProvider } from "../contexts/MessagesContext"; import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext"; import { AuthModalProvider } from "../contexts/AuthModalContext";
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
import marketingMessages from "../../messages/en/marketing"; import marketingMessages from "../../messages/en/marketing";
// Site footer is part of the public marketing chrome only — not rendered for // Site footer is part of the public marketing chrome only — not rendered for
@@ -20,14 +22,21 @@ export default function MarketingLayout({ children }: { children: ReactNode }) {
<MessagesProvider messages={marketingMessages}> <MessagesProvider messages={marketingMessages}>
<AuthModalProvider> <AuthModalProvider>
{/* {/*
* MarketingNavigation reads `usePathname()` to decide chromeless paths * Same session-aware shell as `(app)` / `(admin)`: `ConditionalNavigation`
* (uncached data under `cacheComponents`). Suspense lets the static * reads the cookie behind Suspense so the static page shell can prerender
* shell prerender; the nav streams in with the correct visibility. * while the header streams signed-in vs signed-out correctly.
*/} */}
<SkipToContent />
<Suspense fallback={null}> <Suspense fallback={null}>
<MarketingNavigation /> <ConditionalNavigation />
</Suspense> </Suspense>
<main className="flex-1">{children}</main> <main
id={MAIN_CONTENT_ID}
tabIndex={-1}
className="flex-1 outline-none"
>
{children}
</main>
<Footer /> <Footer />
</AuthModalProvider> </AuthModalProvider>
</MessagesProvider> </MessagesProvider>
+6
View File
@@ -1,10 +1,16 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { getTranslation } from "../../../lib/i18n/getTranslation"; import { getTranslation } from "../../../lib/i18n/getTranslation";
import { routeMetadata } from "../../../lib/siteMetadata";
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate"; import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
import ContentLockup from "../../components/type/ContentLockup"; import ContentLockup from "../../components/type/ContentLockup";
import AskOrganizer from "../../components/sections/AskOrganizer"; import AskOrganizer from "../../components/sections/AskOrganizer";
import { getAllBlogPosts } from "../../../lib/content"; import { getAllBlogPosts } from "../../../lib/content";
export const metadata: Metadata = routeMetadata("/learn", {
title: messages.metadata.learn.title,
});
export default function LearnPage() { export default function LearnPage() {
const allPosts = getAllBlogPosts(); const allPosts = getAllBlogPosts();
+4
View File
@@ -1,11 +1,15 @@
import type { Metadata } from "next";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Suspense } from "react"; import { Suspense } from "react";
import messages from "../../messages/en/index"; import messages from "../../messages/en/index";
import { getTranslation } from "../../lib/i18n/getTranslation"; import { getTranslation } from "../../lib/i18n/getTranslation";
import { routeMetadata } from "../../lib/siteMetadata";
import HeroBanner from "../components/sections/HeroBanner"; import HeroBanner from "../components/sections/HeroBanner";
import AskOrganizer from "../components/sections/AskOrganizer"; import AskOrganizer from "../components/sections/AskOrganizer";
import { MarketingRuleStackSection } from "./_components/MarketingRuleStackSection"; import { MarketingRuleStackSection } from "./_components/MarketingRuleStackSection";
export const metadata: Metadata = routeMetadata("/");
// Code split below-the-fold components to reduce initial bundle size // Code split below-the-fold components to reduce initial bundle size
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), { const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
loading: () => ( loading: () => (
+5 -1
View File
@@ -5,6 +5,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { legalPathForSlug } from "../../../lib/legalSyntheticPost"; import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
import { routeMetadata } from "../../../lib/siteMetadata";
import LegalDocumentPage, { import LegalDocumentPage, {
legalPageMetadata, legalPageMetadata,
} from "../_components/LegalDocumentPage"; } from "../_components/LegalDocumentPage";
@@ -13,7 +14,10 @@ const SLUG = "privacy" as const;
const PATH = legalPathForSlug(SLUG); const PATH = legalPathForSlug(SLUG);
export function generateMetadata(): Metadata { export function generateMetadata(): Metadata {
return legalPageMetadata(messages.metadata.privacy, messages.pages.privacy); return routeMetadata(
PATH,
legalPageMetadata(messages.metadata.privacy, messages.pages.privacy),
);
} }
export default function PrivacyPage() { export default function PrivacyPage() {
+5 -4
View File
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
import messages from "../../../../messages/en/index"; import messages from "../../../../messages/en/index";
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules"; import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections"; import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
import { routeMetadata } from "../../../../lib/siteMetadata";
import CommunityRule from "../../../components/type/CommunityRule"; import CommunityRule from "../../../components/type/CommunityRule";
import HeaderLockup from "../../../components/type/HeaderLockup"; import HeaderLockup from "../../../components/type/HeaderLockup";
@@ -25,22 +26,22 @@ export async function generateMetadata({
typeof rule.summary === "string" && rule.summary.trim().length > 0 typeof rule.summary === "string" && rule.summary.trim().length > 0
? rule.summary ? rule.summary
: undefined; : undefined;
return { return routeMetadata(`/rules/${rule.id}`, {
title: rule.title, title: rule.title,
description, description,
openGraph: { openGraph: {
title: rule.title, title: rule.title,
description, description,
type: "article", type: "article",
url: `https://communityrule.com/rules/${rule.id}`, url: `/rules/${rule.id}`,
siteName: "CommunityRule", siteName: messages.metadata.siteName,
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: rule.title, title: rule.title,
description, description,
}, },
}; });
} }
export default async function PublicRuleDetailPage({ params }: PageProps) { export default async function PublicRuleDetailPage({ params }: PageProps) {
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Suspense } from "react"; import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import HeaderLockup from "../../components/type/HeaderLockup"; import HeaderLockup from "../../components/type/HeaderLockup";
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid"; import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation"; import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
@@ -68,8 +68,8 @@ export default function TemplatesPageClient({
/** /**
* - `fromFlow=1` — skip `prepareFreshCreateFlowEntry` on template click * - `fromFlow=1` — skip `prepareFreshCreateFlowEntry` on template click
* (draft preserved). Used by review “Create from template” and profile. * (draft preserved). Used by profile “Create from template”.
* - `recommendTemplates=1` (with review only) — rank templates + “RECOMMENDED” * - `recommendTemplates=1` — rank templates + “RECOMMENDED”
* from `GET /api/templates?facet.*` using the persisted community draft. * from `GET /api/templates?facet.*` using the persisted community draft.
*/ */
function TemplatesGridWithSearchParams({ function TemplatesGridWithSearchParams({
@@ -96,17 +96,18 @@ function TemplatesGrid({
entries: TemplateGridCardEntry[]; entries: TemplateGridCardEntry[];
fromFlow: boolean; fromFlow: boolean;
}) { }) {
const router = useRouter();
return ( return (
<GovernanceTemplateGrid <GovernanceTemplateGrid
entries={entries} entries={entries}
onTemplateClick={(slug) => { hrefForTemplate={(slug) =>
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow })
}
onTemplateClick={() => {
if (!fromFlow) { if (!fromFlow) {
prepareFreshCreateFlowEntrySync(); // 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 }),
);
}} }}
/> />
); );
+7
View File
@@ -1,7 +1,14 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates"; import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
import { routeMetadata } from "../../../lib/siteMetadata";
import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation"; import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation";
import TemplatesPageClient from "./TemplatesPageClient"; import TemplatesPageClient from "./TemplatesPageClient";
export const metadata: Metadata = routeMetadata("/templates", {
title: messages.metadata.templates.title,
});
export default async function TemplatesPage() { export default async function TemplatesPage() {
const rows = await listRuleTemplatesFromDb(); const rows = await listRuleTemplatesFromDb();
const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows); const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows);
@@ -18,9 +18,10 @@ type UseTemplatesFacetGridEntriesArgs = {
}; };
/** /**
* When `enableFacetRecommendations` (review → “Create from template” only), * When `enableFacetRecommendations` (in-flow `/create/templates`, or marketing
* re-fetch ranked templates from `GET /api/templates?facet.*` using the * `/templates?recommendTemplates=1`), re-fetch ranked templates from
* persisted create-flow draft. Otherwise returns `initialGridEntries` from SSR. * `GET /api/templates?facet.*` using the persisted create-flow draft.
* Otherwise returns `initialGridEntries` from SSR.
*/ */
export function useTemplatesFacetGridEntries({ export function useTemplatesFacetGridEntries({
initialGridEntries, initialGridEntries,
+5 -1
View File
@@ -5,6 +5,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { legalPathForSlug } from "../../../lib/legalSyntheticPost"; import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
import { routeMetadata } from "../../../lib/siteMetadata";
import LegalDocumentPage, { import LegalDocumentPage, {
legalPageMetadata, legalPageMetadata,
} from "../_components/LegalDocumentPage"; } from "../_components/LegalDocumentPage";
@@ -13,7 +14,10 @@ const SLUG = "terms" as const;
const PATH = legalPathForSlug(SLUG); const PATH = legalPathForSlug(SLUG);
export function generateMetadata(): Metadata { export function generateMetadata(): Metadata {
return legalPageMetadata(messages.metadata.terms, messages.pages.terms); return routeMetadata(
PATH,
legalPageMetadata(messages.metadata.terms, messages.pages.terms),
);
} }
export default function TermsPage() { export default function TermsPage() {
+4 -3
View File
@@ -12,6 +12,7 @@ import {
USE_CASE_DETAIL_SLUGS, USE_CASE_DETAIL_SLUGS,
useCaseContentKeyForSlug, useCaseContentKeyForSlug,
} from "../../../../lib/useCaseSyntheticPost"; } from "../../../../lib/useCaseSyntheticPost";
import { routeMetadata } from "../../../../lib/siteMetadata";
import ContentBanner from "../../../components/sections/ContentBanner"; import ContentBanner from "../../../components/sections/ContentBanner";
import AskOrganizer from "../../../components/sections/AskOrganizer"; import AskOrganizer from "../../../components/sections/AskOrganizer";
import type { AskOrganizerVariant } from "../../../components/sections/AskOrganizer/AskOrganizer.types"; import type { AskOrganizerVariant } from "../../../components/sections/AskOrganizer/AskOrganizer.types";
@@ -34,7 +35,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const contentKey = useCaseContentKeyForSlug(slug); const contentKey = useCaseContentKeyForSlug(slug);
const meta = messages.metadata.useCasesDetail[contentKey]; const meta = messages.metadata.useCasesDetail[contentKey];
return { return routeMetadata(`/use-cases/${slug}`, {
title: meta.title, title: meta.title,
description: meta.description, description: meta.description,
keywords: meta.keywords, keywords: meta.keywords,
@@ -42,9 +43,9 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
title: meta.title, title: meta.title,
description: meta.description, description: meta.description,
type: "website", type: "website",
siteName: "CommunityRule", siteName: messages.metadata.siteName,
}, },
}; });
} }
export default async function UseCaseDetailPage({ params }: PageProps) { export default async function UseCaseDetailPage({ params }: PageProps) {
+4 -3
View File
@@ -5,6 +5,7 @@ import { Suspense } from "react";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { CONTENT_CATALOG_SLUG_ORDER } from "../../../lib/assetUtils"; import { CONTENT_CATALOG_SLUG_ORDER } from "../../../lib/assetUtils";
import { getAllBlogPosts, getRelatedBlogPosts } from "../../../lib/content"; import { getAllBlogPosts, getRelatedBlogPosts } from "../../../lib/content";
import { routeMetadata } from "../../../lib/siteMetadata";
import PageHeader from "../../components/type/PageHeader"; import PageHeader from "../../components/type/PageHeader";
import CaseStudy from "../../components/cards/CaseStudy"; import CaseStudy from "../../components/cards/CaseStudy";
import UseCasesOrgs from "../../components/sections/UseCasesOrgs"; import UseCasesOrgs from "../../components/sections/UseCasesOrgs";
@@ -60,7 +61,7 @@ export async function generateMetadata(): Promise<Metadata> {
const description = messages.metadata.useCases.description; const description = messages.metadata.useCases.description;
const keywords = messages.metadata.useCases.keywords; const keywords = messages.metadata.useCases.keywords;
return { return routeMetadata("/use-cases", {
title, title,
description, description,
keywords, keywords,
@@ -68,9 +69,9 @@ export async function generateMetadata(): Promise<Metadata> {
title, title,
description, description,
type: "website", type: "website",
siteName: "CommunityRule", siteName: messages.metadata.siteName,
}, },
}; });
} }
export default function UseCasesPage() { export default function UseCasesPage() {
+6 -1
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { MessagesProvider } from "../contexts/MessagesContext"; import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext"; import { AuthModalProvider } from "../contexts/AuthModalContext";
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
import marketingMessages from "../../messages/en/marketing"; import marketingMessages from "../../messages/en/marketing";
/** Full-viewport case-study surfaces (completed rule demos) — no marketing footer. */ /** Full-viewport case-study surfaces (completed rule demos) — no marketing footer. */
@@ -12,7 +13,11 @@ export default function MarketingCaseStudyLayout({
return ( return (
<MessagesProvider messages={marketingMessages}> <MessagesProvider messages={marketingMessages}>
<AuthModalProvider> <AuthModalProvider>
<main className="flex h-dvh min-h-0 flex-col overflow-hidden"> <main
id={MAIN_CONTENT_ID}
tabIndex={-1}
className="flex h-dvh min-h-0 flex-col overflow-hidden outline-none"
>
{children} {children}
</main> </main>
</AuthModalProvider> </AuthModalProvider>
@@ -34,7 +34,7 @@ export function useUseCaseCompletedRuleActions({
const [duplicateBusy, setDuplicateBusy] = useState(false); const [duplicateBusy, setDuplicateBusy] = useState(false);
const copyPageLink = useCallback(async () => { const copyPageLink = useCallback(async () => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return false;
try { try {
await navigator.clipboard.writeText(window.location.href); await navigator.clipboard.writeText(window.location.href);
setActionBanner({ setActionBanner({
@@ -43,6 +43,7 @@ export function useUseCaseCompletedRuleActions({
title: t("shareLinkCopiedTitle"), title: t("shareLinkCopiedTitle"),
description: t("shareLinkCopiedDescription"), description: t("shareLinkCopiedDescription"),
}); });
return true;
} catch { } catch {
setActionBanner({ setActionBanner({
key: "shareCopyFailed", key: "shareCopyFailed",
@@ -50,6 +51,7 @@ export function useUseCaseCompletedRuleActions({
title: t("shareCopyFailedTitle"), title: t("shareCopyFailedTitle"),
description: t("shareCopyFailedDescription"), description: t("shareCopyFailedDescription"),
}); });
return false;
} }
}, [setActionBanner, t]); }, [setActionBanner, t]);
@@ -6,6 +6,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import messages from "../../../../../messages/en/index"; import messages from "../../../../../messages/en/index";
import { resolveUseCaseCompletedRule } from "../../../../../lib/useCaseCompletedRule"; import { resolveUseCaseCompletedRule } from "../../../../../lib/useCaseCompletedRule";
import { routeMetadata } from "../../../../../lib/siteMetadata";
import { import {
USE_CASE_DETAIL_SLUGS, USE_CASE_DETAIL_SLUGS,
useCaseContentKeyForSlug, useCaseContentKeyForSlug,
@@ -33,7 +34,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const contentKey = useCaseContentKeyForSlug(resolved.slug); const contentKey = useCaseContentKeyForSlug(resolved.slug);
const meta = messages.metadata.useCasesCompletedRule[contentKey]; const meta = messages.metadata.useCasesCompletedRule[contentKey];
return { return routeMetadata(`/use-cases/${resolved.slug}/rule`, {
title: meta.title, title: meta.title,
description: meta.description, description: meta.description,
keywords: meta.keywords, keywords: meta.keywords,
@@ -41,9 +42,9 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
title: meta.title, title: meta.title,
description: meta.description, description: meta.description,
type: "website", type: "website",
siteName: "CommunityRule", siteName: messages.metadata.siteName,
}, },
}; });
} }
export default async function UseCaseCompletedRulePage({ params }: PageProps) { export default async function UseCaseCompletedRulePage({ params }: PageProps) {
+52 -7
View File
@@ -14,13 +14,40 @@ import { saveCreateFlowUpload } from "../../../lib/server/uploads/saveCreateFlow
import { getUploadRootFromEnv } from "../../../lib/server/uploads/uploadRoot"; import { getUploadRootFromEnv } from "../../../lib/server/uploads/uploadRoot";
import { import {
CREATE_FLOW_UPLOAD_MAX_BYTES, CREATE_FLOW_UPLOAD_MAX_BYTES,
maxBytesForPurpose,
type CreateFlowUploadPurpose, type CreateFlowUploadPurpose,
} from "../../../lib/server/uploads/uploadConstants"; } from "../../../lib/server/uploads/uploadConstants";
import type { CreateFlowUploadValidationReason } from "../../../lib/create/createFlowUploadValidation";
function asUploadedBlob(value: FormDataEntryValue | null): Blob | null {
if (typeof value !== "object" || value === null) return null;
const candidate = value as Partial<Blob>;
if (typeof candidate.arrayBuffer !== "function") return null;
if (typeof candidate.size !== "number") return null;
return value as Blob;
}
function isPurpose(x: string): x is CreateFlowUploadPurpose { function isPurpose(x: string): x is CreateFlowUploadPurpose {
return x === "communityAvatar" || x === "customMethodAttachment"; return x === "communityAvatar" || x === "customMethodAttachment";
} }
function messageForValidationReason(
reason: CreateFlowUploadValidationReason,
): string {
switch (reason) {
case "empty":
return "File is empty.";
case "tooLarge":
return "File exceeds the maximum allowed size for this upload purpose.";
case "svg":
return "SVG uploads are not allowed.";
case "undecodable":
return "File could not be decoded as a valid image.";
case "invalidType":
return "File type is not allowed for this upload purpose.";
}
}
export const POST = apiRoute("uploads.post", async (request: NextRequest) => { export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
if (!isDatabaseConfigured()) { if (!isDatabaseConfigured()) {
return dbUnavailable(); return dbUnavailable();
@@ -54,7 +81,7 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
} }
const purposeRaw = formData.get("purpose"); const purposeRaw = formData.get("purpose");
const file = formData.get("file"); const file = asUploadedBlob(formData.get("file"));
if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) { if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) {
return errorJson( return errorJson(
@@ -64,7 +91,7 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
); );
} }
if (!(file instanceof File)) { if (!file) {
return errorJson( return errorJson(
"validation_error", "validation_error",
"Missing `file` field (multipart file).", "Missing `file` field (multipart file).",
@@ -72,21 +99,29 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
); );
} }
if (file.size > CREATE_FLOW_UPLOAD_MAX_BYTES) { if (file.size === 0) {
return errorJson("validation_error", messageForValidationReason("empty"), 400, {
details: { reason: "empty" },
});
}
if (
file.size > CREATE_FLOW_UPLOAD_MAX_BYTES ||
file.size > maxBytesForPurpose(purposeRaw)
) {
return errorJson( return errorJson(
"payload_too_large", "payload_too_large",
`File exceeds maximum allowed size (${CREATE_FLOW_UPLOAD_MAX_BYTES} bytes).`, messageForValidationReason("tooLarge"),
413, 413,
{ details: { reason: "tooLarge" } },
); );
} }
const buf = Buffer.from(await file.arrayBuffer()); const buf = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || "application/octet-stream";
const saved = await saveCreateFlowUpload({ const saved = await saveCreateFlowUpload({
purpose: purposeRaw, purpose: purposeRaw,
buffer: buf, buffer: buf,
mimeType,
}); });
if ("error" in saved) { if ("error" in saved) {
@@ -95,10 +130,20 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
"File uploads are not configured (UPLOAD_ROOT is unset).", "File uploads are not configured (UPLOAD_ROOT is unset).",
); );
} }
const reason = saved.reason ?? "invalidType";
if (reason === "tooLarge") {
return errorJson(
"payload_too_large",
messageForValidationReason("tooLarge"),
413,
{ details: { reason } },
);
}
return errorJson( return errorJson(
"validation_error", "validation_error",
"File type or size is not allowed for this upload purpose.", messageForValidationReason(reason),
400, 400,
{ details: { reason } },
); );
} }
+2 -2
View File
@@ -93,9 +93,9 @@ const Logo = memo<LogoProps>(
? "text-[var(--color-content-invert-primary)]" ? "text-[var(--color-content-invert-primary)]"
: "text-[var(--color-content-default-primary)]"; : "text-[var(--color-content-default-primary)]";
const wordmarkVisibilityClass = const wordmarkVisibilityClass =
size === "topNavFolderTop" || size === "topNavHeader" size === "topNavHeader"
? wordmark ? wordmark
? "hidden sm:block" ? "hidden md:block"
: "hidden" : "hidden"
: wordmark : wordmark
? "" ? ""
+3 -1
View File
@@ -35,6 +35,7 @@ const RuleContainer = memo<RuleProps>(
backgroundColor = "bg-[var(--color-community-teal-100)]", backgroundColor = "bg-[var(--color-community-teal-100)]",
className = "", className = "",
onClick, onClick,
href,
expanded = false, expanded = false,
size: sizeProp, size: sizeProp,
categories, categories,
@@ -95,8 +96,9 @@ const RuleContainer = memo<RuleProps>(
icon={icon} icon={icon}
backgroundColor={backgroundColor} backgroundColor={backgroundColor}
className={className} className={className}
href={hasBottomLinks ? undefined : href}
onClick={hasBottomLinks ? undefined : handleClick} onClick={hasBottomLinks ? undefined : handleClick}
onKeyDown={hasBottomLinks ? undefined : handleKeyDown} onKeyDown={hasBottomLinks || href ? undefined : handleKeyDown}
expanded={expanded} expanded={expanded}
size={size} size={size}
categories={categories} categories={categories}
+6
View File
@@ -50,6 +50,11 @@ export interface RuleProps {
backgroundColor?: string; backgroundColor?: string;
className?: string; className?: string;
onClick?: () => void; 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; expanded?: boolean;
size?: RuleSizeValue; size?: RuleSizeValue;
categories?: Category[]; categories?: Category[];
@@ -93,6 +98,7 @@ export interface RuleViewProps {
backgroundColor: string; backgroundColor: string;
className: string; className: string;
onClick?: () => void; onClick?: () => void;
href?: string;
onKeyDown?: (_event: React.KeyboardEvent<HTMLDivElement>) => void; onKeyDown?: (_event: React.KeyboardEvent<HTMLDivElement>) => void;
expanded: boolean; expanded: boolean;
size: RuleSizeValue; size: RuleSizeValue;
+34 -10
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import NextLink from "next/link";
import MultiSelect from "../../controls/MultiSelect"; import MultiSelect from "../../controls/MultiSelect";
import InlineTextButton from "../../buttons/InlineTextButton"; import InlineTextButton from "../../buttons/InlineTextButton";
import NavigationLink from "../../navigation/Link"; import NavigationLink from "../../navigation/Link";
@@ -19,6 +20,7 @@ export function RuleView({
backgroundColor, backgroundColor,
className, className,
onClick, onClick,
href,
onKeyDown, onKeyDown,
expanded, expanded,
size, size,
@@ -264,16 +266,11 @@ export function RuleView({
); );
} }
return ( 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 || ""}`;
<div const isLinkCard = Boolean(href) && interactiveCard;
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} const cardBody = (
role={interactiveCard ? "button" : "article"} <>
aria-label={ariaLabel}
aria-expanded={interactiveCard ? expanded : undefined}
onClick={interactiveCard ? onClick : undefined}
onKeyDown={interactiveCard ? onKeyDown : undefined}
>
{/* Figma: Header = `border-b` row, `gap-px`, icon `pl-1 pr-2 py-2` + `border-l` on title. */} {/* Figma: Header = `border-b` row, `gap-px`, icon `pl-1 pr-2 py-2` + `border-l` on title. */}
<div <div
className=" className="
@@ -483,6 +480,33 @@ export function RuleView({
</div> </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> </div>
); );
} }
+3
View File
@@ -4,6 +4,9 @@ export interface StatProps {
value: string; value: string;
label: string; label: string;
asOf?: string; asOf?: string;
asOfPrefix?: string;
sourceHref?: string;
sourceName?: string;
shapeVariant?: StatShapeVariant; shapeVariant?: StatShapeVariant;
className?: string; className?: string;
} }
+22 -1
View File
@@ -11,9 +11,14 @@ function StatView({
value, value,
label, label,
asOf, asOf,
asOfPrefix,
sourceHref,
sourceName,
shapeVariant, shapeVariant,
className = "", className = "",
}: StatViewProps) { }: StatViewProps) {
const sourcedYear = Boolean(asOfPrefix || sourceHref);
return ( return (
<article <article
className={`relative flex h-auto min-h-[182px] w-full flex-col items-start justify-between rounded-[var(--radius-measures-radius-xlarge,20px)] bg-[var(--color-surface-invert-primary,white)] px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)] sm:h-[170px] sm:min-h-0 sm:p-[var(--spacing-scale-024)] ${className}`.trim()} className={`relative flex h-auto min-h-[182px] w-full flex-col items-start justify-between rounded-[var(--radius-measures-radius-xlarge,20px)] bg-[var(--color-surface-invert-primary,white)] px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)] sm:h-[170px] sm:min-h-0 sm:p-[var(--spacing-scale-024)] ${className}`.trim()}
@@ -34,7 +39,23 @@ function StatView({
</div> </div>
{asOf ? ( {asOf ? (
<p className="w-full text-xx-small-paragraph text-[var(--color-content-invert-tertiary,#2d2d2d)]"> <p className="w-full text-xx-small-paragraph text-[var(--color-content-invert-tertiary,#2d2d2d)]">
{asOf} {sourcedYear ? (
<>
{asOfPrefix ? <span>{`${asOfPrefix} `}</span> : null}
{sourceHref ? (
<a href={sourceHref} className="underline">
{asOf}
{sourceName ? (
<span className="sr-only">{` (${sourceName})`}</span>
) : null}
</a>
) : (
<span className="underline">{asOf}</span>
)}
</>
) : (
asOf
)}
</p> </p>
) : null} ) : null}
</article> </article>
@@ -16,7 +16,7 @@ import type { ContentContainerProps } from "./ContentContainer.types";
const ContentContainerContainer = memo<ContentContainerProps>( const ContentContainerContainer = memo<ContentContainerProps>(
({ ({
post, post,
width = "200px", width: widthProp,
size: sizeProp = "responsive", size: sizeProp = "responsive",
tone: toneProp = "inverse", tone: toneProp = "inverse",
leadingImageSrc, leadingImageSrc,
@@ -26,6 +26,8 @@ const ContentContainerContainer = memo<ContentContainerProps>(
const size = sizeProp; const size = sizeProp;
const tone = toneProp; const tone = toneProp;
const showLeadingImage = showLeadingImageProp; const showLeadingImage = showLeadingImageProp;
const width =
widthProp ?? (size === "useCase" ? "100%" : "200px");
const onLight = tone === "onLight"; const onLight = tone === "onLight";
const titleColor = onLight const titleColor = onLight
? "text-[var(--color-content-default-primary)] group-hover:text-[var(--color-content-default-brand-primary)]" ? "text-[var(--color-content-default-primary)] group-hover:text-[var(--color-content-default-brand-primary)]"
@@ -57,40 +59,56 @@ const ContentContainerContainer = memo<ContentContainerProps>(
}, },
); );
const isUseCase = size === "useCase";
const containerClasses = const containerClasses =
size === "xs" size === "xs"
? "relative z-20 flex h-full flex-col gap-[var(--measures-spacing-012)]" ? "relative z-20 flex h-full flex-col gap-[var(--measures-spacing-012)]"
: "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]"; : isUseCase
? "relative z-20 flex h-full w-full min-w-0 flex-col gap-[var(--measures-spacing-024)]"
: "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]";
const contentGapClasses = const contentGapClasses =
size === "xs" size === "xs"
? "flex flex-col gap-[var(--measures-spacing-008)]" ? "flex flex-col gap-[var(--measures-spacing-008)]"
: "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]"; : isUseCase
? "flex w-full min-w-0 flex-col gap-[var(--measures-spacing-016)]"
: "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]";
const textGapClasses = const textGapClasses =
size === "xs" size === "xs"
? "flex flex-col gap-[var(--measures-spacing-004)]" ? "flex flex-col gap-[var(--measures-spacing-004)]"
: "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]"; : isUseCase
? "flex w-full min-w-0 flex-col gap-[var(--measures-spacing-004)]"
: "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]";
const titleClasses = const titleClasses =
size === "xs" size === "xs"
? `font-bricolage-grotesque font-medium text-[18px] leading-[22px] transition-colors ${titleColor}` ? `font-bricolage-grotesque font-medium text-[18px] leading-[22px] transition-colors ${titleColor}`
: `font-bricolage-grotesque font-medium text-xx-small-display sm:text-x-small-display md:text-[32px] md:leading-[110%] lg:text-medium-display xl:text-x-large-display transition-colors ${titleColor}`; : isUseCase
? `w-full font-bricolage-grotesque font-medium text-[32px] leading-[110%] lg:text-medium-display transition-colors ${titleColor}`
: `font-bricolage-grotesque font-medium text-xx-small-display sm:text-x-small-display md:text-[32px] md:leading-[110%] lg:text-medium-display xl:text-x-large-display transition-colors ${titleColor}`;
const descriptionClasses = const descriptionClasses =
size === "xs" size === "xs"
? `text-x-small-paragraph max-w-md ${bodyColor}` ? `text-x-small-paragraph max-w-md ${bodyColor}`
: `text-x-small-paragraph sm:text-small-paragraph md:text-small-paragraph lg:text-large-paragraph xl:text-x-large-paragraph ${bodyColor}`; : isUseCase
? `w-full text-small-paragraph lg:text-large-paragraph ${bodyColor}`
: `text-x-small-paragraph sm:text-small-paragraph md:text-small-paragraph lg:text-large-paragraph xl:text-x-large-paragraph ${bodyColor}`;
const authorClasses = const authorClasses =
size === "xs" size === "xs"
? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-paragraph ${bodyColor}` ? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-paragraph ${bodyColor}`
: `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`; : isUseCase
? `overflow-hidden text-ellipsis whitespace-nowrap text-x-small-paragraph lg:text-small-paragraph ${bodyColor}`
: `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`;
const dateClasses = const dateClasses =
size === "xs" size === "xs"
? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-paragraph ${bodyColor}` ? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-paragraph ${bodyColor}`
: `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`; : isUseCase
? `overflow-hidden text-ellipsis whitespace-nowrap text-x-small-paragraph lg:text-small-paragraph ${bodyColor}`
: `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`;
return ( return (
<ContentContainerView <ContentContainerView
@@ -1,6 +1,5 @@
import type { BlogPost } from "../../../../lib/content"; import type { BlogPost } from "../../../../lib/content";
import type { ContentContainerSizeValue } from "../../../../lib/propNormalization";
export type ContentContainerSizeValue = "xs" | "responsive";
/** `inverse` — blog hero on imagery; `onLight` — marketing pages on default surface. */ /** `inverse` — blog hero on imagery; `onLight` — marketing pages on default surface. */
export type ContentContainerToneValue = "inverse" | "onLight"; export type ContentContainerToneValue = "inverse" | "onLight";
@@ -9,7 +8,8 @@ export interface ContentContainerProps {
post: BlogPost; post: BlogPost;
width?: string; width?: string;
/** /**
* Content container size. * `xs` — catalog thumbnail. `responsive` — article banner (scales through xl).
* `useCase` — case-study ContentBanner lockup (Figma 365px / medium-display).
*/ */
size?: ContentContainerSizeValue; size?: ContentContainerSizeValue;
/** /**
@@ -27,7 +27,7 @@ export interface ContentContainerProps {
export interface ContentContainerViewProps { export interface ContentContainerViewProps {
post: BlogPost; post: BlogPost;
width: string; width: string;
size: "xs" | "responsive"; size: ContentContainerSizeValue;
tone: ContentContainerToneValue; tone: ContentContainerToneValue;
iconImage: string; iconImage: string;
iconAlt: string; iconAlt: string;
+3 -4
View File
@@ -29,10 +29,9 @@ export interface ChipProps {
className?: string; className?: string;
/** /**
* Whether the chip should be non-interactive. Defaults to `true` when * Whether the chip should be non-interactive. Defaults to `true` when
* `state === "disabled"` to preserve historical behavior. Pass * `state === "disabled"`. Toggle groups use `selected` / `unselected`
* `disabled={false}` alongside `state="disabled"` to render the dimmed * (Chip sets `aria-pressed`); pass `disabled` only when the chip cannot
* "disabled" visual while keeping the chip clickable — useful for toggle * be activated.
* groups where the unselected state is the disabled visual.
*/ */
disabled?: boolean; disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void; onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
+110 -70
View File
@@ -1,8 +1,81 @@
"use client"; "use client";
import { memo } from "react"; import { memo, type CSSProperties } from "react";
import type { ChipViewProps } from "./Chip.types"; import type { ChipViewProps } from "./Chip.types";
function chipSurfaceStyle(
state: ChipViewProps["state"],
palette: ChipViewProps["palette"],
size: ChipViewProps["size"],
): CSSProperties {
const borderWidth = size === "s" ? "1.25px" : "2px";
const reset: CSSProperties = {
appearance: "none",
WebkitAppearance: "none",
backgroundImage: "none",
};
const paint = (
backgroundColor: string,
color: string,
borderColor?: string,
): CSSProperties => ({
...reset,
backgroundColor,
color,
WebkitTextFillColor: color,
...(borderColor
? { borderWidth, borderStyle: "solid", borderColor }
: { borderWidth: 0, borderStyle: "none", borderColor: "transparent" }),
});
if (palette === "inverse") {
if (state === "disabled") {
return paint(
"var(--color-surface-inverse-tertiary)",
"var(--color-content-inverse-primary)",
);
}
if (state === "selected") {
return paint(
"var(--color-surface-default-semi-opaque)",
"var(--color-content-inverse-primary)",
"var(--color-border-default-primary)",
);
}
return paint(
"transparent",
"var(--color-content-inverse-primary)",
"var(--color-border-default-primary)",
);
}
if (state === "custom") {
return paint(
"var(--color-surface-default-secondary)",
"var(--color-content-default-tertiary)",
);
}
if (state === "disabled") {
return paint(
"var(--color-surface-default-secondary)",
"var(--color-content-invert-tertiary)",
);
}
if (state === "selected") {
return paint(
"var(--color-surface-invert-brand-primary, #fefcc9)",
"var(--color-content-invert-primary, #000)",
"var(--color-border-default-brand-primary, #fdfaa8)",
);
}
return paint(
"transparent",
"var(--color-content-default-brand-primary, #fefcc9)",
"var(--color-border-default-tertiary, #464646)",
);
}
function ChipView({ function ChipView({
label, label,
state, state,
@@ -23,17 +96,15 @@ function ChipView({
typeToAddPlaceholder, typeToAddPlaceholder,
closeAriaLabel, closeAriaLabel,
}: ChipViewProps) { }: ChipViewProps) {
// The container is the source of truth for `disabled`. This allows // The container is the source of truth for `disabled`. `state="disabled"`
// `state="disabled"` to be used purely as a visual (for toggle-group chips // is the non-interactive visual only — toggle groups use selected/unselected.
// that look dimmed while remaining clickable) by passing `disabled={false}`.
const isDisabled = disabled ?? false; const isDisabled = disabled ?? false;
const isSelected = state === "selected"; const isSelected = state === "selected";
const isCustom = state === "custom"; const isCustom = state === "custom";
const isToggle = !isCustom;
const isInverse = palette === "inverse";
const isDefault = palette === "default";
const isSmall = size === "s"; const isSmall = size === "s";
const surfaceStyle = chipSurfaceStyle(state, palette, size);
// Size-based styles from Figma tokens // Size-based styles from Figma tokens
// Custom state has different padding // Custom state has different padding
@@ -45,59 +116,8 @@ function ChipView({
? "h-[30px] px-[var(--measures-spacing-200,8px)] gap-[var(--measures-spacing-050,2px)] text-x-small-label" ? "h-[30px] px-[var(--measures-spacing-200,8px)] gap-[var(--measures-spacing-050,2px)] text-x-small-label"
: "px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-300,12px)] gap-[var(--measures-spacing-150,6px)] text-medium-label"; : "px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-300,12px)] gap-[var(--measures-spacing-150,6px)] text-medium-label";
// Palette + state styling based on Figma examples
// Use consistent border width to prevent layout shift
const borderWidth = isSmall ? "border-[1.25px]" : "border-2";
let background =
"bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]";
let border = `${borderWidth} border-[var(--color-border-default-tertiary,#464646)]`;
let textColor =
"text-[color:var(--color-content-default-brand-primary,#fefcc9)]";
if (isDefault) {
if (state === "custom") {
background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background for custom
border = "border-none";
textColor = "text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
} else if (state === "disabled") {
background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background
border = "border-none";
// Per Figma (node 19839:13842) disabled uses invert-tertiary for the
// strongly dimmed look, not default-tertiary.
textColor = "text-[color:var(--color-content-invert-tertiary,#2d2d2d)]";
} else if (isSelected) {
background = "bg-[var(--color-surface-invert-brand-primary,#fefcc9)]"; // yellow selected
border = `${borderWidth} border-[var(--color-border-default-brand-primary,#fdfaa8)]`;
textColor = "text-[color:var(--color-content-invert-primary,black)]";
} else {
// Unselected default
background =
"bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]";
border = `${borderWidth} border-[var(--color-border-default-tertiary,#464646)]`;
textColor =
"text-[color:var(--color-content-default-brand-primary,#fefcc9)]";
}
} else if (isInverse) {
if (state === "disabled") {
background = "bg-[var(--color-surface-inverse-tertiary,#d2d2d2)]";
border = "border-none";
textColor = "text-[color:var(--color-content-inverse-primary,black)]";
} else if (isSelected) {
background =
"bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.05))]";
border = `${borderWidth} border-[var(--color-border-default-primary,#141414)]`;
textColor = "text-[color:var(--color-content-inverse-primary,black)]";
} else {
// Unselected / custom inverse
background =
"bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]";
border = `${borderWidth} border-[var(--color-border-default-primary,#141414)]`;
textColor = "text-[color:var(--color-content-inverse-primary,black)]";
}
}
const baseClasses = ` const baseClasses = `
appearance-none
inline-flex inline-flex
max-w-full max-w-full
items-center items-center
@@ -107,7 +127,7 @@ function ChipView({
box-border box-border
focus:outline-none focus:outline-none
focus-visible:ring-2 focus-visible:ring-2
focus-visible:ring-[var(--color-border-default-primary,#141414)] focus-visible:ring-[var(--color-border-default-primary)]
focus-visible:ring-offset-2 focus-visible:ring-offset-2
focus-visible:ring-offset-transparent focus-visible:ring-offset-transparent
transition-[background,border-color,color,box-shadow] transition-[background,border-color,color,box-shadow]
@@ -121,15 +141,7 @@ function ChipView({
? "cursor-not-allowed opacity-60" ? "cursor-not-allowed opacity-60"
: "cursor-pointer"; : "cursor-pointer";
const combinedClasses = [ const combinedClasses = [baseClasses, sizeClasses, stateClasses, className]
baseClasses,
sizeClasses,
background,
border,
textColor,
stateClasses,
className,
]
.filter(Boolean) .filter(Boolean)
.join(" "); .join(" ");
@@ -145,6 +157,7 @@ function ChipView({
const sharedA11y = { const sharedA11y = {
"aria-label": ariaLabel, "aria-label": ariaLabel,
...(isToggle ? { "aria-pressed": isSelected } : {}),
}; };
// Custom state rendering with check/close buttons // Custom state rendering with check/close buttons
@@ -152,6 +165,7 @@ function ChipView({
return ( return (
<div <div
className={combinedClasses} className={combinedClasses}
style={surfaceStyle}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={handleClick} onClick={handleClick}
@@ -261,11 +275,37 @@ function ChipView({
<button <button
type="button" type="button"
className={combinedClasses} className={combinedClasses}
style={surfaceStyle}
disabled={isDisabled} disabled={isDisabled}
onClick={handleClick} onClick={handleClick}
{...sharedA11y} {...sharedA11y}
> >
<span className="min-w-0 truncate">{label}</span> {isSelected ? (
<svg
aria-hidden
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`shrink-0 ${isSmall ? "size-[12px]" : "size-[16px]"}`}
>
<path
d="M10 3L4.5 8.5L2 6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : null}
<span
className="min-w-0 truncate"
style={{
color: surfaceStyle.color,
WebkitTextFillColor: surfaceStyle.WebkitTextFillColor,
}}
>
{label}
</span>
{onRemove && !isDisabled && ( {onRemove && !isDisabled && (
<button <button
type="button" type="button"
@@ -1,3 +1,6 @@
"use client";
import { useId } from "react";
import type { InputWithCounterProps } from "./InputWithCounter.types"; import type { InputWithCounterProps } from "./InputWithCounter.types";
export function InputWithCounterView({ export function InputWithCounterView({
@@ -10,12 +13,16 @@ export function InputWithCounterView({
className = "", className = "",
inputClassName = "", inputClassName = "",
}: InputWithCounterProps) { }: InputWithCounterProps) {
const inputId = useId();
return ( return (
<div className={`space-y-[var(--spacing-scale-008)] ${className}`}> <div className={`space-y-[var(--spacing-scale-008)] ${className}`}>
{/* Label with help icon */} {/* Label with help icon */}
{label && ( {label && (
<div className="flex items-center gap-[var(--spacing-scale-002)]"> <div className="flex items-center gap-[var(--spacing-scale-002)]">
<label className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"> <label
htmlFor={inputId}
className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"
>
{label} {label}
</label> </label>
{showHelpIcon && ( {showHelpIcon && (
@@ -52,6 +59,7 @@ export function InputWithCounterView({
{/* Input field */} {/* Input field */}
<div className="relative"> <div className="relative">
<input <input
id={inputId}
type="text" type="text"
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
@@ -12,7 +12,7 @@ import type { MultiSelectProps } from "./MultiSelect.types";
const MultiSelectContainer = memo<MultiSelectProps>( const MultiSelectContainer = memo<MultiSelectProps>(
({ ({
label, label,
showHelpIcon = true, showHelpIcon = false,
size: sizeProp = "m", size: sizeProp = "m",
palette: paletteProp = "default", palette: paletteProp = "default",
options, options,
@@ -23,6 +23,9 @@ const MultiSelectContainer = memo<MultiSelectProps>(
formHeader = true, formHeader = true,
onCustomChipConfirm, onCustomChipConfirm,
onCustomChipClose, onCustomChipClose,
maxSelections,
selectionCountText,
limitReachedAnnouncement,
className = "", className = "",
}) => { }) => {
const t = useTranslation("controlsChrome"); const t = useTranslation("controlsChrome");
@@ -46,6 +49,9 @@ const MultiSelectContainer = memo<MultiSelectProps>(
formHeader={formHeader} formHeader={formHeader}
onCustomChipConfirm={onCustomChipConfirm} onCustomChipConfirm={onCustomChipConfirm}
onCustomChipClose={onCustomChipClose} onCustomChipClose={onCustomChipClose}
maxSelections={maxSelections}
selectionCountText={selectionCountText}
limitReachedAnnouncement={limitReachedAnnouncement}
className={className} className={className}
/> />
); );
@@ -62,6 +62,15 @@ export interface MultiSelectProps {
* Callback when a custom chip is closed/removed * Callback when a custom chip is closed/removed
*/ */
onCustomChipClose?: (chipId: string) => void; onCustomChipClose?: (chipId: string) => void;
/**
* When set, unselected chips and the add control become non-interactive
* once this many options are `selected`.
*/
maxSelections?: number;
/** Visible selection counter, e.g. "3 of 5". */
selectionCountText?: string;
/** Announced when the selection limit is reached. */
limitReachedAnnouncement?: string;
className?: string; className?: string;
} }
@@ -79,5 +88,8 @@ export interface MultiSelectViewProps {
formHeader: boolean; formHeader: boolean;
onCustomChipConfirm?: (chipId: string, value: string) => void; onCustomChipConfirm?: (chipId: string, value: string) => void;
onCustomChipClose?: (chipId: string) => void; onCustomChipClose?: (chipId: string) => void;
maxSelections?: number;
selectionCountText?: string;
limitReachedAnnouncement?: string;
className: string; className: string;
} }
@@ -19,6 +19,9 @@ function MultiSelectView({
formHeader = true, formHeader = true,
onCustomChipConfirm, onCustomChipConfirm,
onCustomChipClose, onCustomChipClose,
maxSelections,
selectionCountText,
limitReachedAnnouncement,
className, className,
}: MultiSelectViewProps) { }: MultiSelectViewProps) {
const isSmall = size === "s"; const isSmall = size === "s";
@@ -30,6 +33,11 @@ function MultiSelectView({
: "gap-[var(--measures-spacing-300,12px)]"; : "gap-[var(--measures-spacing-300,12px)]";
const chipSize = size; const chipSize = size;
const selectedCount = options.filter((o) => o.state === "selected").length;
const atLimit =
maxSelections != null && selectedCount >= maxSelections;
const countCaption = selectionCountText?.trim() ?? "";
const helperText = countCaption.length > 0 ? countCaption : false;
return ( return (
<div <div
@@ -41,58 +49,83 @@ function MultiSelectView({
label={label} label={label}
helpIcon={showHelpIcon} helpIcon={showHelpIcon}
asterisk={false} asterisk={false}
helperText={false} helperText={helperText}
size={size} size={size}
palette={palette} palette={palette}
/> />
)} )}
{!formHeader || !label ? (
countCaption.length > 0 ? (
<p className="w-full text-x-small-paragraph text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
{countCaption}
</p>
) : null
) : null}
{limitReachedAnnouncement ? (
<p className="sr-only" aria-live="polite">
{atLimit ? limitReachedAnnouncement : ""}
</p>
) : null}
{/* Chips container */} {/* Chips container */}
<div <div
className={`flex flex-wrap ${gapClass} items-center relative shrink-0 w-full`} className={`flex flex-wrap ${gapClass} items-center relative shrink-0 w-full`}
> >
{options.map((option) => ( {options.map((option) => {
<Chip const isCustom = option.state === "custom";
key={option.id} const isSelected = option.state === "selected";
label={option.state === "custom" ? "" : option.label} const chipDisabled = !isCustom && !isSelected && atLimit;
state={option.state || "unselected"} const chipState = chipDisabled
palette={palette} ? "disabled"
size={chipSize} : option.state || "unselected";
onClick={() => { return (
if (option.state !== "custom" && onChipClick) { <Chip
onChipClick(option.id); key={option.id}
} label={isCustom ? "" : option.label}
}} state={chipState}
onCheck={(value, e) => { palette={palette}
e.stopPropagation(); size={chipSize}
if (onCustomChipConfirm) { disabled={chipDisabled}
onCustomChipConfirm(option.id, value); onClick={() => {
} if (!isCustom && !chipDisabled && onChipClick) {
}} onChipClick(option.id);
onClose={(e) => { }
e.stopPropagation(); }}
if (onCustomChipClose) { onCheck={(value, e) => {
onCustomChipClose(option.id); e.stopPropagation();
} if (onCustomChipConfirm) {
}} onCustomChipConfirm(option.id, value);
/> }
))} }}
onClose={(e) => {
e.stopPropagation();
if (onCustomChipClose) {
onCustomChipClose(option.id);
}
}}
/>
);
})}
{/* Add button — icon-only: bordered circle + brand icon (chips stay yellow). With label: Figma 19688:38288 — brand + icon, primary label text, no fill/border. */} {/* Add button — icon-only: bordered circle + brand icon (chips stay yellow). With label: Figma 19688:38288 — brand + icon, primary label text, no fill/border. */}
{addButton && ( {addButton && (
<button <button
type="button" type="button"
aria-label={addButtonAriaLabel} aria-label={addButtonAriaLabel}
disabled={atLimit}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (atLimit) return;
onAddClick?.(); onAddClick?.();
}} }}
className={ className={
!addButtonText !addButtonText
? // Circular button with border (Rule style) ? // Circular button with border (Rule style)
`cursor-pointer bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary,#141414)]" : "border-[var(--color-border-default-tertiary,#464646)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity` `${atLimit ? "cursor-not-allowed opacity-60" : "cursor-pointer hover:opacity-80"} bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary,#141414)]" : "border-[var(--color-border-default-tertiary,#464646)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 transition-opacity`
: // Text add control (default palette: white label + brand “+”; inverse: inverse primary for both) : // Text add control (default palette: white label + brand “+”; inverse: inverse primary for both)
`cursor-pointer flex items-center justify-center overflow-hidden rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity ${ `${atLimit ? "cursor-not-allowed opacity-60" : "cursor-pointer hover:opacity-80"} flex items-center justify-center overflow-hidden rounded-[var(--measures-radius-full,9999px)] shrink-0 transition-opacity ${
isSmall isSmall
? "gap-[var(--measures-spacing-100,4px)] px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-200,8px)]" ? "gap-[var(--measures-spacing-100,4px)] px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-200,8px)]"
: "gap-[var(--measures-spacing-150,6px)] px-[var(--space-400,16px)] py-[var(--measures-spacing-300,12px)]" : "gap-[var(--measures-spacing-150,6px)] px-[var(--space-400,16px)] py-[var(--measures-spacing-300,12px)]"
@@ -36,31 +36,39 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
}, },
ref, ref,
) => { ) => {
const labelEl = label ? (
formHeader ? (
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label
id={labelId}
htmlFor={textareaId}
className={`${labelClasses} font-medium text-[var(--color-content-default-secondary)]`}
>
{label}
</label>
{showHelpIcon && (
<div className="relative shrink-0 size-[12px]">
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt={helpIconAlt}
className="block max-w-none size-full"
/>
</div>
)}
</div>
</div>
) : (
<label id={labelId} htmlFor={textareaId} className="sr-only">
{label}
</label>
)
) : null;
return ( return (
<div className={containerClasses}> <div className={containerClasses}>
{formHeader && label && ( {labelEl}
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label
id={labelId}
htmlFor={textareaId}
className={`${labelClasses} font-medium text-[var(--color-content-default-secondary)]`}
>
{label}
</label>
{showHelpIcon && (
<div className="relative shrink-0 size-[12px]">
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt={helpIconAlt}
className="block max-w-none size-full"
/>
</div>
)}
</div>
</div>
)}
<div className={disabled ? "opacity-40" : ""}> <div className={disabled ? "opacity-40" : ""}>
<textarea <textarea
ref={ref} ref={ref}
@@ -31,6 +31,11 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
textHint = false, textHint = false,
formHeader = true, formHeader = true,
maxLength, maxLength,
autoComplete,
inputMode,
required = false,
form,
onKeyDown,
...props ...props
}, },
ref, ref,
@@ -250,6 +255,11 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
maxLength={maxLength} maxLength={maxLength}
helpIconAlt={t("helpIconAlt")} helpIconAlt={t("helpIconAlt")}
hintDefault={t("hintDefault")} hintDefault={t("hintDefault")}
autoComplete={autoComplete}
inputMode={inputMode}
required={required}
form={form}
onKeyDown={onKeyDown}
{...props} {...props}
/> />
); );
@@ -67,4 +67,9 @@ export interface TextInputViewProps {
maxLength?: number; maxLength?: number;
helpIconAlt: string; helpIconAlt: string;
hintDefault: string; hintDefault: string;
autoComplete?: string;
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
required?: boolean;
form?: string;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
} }
@@ -13,7 +13,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
name, name,
type, type,
disabled, disabled,
error: _error, error = false,
className: _className, className: _className,
containerClasses, containerClasses,
labelClasses, labelClasses,
@@ -31,34 +31,49 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
maxLength, maxLength,
helpIconAlt, helpIconAlt,
hintDefault, hintDefault,
autoComplete,
inputMode,
required = false,
form,
onKeyDown,
state: _state,
isFilled: _isFilled,
}, },
ref, ref,
) => { ) => {
const labelEl = label ? (
formHeader ? (
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label
id={labelId}
htmlFor={inputId}
className={`${labelClasses} font-medium text-[var(--color-content-default-primary)]`}
>
{label}
</label>
{showHelpIcon && (
<div className="relative shrink-0 size-[12px]">
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt={helpIconAlt}
className="block max-w-none size-full"
/>
</div>
)}
</div>
</div>
) : (
<label id={labelId} htmlFor={inputId} className="sr-only">
{label}
</label>
)
) : null;
return ( return (
<div className={containerClasses}> <div className={containerClasses}>
{formHeader && label && ( {labelEl}
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label
id={labelId}
htmlFor={inputId}
className={`${labelClasses} font-medium text-[var(--color-content-default-primary)]`}
>
{label}
</label>
{showHelpIcon && (
<div className="relative shrink-0 size-[12px]">
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt={helpIconAlt}
className="block max-w-none size-full"
/>
</div>
)}
</div>
</div>
)}
<div className={inputWrapperClasses}> <div className={inputWrapperClasses}>
<div className={disabled ? "opacity-40" : ""}> <div className={disabled ? "opacity-40" : ""}>
<input <input
@@ -71,9 +86,16 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
onChange={handleChange} onChange={handleChange}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={onKeyDown}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
disabled={disabled} disabled={disabled}
maxLength={maxLength} maxLength={maxLength}
autoComplete={autoComplete}
inputMode={inputMode}
required={required}
form={form}
aria-invalid={error || undefined}
aria-required={required || undefined}
className={inputClasses} className={inputClasses}
style={{ borderRadius }} style={{ borderRadius }}
/> />
@@ -21,10 +21,13 @@ const CreateContainer = memo<CreateProps>(
footerClassName, footerClassName,
showBackButton = true, showBackButton = true,
showNextButton = true, showNextButton = true,
showRemoveButton = false,
onBack, onBack,
onNext, onNext,
onRemove,
backButtonText = "Back", backButtonText = "Back",
nextButtonText = "Next", nextButtonText = "Next",
removeButtonText,
nextButtonDisabled = false, nextButtonDisabled = false,
currentStep, currentStep,
totalSteps, totalSteps,
@@ -56,10 +59,13 @@ const CreateContainer = memo<CreateProps>(
footerClassName={footerClassName} footerClassName={footerClassName}
showBackButton={showBackButton} showBackButton={showBackButton}
showNextButton={showNextButton} showNextButton={showNextButton}
showRemoveButton={showRemoveButton}
onBack={onBack} onBack={onBack}
onNext={onNext} onNext={onNext}
onRemove={onRemove}
backButtonText={backButtonText} backButtonText={backButtonText}
nextButtonText={nextButtonText} nextButtonText={nextButtonText}
removeButtonText={removeButtonText}
nextButtonDisabled={nextButtonDisabled} nextButtonDisabled={nextButtonDisabled}
currentStep={currentStep} currentStep={currentStep}
totalSteps={totalSteps} totalSteps={totalSteps}
@@ -16,10 +16,13 @@ export interface CreateProps {
footerClassName?: string; footerClassName?: string;
showBackButton?: boolean; showBackButton?: boolean;
showNextButton?: boolean; showNextButton?: boolean;
showRemoveButton?: boolean;
onBack?: () => void; onBack?: () => void;
onNext?: () => void; onNext?: () => void;
onRemove?: () => void;
backButtonText?: string; backButtonText?: string;
nextButtonText?: string; nextButtonText?: string;
removeButtonText?: string;
nextButtonDisabled?: boolean; nextButtonDisabled?: boolean;
currentStep?: number; currentStep?: number;
totalSteps?: number; totalSteps?: number;
@@ -58,10 +61,13 @@ export interface CreateViewProps {
footerClassName?: string; footerClassName?: string;
showBackButton: boolean; showBackButton: boolean;
showNextButton: boolean; showNextButton: boolean;
showRemoveButton: boolean;
onBack?: () => void; onBack?: () => void;
onNext?: () => void; onNext?: () => void;
onRemove?: () => void;
backButtonText: string; backButtonText: string;
nextButtonText: string; nextButtonText: string;
removeButtonText?: string;
nextButtonDisabled: boolean; nextButtonDisabled: boolean;
currentStep?: number; currentStep?: number;
totalSteps?: number; totalSteps?: number;
@@ -17,10 +17,13 @@ export function CreateView({
footerClassName, footerClassName,
showBackButton, showBackButton,
showNextButton, showNextButton,
showRemoveButton,
onBack, onBack,
onNext, onNext,
onRemove,
backButtonText, backButtonText,
nextButtonText, nextButtonText,
removeButtonText,
nextButtonDisabled, nextButtonDisabled,
currentStep, currentStep,
totalSteps, totalSteps,
@@ -76,10 +79,13 @@ export function CreateView({
<ModalFooter <ModalFooter
showBackButton={showBackButton} showBackButton={showBackButton}
showNextButton={showNextButton} showNextButton={showNextButton}
showRemoveButton={showRemoveButton}
onBack={onBack} onBack={onBack}
onNext={onNext} onNext={onNext}
onRemove={onRemove}
backButtonText={backButtonText} backButtonText={backButtonText}
nextButtonText={nextButtonText} nextButtonText={nextButtonText}
removeButtonText={removeButtonText}
nextButtonDisabled={nextButtonDisabled} nextButtonDisabled={nextButtonDisabled}
currentStep={currentStep} currentStep={currentStep}
totalSteps={totalSteps} totalSteps={totalSteps}
@@ -1,10 +1,93 @@
"use client"; "use client";
import type { RefObject } from "react"; import type { RefObject } from "react";
import { useEffect, useRef } from "react"; import { useEffect, useLayoutEffect, useRef } from "react";
const FOCUSABLE_SELECTOR =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
let lastInteractedFocusable: HTMLElement | null = null;
let interactionTrackingBound = false;
function closestFocusable(target: EventTarget | null): HTMLElement | null {
if (!(target instanceof Element)) return null;
const match = target.closest(FOCUSABLE_SELECTOR);
return match instanceof HTMLElement ? match : null;
}
/** Menu items unmount on select; restore to the control that opened the menu. */
function menuTriggerFor(item: HTMLElement): HTMLElement | null {
if (item.getAttribute("role") !== "menuitem") return null;
const menu = item.closest("[role='menu']");
const menuId = menu?.getAttribute("id");
if (menuId) {
const trigger = document.querySelector(
`[aria-controls="${CSS.escape(menuId)}"]`,
);
if (trigger instanceof HTMLElement) return trigger;
}
const expanded = document.querySelector(
'[aria-haspopup="menu"][aria-expanded="true"]',
);
return expanded instanceof HTMLElement ? expanded : null;
}
function stableFocusableFrom(target: EventTarget | null): HTMLElement | null {
const focusable = closestFocusable(target);
if (!focusable) return null;
return menuTriggerFor(focusable) ?? focusable;
}
function isRestorable(
node: HTMLElement | null,
dialog: HTMLElement | null,
): node is HTMLElement {
if (!node?.isConnected) return false;
if (node === document.body || node === document.documentElement) return false;
if (dialog?.contains(node)) return false;
return true;
}
function retainLastInteracted(): void {
if (interactionTrackingBound || typeof document === "undefined") return;
interactionTrackingBound = true;
const save = (event: Event) => {
const el = stableFocusableFrom(event.target);
if (!el) return;
lastInteractedFocusable = el;
};
document.addEventListener("pointerdown", save, true);
document.addEventListener("focusin", save);
}
retainLastInteracted();
function snapshotTrigger(dialog: HTMLElement | null): HTMLElement | null {
if (lastInteractedFocusable && !lastInteractedFocusable.isConnected) {
lastInteractedFocusable = null;
}
const active =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const fromActive = isRestorable(active, dialog)
? (menuTriggerFor(active) ?? active)
: null;
if (fromActive && isRestorable(fromActive, dialog)) return fromActive;
if (isRestorable(lastInteractedFocusable, dialog)) {
return lastInteractedFocusable;
}
return null;
}
function restoreFocus(node: HTMLElement | null): void {
if (!node?.isConnected) return;
node.focus();
}
/** /**
* Escape-to-close, body scroll lock, focus move-in and tab trap for Create-shell modals. * Escape-to-close, body scroll lock, focus move-in, tab trap, and restore
* focus to the control that opened a Create-shell modal.
*/ */
export function useCreateModalA11y( export function useCreateModalA11y(
isOpen: boolean, isOpen: boolean,
@@ -28,17 +111,17 @@ export function useCreateModalA11y(
}; };
}, [isOpen, onClose]); }, [isOpen, onClose]);
useEffect(() => { useLayoutEffect(() => {
if (!isOpen) return; if (!isOpen) return;
previousActiveElementRef.current = document.activeElement as HTMLElement; previousActiveElementRef.current = snapshotTrigger(dialogRef.current);
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
if (dialogRef.current) { if (dialogRef.current) {
const focusableElements = dialogRef.current.querySelectorAll( const focusableElements = dialogRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', FOCUSABLE_SELECTOR,
); );
const firstElement = focusableElements[0] as HTMLElement; const firstElement = focusableElements[0] as HTMLElement | undefined;
if (firstElement) { if (firstElement) {
firstElement.focus(); firstElement.focus();
} else { } else {
@@ -51,23 +134,21 @@ export function useCreateModalA11y(
if (e.key !== "Tab" || !dialogRef.current) return; if (e.key !== "Tab" || !dialogRef.current) return;
const focusableElements = dialogRef.current.querySelectorAll( const focusableElements = dialogRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', FOCUSABLE_SELECTOR,
); );
const firstElement = focusableElements[0] as HTMLElement; const firstElement = focusableElements[0] as HTMLElement | undefined;
const lastElement = focusableElements[ const lastElement = focusableElements[
focusableElements.length - 1 focusableElements.length - 1
] as HTMLElement; ] as HTMLElement | undefined;
if (e.shiftKey) { if (e.shiftKey) {
if (document.activeElement === firstElement) { if (document.activeElement === firstElement) {
e.preventDefault(); e.preventDefault();
lastElement?.focus(); lastElement?.focus();
} }
} else { } else if (document.activeElement === lastElement) {
if (document.activeElement === lastElement) { e.preventDefault();
e.preventDefault(); firstElement?.focus();
firstElement?.focus();
}
} }
}; };
@@ -76,7 +157,7 @@ export function useCreateModalA11y(
return () => { return () => {
document.body.style.overflow = ""; document.body.style.overflow = "";
document.removeEventListener("keydown", handleTab); document.removeEventListener("keydown", handleTab);
previousActiveElementRef.current?.focus(); restoreFocus(previousActiveElementRef.current);
}; };
}, [dialogRef, isOpen]); }, [dialogRef, isOpen]);
} }
+4 -1
View File
@@ -31,7 +31,10 @@ export function LoginView({
<div <div
ref={backdropRef} ref={backdropRef}
className={`fixed inset-0 z-[9998] flex flex-col items-center justify-center gap-6 overflow-y-auto px-4 py-8 ${backdropClasses[backdropVariant]}`} className={`fixed inset-0 z-[9998] flex flex-col items-center justify-center gap-6 overflow-y-auto px-4 py-8 ${backdropClasses[backdropVariant]}`}
onClick={onClose} onPointerDown={(event) => {
if (event.target !== event.currentTarget) return;
onClose();
}}
role="presentation" role="presentation"
> >
<div <div
+54 -20
View File
@@ -44,17 +44,51 @@ function MailIconInline() {
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export type LoginFormVariant = "default" | "saveProgress"; export type LoginFormVariant = "default" | "saveProgress" | "keepRule";
function loginFormHeading(
variant: LoginFormVariant,
sent: boolean,
t: (_key: string) => string,
): { title: string; description: string } {
if (sent) {
return { title: t("successTitle"), description: t("successBody") };
}
if (variant === "saveProgress") {
return {
title: t("saveProgressTitle"),
description: t("saveProgressSubtitle"),
};
}
if (variant === "keepRule") {
return {
title: t("keepRuleTitle"),
description: t("keepRuleSubtitle"),
};
}
return { title: t("title"), description: t("subtitle") };
}
function shouldAttachCreateFlowDraft(
variant: LoginFormVariant,
nextPath: string,
): boolean {
if (variant === "keepRule") return false;
return variant === "saveProgress" || nextPath.includes("syncDraft=1");
}
export type LoginFormProps = { export type LoginFormProps = {
variant?: LoginFormVariant; variant?: LoginFormVariant;
/** Overrides URL `next` for `requestMagicLink` (e.g. create-flow exit modal). */ /** Overrides URL `next` for `requestMagicLink` (e.g. create-flow exit modal). */
magicLinkNextPath?: string; magicLinkNextPath?: string;
/** `keepRule`: Continue without saving. */
onDismiss?: () => void;
}; };
export default function LoginForm({ export default function LoginForm({
variant = "default", variant = "default",
magicLinkNextPath, magicLinkNextPath,
onDismiss,
}: LoginFormProps) { }: LoginFormProps) {
const t = useTranslation("pages.login"); const t = useTranslation("pages.login");
const tFooter = useTranslation("footer"); const tFooter = useTranslation("footer");
@@ -72,7 +106,7 @@ export default function LoginForm({
const nextParam = searchParams.get("next"); const nextParam = searchParams.get("next");
const errorParam = searchParams.get("error"); const errorParam = searchParams.get("error");
const isSaveProgress = variant === "saveProgress"; const heading = loginFormHeading(variant, sent, t);
/** Drop `error` from the URL so URL-driven messages dont linger after a new attempt. */ /** Drop `error` from the URL so URL-driven messages dont linger after a new attempt. */
const stripErrorQuery = useCallback(() => { const stripErrorQuery = useCallback(() => {
@@ -96,8 +130,7 @@ export default function LoginForm({
try { try {
const rawNext = magicLinkNextPath ?? nextParam; const rawNext = magicLinkNextPath ?? nextParam;
const nextPath = safeInternalPath(rawNext); const nextPath = safeInternalPath(rawNext);
const shouldAttachDraft = const shouldAttachDraft = shouldAttachCreateFlowDraft(variant, nextPath);
isSaveProgress || nextPath.includes("syncDraft=1");
const localDraft = readAnonymousCreateFlowState(); const localDraft = readAnonymousCreateFlowState();
const draft = const draft =
shouldAttachDraft && Object.keys(localDraft).length > 0 shouldAttachDraft && Object.keys(localDraft).length > 0
@@ -115,7 +148,7 @@ export default function LoginForm({
} }
return; return;
} }
if (isSaveProgress || nextPath.includes("syncDraft=1")) { if (shouldAttachDraft) {
setTransferPendingFlag(); setTransferPendingFlag();
} }
setEmail(trimmed); setEmail(trimmed);
@@ -127,11 +160,11 @@ export default function LoginForm({
} }
}, [ }, [
email, email,
isSaveProgress,
magicLinkNextPath, magicLinkNextPath,
nextParam, nextParam,
stripErrorQuery, stripErrorQuery,
t, t,
variant,
]); ]);
const urlErrorMessage = const urlErrorMessage =
@@ -155,20 +188,8 @@ export default function LoginForm({
</div> </div>
<ContentLockup <ContentLockup
titleId={titleId} titleId={titleId}
title={ title={heading.title}
sent description={heading.description}
? t("successTitle")
: isSaveProgress
? t("saveProgressTitle")
: t("title")
}
description={
sent
? t("successBody")
: isSaveProgress
? t("saveProgressSubtitle")
: t("subtitle")
}
variant="login" variant="login"
alignment="left" alignment="left"
/> />
@@ -255,6 +276,19 @@ export default function LoginForm({
> >
{t("sendMagicLink")} {t("sendMagicLink")}
</Button> </Button>
{onDismiss ? (
<Button
type="button"
size="large"
buttonType="ghost"
palette="default"
disabled={submitting}
onClick={onDismiss}
className="w-full !justify-center text-center px-[var(--spacing-scale-016)] py-[var(--spacing-scale-012)]"
>
{t("continueWithoutSaving")}
</Button>
) : null}
<p className="text-center text-small-paragraph text-[var(--color-content-default-tertiary)]"> <p className="text-center text-small-paragraph text-[var(--color-content-default-tertiary)]">
{t("legalPrefix")} {t("legalPrefix")}
<Link <Link
@@ -14,12 +14,14 @@ const ModalFooterContainer = memo<ModalFooterProps>((props) => {
const t = useTranslation("common"); const t = useTranslation("common");
const resolvedBackText = props.backButtonText ?? t("buttons.back"); const resolvedBackText = props.backButtonText ?? t("buttons.back");
const resolvedNextText = props.nextButtonText ?? t("buttons.next"); const resolvedNextText = props.nextButtonText ?? t("buttons.next");
const resolvedRemoveText = props.removeButtonText ?? t("buttons.remove");
return ( return (
<ModalFooterView <ModalFooterView
{...props} {...props}
backButtonText={resolvedBackText} backButtonText={resolvedBackText}
nextButtonText={resolvedNextText} nextButtonText={resolvedNextText}
removeButtonText={resolvedRemoveText}
/> />
); );
}); });
@@ -1,8 +1,14 @@
export interface ModalFooterProps { export interface ModalFooterProps {
showBackButton?: boolean; showBackButton?: boolean;
showNextButton?: boolean; showNextButton?: boolean;
/**
* Danger **Remove** in the left footer slot (selected create-flow modules).
* Takes the left slot instead of Back when both would otherwise show.
*/
showRemoveButton?: boolean;
onBack?: () => void; onBack?: () => void;
onNext?: () => void; onNext?: () => void;
onRemove?: () => void;
/** /**
* Custom back button text. If not provided, uses localized "Back" from common.json * Custom back button text. If not provided, uses localized "Back" from common.json
*/ */
@@ -11,6 +17,10 @@ export interface ModalFooterProps {
* Custom next button text. If not provided, uses localized "Next" from common.json * Custom next button text. If not provided, uses localized "Next" from common.json
*/ */
nextButtonText?: string; nextButtonText?: string;
/**
* Custom remove button text. If not provided, uses localized "Remove" from common.json
*/
removeButtonText?: string;
nextButtonDisabled?: boolean; nextButtonDisabled?: boolean;
currentStep?: number; currentStep?: number;
totalSteps?: number; totalSteps?: number;
@@ -7,10 +7,13 @@ import type { ModalFooterProps } from "./ModalFooter.types";
export function ModalFooterView({ export function ModalFooterView({
showBackButton = false, showBackButton = false,
showNextButton = false, showNextButton = false,
showRemoveButton = false,
onBack, onBack,
onNext, onNext,
onRemove,
backButtonText, backButtonText,
nextButtonText, nextButtonText,
removeButtonText,
nextButtonDisabled = false, nextButtonDisabled = false,
currentStep, currentStep,
totalSteps, totalSteps,
@@ -22,12 +25,26 @@ export function ModalFooterView({
stepperProp !== undefined stepperProp !== undefined
? stepperProp ? stepperProp
: currentStep !== undefined && totalSteps !== undefined; : currentStep !== undefined && totalSteps !== undefined;
const showStartBack = showBackButton && !showRemoveButton;
return ( return (
<div <div
className={`h-[64px] bg-[var(--color-surface-default-primary)] rounded-bl-[var(--radius-300,12px)] rounded-br-[var(--radius-300,12px)] shrink-0 relative ${className}`} className={`h-[64px] bg-[var(--color-surface-default-primary)] rounded-bl-[var(--radius-300,12px)] rounded-br-[var(--radius-300,12px)] shrink-0 relative ${className}`}
> >
{showBackButton && ( {showRemoveButton && (
<div className="absolute left-[16px] top-[12px]">
<Button
buttonType="danger"
palette="default"
size="medium"
onClick={onRemove}
>
{removeButtonText}
</Button>
</div>
)}
{showStartBack && (
<div className="absolute left-[16px] top-[12px]"> <div className="absolute left-[16px] top-[12px]">
<Button <Button
buttonType="outline" buttonType="outline"

Some files were not shown because too many files have changed in this diff Show More