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