Compare commits

...
34 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
an.di df7051f33a Merge pull request 'Encode sign-in mail as base64 so webmail cannot mangle the verify token.' (#71) from adilallo/fix/CR-172-magic-link-base64 into main
Reviewed-on: #71
2026-09-01 23:23:44 +00:00
adilalloandCursor f780eac1fa Encode sign-in mail as base64 so webmail cannot mangle the verify token.
Quoted-printable still rewrote token= as token=3D in the raw MIME, which some clients never decode. Distinguish a missing token from a real expiry, and keep links valid for 60 minutes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 17:07:00 -06:00
an.di fa928d1b2c Merge pull request 'Fix magic-link verify URLs in email and related create-flow QA' (#70) from adilallo/fix/QaFeedback into main
Reviewed-on: #70
2026-09-01 16:09:31 +00:00
adilalloandCursor d920e39f09 Send sign-in mail as HTML so quoted-printable wrapping cannot break the verify URL.
Staging was delivering magic links that looked expired because the token query string was encoded and wrapped in plaintext MIME. Also read rate-limit retry from the API error body, mention spam in the success copy, and document SES relay DNS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:01:25 -06:00
adilalloandCursor 511c3efb4c Stop seeding 75% consensus on custom decision cards so wizard copy shows on the published rule.
Wire Back on the final-review chip editor to the same dismiss path as close, instead of a no-op.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 11:28:32 -06:00
adilalloandCursor 7f67cc6271 Warn on tab close when create-flow module or wizard edits have not been saved.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 11:00:40 -06:00
adilalloandCursor a820739d07 Open create-flow method modals with editable fields and tertiary default copy.
Seeded section text should look like the form default state, not locked primary, and Customize stays on the kebab instead of unlocking the body.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 12:59:53 -06:00
289 changed files with 7151 additions and 1772 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.11", "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 />;
} }
+137 -47
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({ setCompletedFlowBanner({
key: "guestClaimHint", key: "guestClaimHint",
status: "warning", status: "warning",
title: completedCopy.guestClaimHintTitle, title: completedCopy.guestClaimHintTitle,
description: completedCopy.guestClaimHintDescription, 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,13 +561,14 @@ 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"
: isCreateTemplatesPickerRoute
? "items-start justify-start overflow-y-auto"
: isTemplateReviewRoute || isFinalReviewLike || isCardLayoutStep : isTemplateReviewRoute || isFinalReviewLike || isCardLayoutStep
? CREATE_FLOW_MD_CENTERED_MAIN_CLASS ? CREATE_FLOW_MD_CENTERED_MAIN_CLASS
: "items-start justify-center overflow-y-auto md:items-center"; : "items-start justify-center overflow-y-auto md:items-center";
@@ -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}
/> />
@@ -6,7 +6,9 @@ import {
useTranslation, useTranslation,
} from "../../../../contexts/MessagesContext"; } from "../../../../contexts/MessagesContext";
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm"; import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
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,
@@ -213,6 +215,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
policyTitle, policyTitle,
]); ]);
useBeforeUnloadGuard(isOpen && isWizardSessionDirty());
const confirmAbandonWizardEdits = useCallback(async () => { const confirmAbandonWizardEdits = useCallback(async () => {
if (!isWizardSessionDirty()) { if (!isWizardSessionDirty()) {
return true; return true;
@@ -459,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.
* *
@@ -28,6 +29,7 @@ import {
import CustomMethodCardModalBody from "./CustomMethodCardModalBody"; import CustomMethodCardModalBody from "./CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu";
import { useDiscardCustomizeConfirm } from "../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../hooks/useDiscardCustomizeConfirm";
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
import { import {
communicationPresetFor, communicationPresetFor,
conflictManagementPresetFor, conflictManagementPresetFor,
@@ -370,6 +372,12 @@ export function FinalReviewChipEditModal({
draftFieldBlocks, draftFieldBlocks,
]); ]);
useBeforeUnloadGuard(
isOpen &&
!addCustomWizardOpen &&
(!coreCustomizeSaveDisabled || !methodCustomizeSaveDisabled),
);
const modalUsesWizardFieldBlocksBody = Boolean( const modalUsesWizardFieldBlocksBody = Boolean(
target && target &&
(usesWizardFieldBlocksModalBody({ (usesWizardFieldBlocksModalBody({
@@ -1066,11 +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}
showBackButton={!showFooterRemove}
showRemoveButton={showFooterRemove}
onRemove={
target?.groupKey === "coreValues"
? handleRemoveCoreValueFromModal
: handleRemoveSelectedFromModal
}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
headerContent={headerContent} headerContent={headerContent}
showNextButton={true} showNextButton={true}
@@ -6,8 +6,9 @@
*/ */
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
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";
@@ -57,6 +58,8 @@ export function FinalReviewCommunityContextEditModal({
[draft], [draft],
); );
useBeforeUnloadGuard(isOpen && isDirty);
const characterHint = tField("characterCountTemplate") const characterHint = tField("characterCountTemplate")
.replace("{current}", String(draft.length)) .replace("{current}", String(draft.length))
.replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH)); .replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH));
@@ -92,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;
}
}
@@ -15,7 +15,7 @@ import type { CoreValueDetailEntry } from "../../types";
export interface CoreValueEditFieldsProps { export interface CoreValueEditFieldsProps {
value: CoreValueDetailEntry; value: CoreValueDetailEntry;
onChange: (_next: CoreValueDetailEntry) => void; onChange: (_next: CoreValueDetailEntry) => void;
/** Disable meaning/signals. Create-flow core-values omits this; final-review locks until Customize. */ /** Disable meaning/signals. Facet and final-review callers omit this so fields stay editable on open. */
readOnly?: boolean; readOnly?: boolean;
} }
@@ -80,6 +80,7 @@ function DecisionApproachEditFieldsComponent({
onChange={(v) => patch("stepByStepInstructions", v)} onChange={(v) => patch("stepByStepInstructions", v)}
disabled={readOnly} disabled={readOnly}
/> />
{value.consensusLevel !== undefined ? (
<IncrementerBlock <IncrementerBlock
label={t.sectionHeadings.consensusLevel} label={t.sectionHeadings.consensusLevel}
helpIcon={false} helpIcon={false}
@@ -93,6 +94,7 @@ function DecisionApproachEditFieldsComponent({
incrementAriaLabel="Increase consensus level" incrementAriaLabel="Increase consensus level"
disabled={readOnly} disabled={readOnly}
/> />
) : null}
<ModalTextAreaField <ModalTextAreaField
label={t.sectionHeadings.objectionsDeadlocks} label={t.sectionHeadings.objectionsDeadlocks}
value={value.objectionsDeadlocks} value={value.objectionsDeadlocks}
@@ -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) {
return {
...seeded,
pendingTemplateAction: undefined,
};
}
return {
...seeded,
title: undefined,
communityContext: undefined,
communityAvatarUrl: undefined,
currentStep: undefined,
templateReviewEntryFromCreateFlow: undefined,
pendingTemplateAction: { pendingTemplateAction: {
slug: templateReviewSlug, slug: templateReviewSlug,
mode: "useWithoutChanges", 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,31 +6,26 @@
* *
* 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; a selected card in view mode has * primary action is **Add Platform** for an unselected card and **Save** for a
* no footer primary — **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";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
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";
@@ -57,6 +52,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -88,6 +84,16 @@ export function CommunicationMethodsScreen() {
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedCommunicationMethodIds ?? []; const selectedIds = state.selectedCommunicationMethodIds ?? [];
const mergedMethods = useMemo( const mergedMethods = useMemo(
@@ -216,7 +222,7 @@ export function CommunicationMethodsScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked: false, modalEditUnlocked: true,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -688,22 +694,10 @@ 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">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady}
> >
{recommendationsReady && ( {recommendationsReady && (
<CardStack <CardStack
@@ -723,9 +717,7 @@ export function CommunicationMethodsScreen() {
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
)} )}
</div> </CreateFlowCardStackStepShell>
</div>
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
@@ -735,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}
@@ -766,7 +760,6 @@ export function CommunicationMethodsScreen() {
) )
) : null} ) : null}
</Create> </Create>
</CreateFlowStepShell>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
@@ -16,18 +16,13 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
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";
@@ -54,6 +49,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -85,6 +81,16 @@ export function ConflictManagementScreen() {
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedConflictManagementIds ?? []; const selectedIds = state.selectedConflictManagementIds ?? [];
const mergedMethods = useMemo( const mergedMethods = useMemo(
@@ -217,7 +223,7 @@ export function ConflictManagementScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked: false, modalEditUnlocked: true,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -689,22 +695,10 @@ 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">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady}
> >
{recommendationsReady && ( {recommendationsReady && (
<CardStack <CardStack
@@ -724,9 +718,7 @@ export function ConflictManagementScreen() {
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
)} )}
</div> </CreateFlowCardStackStepShell>
</div>
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
@@ -736,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}
@@ -767,7 +761,6 @@ export function ConflictManagementScreen() {
) )
) : null} ) : null}
</Create> </Create>
</CreateFlowStepShell>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
@@ -17,18 +17,13 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
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";
@@ -55,6 +50,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -86,6 +82,16 @@ export function MembershipMethodsScreen() {
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedMembershipMethodIds ?? []; const selectedIds = state.selectedMembershipMethodIds ?? [];
const mergedMethods = useMemo( const mergedMethods = useMemo(
@@ -214,7 +220,7 @@ export function MembershipMethodsScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked: false, modalEditUnlocked: true,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -682,22 +688,10 @@ 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">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady}
> >
{recommendationsReady && ( {recommendationsReady && (
<CardStack <CardStack
@@ -717,9 +711,7 @@ export function MembershipMethodsScreen() {
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
)} )}
</div> </CreateFlowCardStackStepShell>
</div>
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
@@ -729,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}
@@ -760,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"
? createFlowStepPath("core-values")
: createFlowStepPath("confirm-stakeholders");
firedRedirectRef.current = true; firedRedirectRef.current = true;
const pinMerge = router.replace(createFlowStepPath("community-name"));
pending.mode === "customize" return;
? { }
const target = createFlowStepPath("core-values");
firedRedirectRef.current = true;
const pinMerge = {
methodSectionsPinCommitted: { methodSectionsPinCommitted: {
...state.methodSectionsPinCommitted, ...state.methodSectionsPinCommitted,
...methodSectionsPinsForHydratedSelections(state), ...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`.
@@ -26,6 +26,7 @@ import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMes
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
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 { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
@@ -60,6 +61,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -91,6 +93,16 @@ export function DecisionApproachesScreen() {
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedDecisionApproachIds ?? []; const selectedIds = state.selectedDecisionApproachIds ?? [];
const messageBoxCheckedIds = decisionApproachKeyResourceCheckboxIds({ const messageBoxCheckedIds = decisionApproachKeyResourceCheckboxIds({
detailsById: state.decisionApproachDetailsById, detailsById: state.decisionApproachDetailsById,
@@ -250,7 +262,7 @@ export function DecisionApproachesScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked: false, modalEditUnlocked: true,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -799,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,42 +130,31 @@ export function ConfirmStakeholdersScreen() {
if (managePublishedMode) { if (managePublishedMode) {
return ( return (
<CreateFlowStepShell <CreateFlowTwoColumnSelectShell
variant="centeredNarrowBottomPad" header={
contentTopBelowMd="space-1400"
>
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
<CreateFlowHeaderLockup <CreateFlowHeaderLockup
title={t("managePublished.lockupTitle")} title={t("managePublished.lockupTitle")}
description={t("managePublished.lockupDescription")} description={t("managePublished.lockupDescription")}
justification="left" justification="left"
/> />
</div> }
>
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} /> <PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
</div> </CreateFlowTwoColumnSelectShell>
</CreateFlowStepShell>
); );
} }
return ( return (
<> <>
<CreateFlowStepShell <CreateFlowTwoColumnSelectShell
variant="centeredNarrowBottomPad" header={
contentTopBelowMd="space-1400"
>
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
<CreateFlowHeaderLockup <CreateFlowHeaderLockup
title={t("title")} title={t("title")}
description={t("description")} description={t("description")}
justification="left" justification="left"
/> />
</div> }
>
{chipError ? ( {chipError ? (
<p <p
className="text-small-paragraph text-[var(--color-border-default-utility-negative)]" className="text-small-paragraph text-[var(--color-border-default-utility-negative)]"
@@ -187,8 +175,7 @@ export function ConfirmStakeholdersScreen() {
addButton addButton
addButtonText={t("addStakeholder")} addButtonText={t("addStakeholder")}
/> />
</div> </CreateFlowTwoColumnSelectShell>
</CreateFlowStepShell>
{!toastDismissed && ( {!toastDismissed && (
<div <div
@@ -9,6 +9,7 @@ import { useMessages } from "../../../../contexts/MessagesContext";
import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft"; import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm"; import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import type { import type {
CommunityStructureChipSnapshotRow, CommunityStructureChipSnapshotRow,
CoreValueDetailEntry, CoreValueDetailEntry,
@@ -257,6 +258,14 @@ export function CoreValuesSelectScreen() {
}); });
}, [cv.detailModal, draft, modalSession, requestConfirm]); }, [cv.detailModal, draft, modalSession, requestConfirm]);
useBeforeUnloadGuard(
activeModalChipId != null &&
!addCustomWizardOpen &&
initialDraftRef.current != null &&
(draft.meaning !== initialDraftRef.current.meaning ||
draft.signals !== initialDraftRef.current.signals),
);
const handleDuplicateCoreChip = useCallback(() => { const handleDuplicateCoreChip = useCallback(() => {
if (!activeModalChipId || !modalSession) return; if (!activeModalChipId || !modalSession) return;
markCreateFlowInteraction(); markCreateFlowInteraction();
@@ -578,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",
@@ -596,7 +611,7 @@ export function CoreValuesSelectScreen() {
}); });
}, [ }, [
activeModalChipId, activeModalChipId,
coreValueOptions, atSelectionLimit,
handleCustomize, handleCustomize,
handleDuplicateCoreChip, handleDuplicateCoreChip,
handleRemoveFromKebab, handleRemoveFromKebab,
@@ -686,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>
@@ -721,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 && (
@@ -742,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">
{multiline ? (
<TextArea
className="!transition-none"
label={fieldLabel}
placeholder={t("placeholder")}
value={value}
onChange={(e) => persistValue(e.target.value)}
onBlur={() => {
const trimmed = value.trim();
if (trimmed !== value) persistValue(trimmed);
}}
size={mdUp ? "medium" : "small"}
formHeader={false}
showHelpIcon={false}
textHint={hint}
maxLength={maxLength}
required={required}
rows={4}
/>
) : (
<TextInput <TextInput
className="!transition-none" className="!transition-none"
type={inputType} type={inputType}
name={isEmail ? "email" : undefined}
label={fieldLabel}
placeholder={t("placeholder")} placeholder={t("placeholder")}
value={value} value={value}
onChange={(e) => { onChange={(e) => persistValue(e.target.value)}
const v = e.target.value; onBlur={() => {
setValue(v); const trimmed = isEmail
markCreateFlowInteraction(); ? value.trim().toLowerCase()
updateState({ [stateField]: v } as Record<string, string>); : 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"} inputSize={mdUp ? "medium" : "small"}
formHeader={false} formHeader={false}
showHelpIcon={false}
textHint={hint} textHint={hint}
maxLength={maxLength} 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 serverAvatarUrl = hasCommunityAvatarUrl(state.communityAvatarUrl)
? state.communityAvatarUrl!.trim()
: null;
const serverAvatarUrlRef = useRef(serverAvatarUrl);
serverAvatarUrlRef.current = serverAvatarUrl;
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 () => {
cancelled = true;
};
}, []);
const resolveUploadError = useCallback( const resolveUploadError = useCallback(
(err: unknown) => { (err: unknown) => tUpload(createFlowUploadFailureMessageKey(err)),
if (err instanceof UploadToServerError) {
if (err.status === 413) return tUpload("errors.tooLarge");
if (err.status === 401) return tUpload("errors.unauthorized");
if (err.code === "server_misconfigured") {
return tUpload("errors.misconfigured");
}
}
return tUpload("errors.generic");
},
[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} />;
}
+19 -17
View File
@@ -85,7 +85,11 @@ export type DecisionApproachDetailEntry = {
applicableScope: string[]; applicableScope: string[];
selectedApplicableScope: string[]; selectedApplicableScope: string[];
stepByStepInstructions: string; stepByStepInstructions: string;
consensusLevel: number; /**
* Catalog presets always set this. User-authored custom cards omit it until
* the author adds a proportion field or edits consensus in the facet form.
*/
consensusLevel?: number;
objectionsDeadlocks: string; objectionsDeadlocks: string;
}; };
@@ -190,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;
@@ -202,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;
@@ -275,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) =>
if (!fromFlow) { buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow })
prepareFreshCreateFlowEntrySync(); }
onTemplateClick={() => {
if (!fromFlow) {
// Marketing /templates has no session in hand; DELETE is
// best-effort and the sentinel blocks stale-draft hydration.
prepareFreshCreateFlowEntrySync({ signedIn: true });
} }
router.push(
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
);
}} }}
/> />
); );
+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) {
+1 -1
View File
@@ -24,7 +24,7 @@ import { getPublicOrigin } from "../../../../../lib/server/publicOrigin";
import { magicLinkRequestBodySchema } from "../../../../../lib/server/validation/createFlowSchemas"; import { magicLinkRequestBodySchema } from "../../../../../lib/server/validation/createFlowSchemas";
import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp"; import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; const MAGIC_LINK_TTL_MS = 60 * 60 * 1000;
const EMAIL_MIN_INTERVAL_MS = 60 * 1000; const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
const IP_MIN_INTERVAL_MS = 20 * 1000; const IP_MIN_INTERVAL_MS = 20 * 1000;
const SCOPE = "auth.magicLink.request"; const SCOPE = "auth.magicLink.request";
+9 -1
View File
@@ -54,7 +54,15 @@ export async function GET(request: NextRequest) {
where: { tokenHash }, where: { tokenHash },
}); });
if (!row || row.expiresAt < new Date()) { if (!row) {
return redirectWithRequestId(
request,
"/login?error=invalid_link",
requestId,
);
}
if (row.expiresAt < new Date()) {
return redirectWithRequestId( return redirectWithRequestId(
request, request,
"/login?error=expired_link", "/login?error=expired_link",
+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 } },
); );
} }
+1 -1
View File
@@ -25,7 +25,7 @@ import { readLimitedJson } from "../../../../../lib/server/validation/requestBod
import { emailChangeRequestBodySchema } from "../../../../../lib/server/validation/userEmailChangeSchemas"; import { emailChangeRequestBodySchema } from "../../../../../lib/server/validation/userEmailChangeSchemas";
import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp"; import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
const EMAIL_CHANGE_TTL_MS = 15 * 60 * 1000; const EMAIL_CHANGE_TTL_MS = 60 * 60 * 1000;
const EMAIL_MIN_INTERVAL_MS = 60 * 1000; const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
const IP_MIN_INTERVAL_MS = 20 * 1000; const IP_MIN_INTERVAL_MS = 20 * 1000;
const SCOPE = "user.emailChange.request"; const SCOPE = "user.emailChange.request";
+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;
} }
+21
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)]">
{sourcedYear ? (
<>
{asOfPrefix ? <span>{`${asOfPrefix} `}</span> : null}
{sourceHref ? (
<a href={sourceHref} className="underline">
{asOf} {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,39 +59,55 @@ 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)]"
: 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)]"; : "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)]"
: 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)]"; : "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)]"
: 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)]"; : "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}`
: 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}`; : `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}`
: 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}`; : `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}`
: 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}`; : `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}`
: 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}`; : `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`;
return ( return (
@@ -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,25 +49,47 @@ 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) => {
const isCustom = option.state === "custom";
const isSelected = option.state === "selected";
const chipDisabled = !isCustom && !isSelected && atLimit;
const chipState = chipDisabled
? "disabled"
: option.state || "unselected";
return (
<Chip <Chip
key={option.id} key={option.id}
label={option.state === "custom" ? "" : option.label} label={isCustom ? "" : option.label}
state={option.state || "unselected"} state={chipState}
palette={palette} palette={palette}
size={chipSize} size={chipSize}
disabled={chipDisabled}
onClick={() => { onClick={() => {
if (option.state !== "custom" && onChipClick) { if (!isCustom && !chipDisabled && onChipClick) {
onChipClick(option.id); onChipClick(option.id);
} }
}} }}
@@ -76,23 +106,26 @@ function MultiSelectView({
} }
}} }}
/> />
))} );
})}
{/* 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)]"
@@ -80,7 +80,8 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
}, },
}; };
// State styles (embedded: Figma 20736-12668 borderless, darker grey block, white text) // Embedded (Figma 20736-12668): borderless grey block; default copy is
// tertiary, primary on focus so seeded modal fields stay muted until edit.
const getStateStyles = (): { const getStateStyles = (): {
textarea: string; textarea: string;
label: string; label: string;
@@ -89,13 +90,13 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
if (disabled) { if (disabled) {
return { return {
textarea: textarea:
"border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] cursor-not-allowed opacity-60", "border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-tertiary,#b4b4b4)] cursor-not-allowed opacity-60",
label: "text-[var(--color-content-default-secondary)]", label: "text-[var(--color-content-default-secondary)]",
}; };
} }
return { return {
textarea: textarea:
"border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-border-default-tertiary)] focus:ring-inset", "border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-tertiary,#b4b4b4)] placeholder:text-[var(--color-content-default-tertiary,#b4b4b4)] focus:text-[var(--color-content-default-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-border-default-tertiary)] focus:ring-inset",
label: "text-[var(--color-content-default-secondary)]", label: "text-[var(--color-content-default-secondary)]",
}; };
} }
@@ -48,7 +48,8 @@ export interface TextAreaProps extends Omit<
showHelpIcon?: boolean; showHelpIcon?: boolean;
/** /**
* Visual appearance. "embedded" matches Create modal sections (Figma 20736-12668): * Visual appearance. "embedded" matches Create modal sections (Figma 20736-12668):
* borderless, darker grey background, white text. "default" is standard bordered input. * borderless, darker grey background, tertiary text in default (primary on
* focus). "default" is the standard bordered input.
* @default "default" * @default "default"
*/ */
appearance?: TextAreaAppearanceValue; appearance?: TextAreaAppearanceValue;
@@ -36,9 +36,8 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
}, },
ref, ref,
) => { ) => {
return ( const labelEl = label ? (
<div className={containerClasses}> formHeader ? (
{formHeader && label && (
<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 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"> <div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label <label
@@ -60,7 +59,16 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
)} )}
</div> </div>
</div> </div>
)} ) : (
<label id={labelId} htmlFor={textareaId} className="sr-only">
{label}
</label>
)
) : null;
return (
<div className={containerClasses}>
{labelEl}
<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,12 +31,18 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
maxLength, maxLength,
helpIconAlt, helpIconAlt,
hintDefault, hintDefault,
autoComplete,
inputMode,
required = false,
form,
onKeyDown,
state: _state,
isFilled: _isFilled,
}, },
ref, ref,
) => { ) => {
return ( const labelEl = label ? (
<div className={containerClasses}> formHeader ? (
{formHeader && label && (
<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 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"> <div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label <label
@@ -58,7 +64,16 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
)} )}
</div> </div>
</div> </div>
)} ) : (
<label id={labelId} htmlFor={inputId} className="sr-only">
{label}
</label>
)
) : null;
return (
<div className={containerClasses}>
{labelEl}
<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;

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