Compare commits
20
Commits
aecb890cc8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77b0ec8ad8 | ||
|
|
98945ceb7e | ||
|
|
234f3998ad | ||
|
|
6ccc1e8c8e | ||
|
|
90db213413 | ||
|
|
3f9a8395be | ||
|
|
0e272f0c1e | ||
|
|
4055278628 | ||
|
|
42d83c8b62 | ||
|
|
130663f930 | ||
|
|
6c1d83e89d | ||
|
|
64a2ef5a00 | ||
|
|
95e1937419 | ||
|
|
ce581a42c8 | ||
|
|
ddf7b0ef7a | ||
|
|
4bd9fa6dc6 | ||
|
|
b42716c262 | ||
|
|
40225e2845 | ||
|
|
2d351987cf | ||
|
|
5242f8c6bb |
@@ -33,7 +33,7 @@ Reach for these before writing new markup:
|
|||||||
| `[– value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
|
| `[– value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
|
||||||
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
|
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
|
||||||
| Help-icon + label above a control | `app/components/type/InputLabel` (`helpIcon` prop) |
|
| Help-icon + label above a control | `app/components/type/InputLabel` (`helpIcon` prop) |
|
||||||
| Toggle chip (dim-but-clickable) | `Chip` with `state="Disabled" disabled={false}` |
|
| Toggle chip | `Chip` with `state="selected"` / `"unselected"` (Chip sets `aria-pressed`; selected includes a check mark) |
|
||||||
| Card-click → structured creation modal | `Create` with `backdropVariant="blurredYellow"` |
|
| Card-click → structured creation modal | `Create` with `backdropVariant="blurredYellow"` |
|
||||||
|
|
||||||
If a screen grows a 2nd inline copy of any pattern above, **extract a shared
|
If a screen grows a 2nd inline copy of any pattern above, **extract a shared
|
||||||
|
|||||||
+12
-10
@@ -14,10 +14,10 @@ the file tree without affecting URLs.
|
|||||||
|
|
||||||
| Group | URL surface | Audience | Chrome |
|
| Group | URL surface | Audience | Chrome |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | `Top` (via root) + marketing `<Footer />` |
|
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | `SkipToContent` + `ConditionalNavigation` + marketing `<Footer />` |
|
||||||
| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | `Top` (via root) — no footer except **`/profile`** (see `profile/layout.tsx`) |
|
| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | `SkipToContent` + `ConditionalNavigation` — no footer except **`/profile`** (see `profile/layout.tsx`) |
|
||||||
| `app/(admin)/` | `/monitor`, future ops dashboards | Operators | `Top` (via root) — no footer |
|
| `app/(admin)/` | `/monitor`, future ops dashboards | Operators | `SkipToContent` + `ConditionalNavigation` — no footer |
|
||||||
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | `Top` (via root) — no footer |
|
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | `Top` omitted — no footer |
|
||||||
| `app/(marketing-case-study)/` | `/use-cases/[slug]/rule` | Public case-study demos | Chromeless (no global `Top`; see `navigationChromelessPath.ts`) |
|
| `app/(marketing-case-study)/` | `/use-cases/[slug]/rule` | Public case-study demos | Chromeless (no global `Top`; see `navigationChromelessPath.ts`) |
|
||||||
| `app/api/` | API routes | n/a | n/a |
|
| `app/api/` | API routes | n/a | n/a |
|
||||||
|
|
||||||
@@ -28,14 +28,16 @@ the folder next to `(marketing)/`.
|
|||||||
## Layout responsibilities
|
## Layout responsibilities
|
||||||
|
|
||||||
- **`app/layout.tsx`** — `<html>`, `<body>`, providers (`MessagesProvider`,
|
- **`app/layout.tsx`** — `<html>`, `<body>`, providers (`MessagesProvider`,
|
||||||
`AuthModalProvider`), fonts, and `ConditionalNavigation`. Renders
|
`AuthModalProvider` are per group), fonts. Renders
|
||||||
`{children}` directly inside the flex column. **Does not** render
|
`{children}` directly inside the flex column. **Does not** render
|
||||||
`<main>` — each group layout owns that.
|
`<main>` or the header — each group layout owns that.
|
||||||
- **`app/(marketing)/layout.tsx`** — wraps with `<main className="flex-1">`
|
- **`app/(marketing)/layout.tsx`** — skip-to-content, `ConditionalNavigation`
|
||||||
and appends the public `<Footer />`.
|
(SSR session), `<main id="main-content">`, public `<Footer />`.
|
||||||
- **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** / **`(dev)/layout.tsx`** —
|
- **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** —
|
||||||
wrap with `<main className="flex-1">`. No footer by default; **`app/(app)/profile/layout.tsx`**
|
skip-to-content, `ConditionalNavigation`, `<main id="main-content">`.
|
||||||
|
No footer by default; **`app/(app)/profile/layout.tsx`**
|
||||||
appends the marketing `<Footer />` for `/profile` only.
|
appends the marketing `<Footer />` for `/profile` only.
|
||||||
|
- **`app/(dev)/layout.tsx`** — `<main id="main-content">` only.
|
||||||
- **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific
|
- **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific
|
||||||
chrome inside the group's `<main>` — never render `<html>`, `<body>`,
|
chrome inside the group's `<main>` — never render `<html>`, `<body>`,
|
||||||
`<main>`, or providers.
|
`<main>`, or providers.
|
||||||
|
|||||||
+10
-1
@@ -1,9 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Suspense, type ReactNode } from "react";
|
import { Suspense, type ReactNode } from "react";
|
||||||
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
|
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
|
||||||
|
import SkipToContent from "../components/navigation/SkipToContent";
|
||||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||||
import messages from "../../messages/en/index";
|
import messages from "../../messages/en/index";
|
||||||
|
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
|
||||||
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
|
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -20,10 +22,17 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<MessagesProvider messages={messages}>
|
<MessagesProvider messages={messages}>
|
||||||
<AuthModalProvider>
|
<AuthModalProvider>
|
||||||
|
<SkipToContent />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ConditionalNavigation />
|
<ConditionalNavigation />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<main className="flex-1">{children}</main>
|
<main
|
||||||
|
id={MAIN_CONTENT_ID}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="flex-1 outline-none"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</AuthModalProvider>
|
</AuthModalProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ import {
|
|||||||
shouldOfferCreateFlowSaveAndExit,
|
shouldOfferCreateFlowSaveAndExit,
|
||||||
isDirectTemplateReviewEntry,
|
isDirectTemplateReviewEntry,
|
||||||
createFlowStepUsesSelectSplitScroll,
|
createFlowStepUsesSelectSplitScroll,
|
||||||
TEMPLATES_FACET_RECOMMEND_QUERY,
|
isCreateFlowTemplatesPickerPath,
|
||||||
TEMPLATES_FACET_RECOMMEND_VALUE,
|
|
||||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
||||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
||||||
} from "./utils/flowSteps";
|
} from "./utils/flowSteps";
|
||||||
import {
|
import {
|
||||||
|
CREATE_FLOW_COMMUNITY_SAVE_FORM_ID,
|
||||||
CREATE_FLOW_SYNC_DRAFT_QUERY,
|
CREATE_FLOW_SYNC_DRAFT_QUERY,
|
||||||
CREATE_FLOW_SYNC_DRAFT_VALUE,
|
CREATE_FLOW_SYNC_DRAFT_VALUE,
|
||||||
CREATE_ROUTES,
|
CREATE_ROUTES,
|
||||||
@@ -75,7 +75,13 @@ import {
|
|||||||
CREATE_FLOW_FOOTER_BUTTON_CLASS,
|
CREATE_FLOW_FOOTER_BUTTON_CLASS,
|
||||||
CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS,
|
CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS,
|
||||||
} from "./utils/createFlowFooterClassNames";
|
} from "./utils/createFlowFooterClassNames";
|
||||||
import { CREATE_FLOW_MD_CENTERED_MAIN_CLASS } from "./components/createFlowLayoutTokens";
|
import {
|
||||||
|
CREATE_FLOW_MD_CENTERED_MAIN_CLASS,
|
||||||
|
CREATE_FLOW_PAGE_GUTTER_CLASS,
|
||||||
|
CREATE_FLOW_SCROLL_REGION_CLASS,
|
||||||
|
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
|
||||||
|
getCreateFlowContentMaxClass,
|
||||||
|
} from "./components/createFlowLayoutTokens";
|
||||||
import {
|
import {
|
||||||
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
|
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
|
||||||
methodCardFacetSectionForConfirmStep,
|
methodCardFacetSectionForConfirmStep,
|
||||||
@@ -302,6 +308,8 @@ function CreateFlowLayoutContent({
|
|||||||
fromCreateWizard,
|
fromCreateWizard,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
});
|
});
|
||||||
|
const isCreateTemplatesPickerRoute =
|
||||||
|
isCreateFlowTemplatesPickerPath(pathname);
|
||||||
|
|
||||||
const runAuthenticatedExit = useCreateFlowExit({
|
const runAuthenticatedExit = useCreateFlowExit({
|
||||||
state,
|
state,
|
||||||
@@ -482,12 +490,28 @@ function CreateFlowLayoutContent({
|
|||||||
return () => window.clearTimeout(timeoutId);
|
return () => window.clearTimeout(timeoutId);
|
||||||
}, [currentStep, sessionResolved, sessionUser, openLogin]);
|
}, [currentStep, sessionResolved, sessionUser, openLogin]);
|
||||||
|
|
||||||
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
|
const handleCommunitySaveMagicLinkSubmit = useCallback(async (
|
||||||
|
formEl?: HTMLFormElement | null,
|
||||||
|
) => {
|
||||||
setCommunitySaveMagicLinkError(null);
|
setCommunitySaveMagicLinkError(null);
|
||||||
setCommunitySaveMagicLinkSuccess(false);
|
setCommunitySaveMagicLinkSuccess(false);
|
||||||
const raw = state.communitySaveEmail;
|
const namedEmail = formEl?.elements.namedItem("email");
|
||||||
const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
const emailField =
|
||||||
if (!isValidCreateFlowSaveEmail(trimmed)) return;
|
namedEmail instanceof HTMLInputElement
|
||||||
|
? namedEmail
|
||||||
|
: typeof document === "undefined"
|
||||||
|
? null
|
||||||
|
: document.querySelector<HTMLInputElement>(
|
||||||
|
`input[name="email"][form="${CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}"]`,
|
||||||
|
);
|
||||||
|
if (emailField != null && !emailField.checkValidity()) {
|
||||||
|
emailField.reportValidity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw =
|
||||||
|
emailField != null ? emailField.value : state.communitySaveEmail;
|
||||||
|
if (typeof raw !== "string" || !isValidCreateFlowSaveEmail(raw)) return;
|
||||||
|
const trimmed = raw.trim().toLowerCase();
|
||||||
|
|
||||||
setCommunitySaveMagicLinkSubmitting(true);
|
setCommunitySaveMagicLinkSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -543,6 +567,8 @@ function CreateFlowLayoutContent({
|
|||||||
? "items-stretch overflow-y-auto md:overflow-hidden"
|
? "items-stretch overflow-y-auto md:overflow-hidden"
|
||||||
: isSelectSplitScrollStep
|
: isSelectSplitScrollStep
|
||||||
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
|
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
|
||||||
|
: isCreateTemplatesPickerRoute
|
||||||
|
? "items-start justify-start overflow-y-auto"
|
||||||
: isTemplateReviewRoute || isFinalReviewLike || isCardLayoutStep
|
: isTemplateReviewRoute || isFinalReviewLike || isCardLayoutStep
|
||||||
? CREATE_FLOW_MD_CENTERED_MAIN_CLASS
|
? CREATE_FLOW_MD_CENTERED_MAIN_CLASS
|
||||||
: "items-start justify-center overflow-y-auto md:items-center";
|
: "items-start justify-center overflow-y-auto md:items-center";
|
||||||
@@ -556,10 +582,9 @@ function CreateFlowLayoutContent({
|
|||||||
? "max-md:flex-col max-md:items-stretch"
|
? "max-md:flex-col max-md:items-stretch"
|
||||||
: "max-md:flex-col max-md:items-center";
|
: "max-md:flex-col max-md:items-center";
|
||||||
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
|
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
|
||||||
const saveDraftOnExit = shouldOfferCreateFlowSaveAndExit(
|
const saveDraftOnExit =
|
||||||
currentStep,
|
shouldOfferCreateFlowSaveAndExit(currentStep, sessionUser) ||
|
||||||
sessionUser,
|
isCreateTemplatesPickerRoute;
|
||||||
);
|
|
||||||
|
|
||||||
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
|
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
|
||||||
currentStep,
|
currentStep,
|
||||||
@@ -651,10 +676,10 @@ function CreateFlowLayoutContent({
|
|||||||
].filter((b): b is NonNullable<typeof b> => b !== null);
|
].filter((b): b is NonNullable<typeof b> => b !== null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
|
<div className={`${CREATE_FLOW_VIEWPORT_FRAME_CLASS} bg-black`}>
|
||||||
{topBanners.length > 0 ? (
|
{topBanners.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]"
|
className={`pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-[var(--spacing-measures-spacing-300,12px)]`}
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
{topBanners.map((b) => (
|
{topBanners.map((b) => (
|
||||||
@@ -770,16 +795,32 @@ function CreateFlowLayoutContent({
|
|||||||
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
|
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
|
||||||
}`.trim()}
|
}`.trim()}
|
||||||
/>
|
/>
|
||||||
|
{currentStep === "community-save" ? (
|
||||||
|
<form
|
||||||
|
id={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
|
||||||
|
className="hidden"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleCommunitySaveMagicLinkSubmit(e.currentTarget);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<main
|
<main
|
||||||
className={`flex min-h-0 min-w-0 flex-1 w-full max-w-full overflow-x-hidden ${mainContentClass} ${mainResponsiveLayout}`}
|
className={`flex min-h-0 min-w-0 flex-1 w-full max-w-full overflow-x-hidden ${CREATE_FLOW_SCROLL_REGION_CLASS} ${mainContentClass} ${mainResponsiveLayout}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
{!isCompletedStep && (
|
{!isCompletedStep && (
|
||||||
<CreateFlowFooter
|
<CreateFlowFooter
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
|
contentMaxClass={getCreateFlowContentMaxClass({
|
||||||
|
step: currentStep,
|
||||||
|
isTemplateReview: isTemplateReviewRoute,
|
||||||
|
isTemplatesPicker: isCreateTemplatesPickerRoute,
|
||||||
|
})}
|
||||||
progressBar={
|
progressBar={
|
||||||
!isTemplateReviewRoute &&
|
!isTemplateReviewRoute &&
|
||||||
|
!isCreateTemplatesPickerRoute &&
|
||||||
!isFinalReviewLike &&
|
!isFinalReviewLike &&
|
||||||
reviewReturnTarget !== "edit-rule"
|
reviewReturnTarget !== "edit-rule"
|
||||||
}
|
}
|
||||||
@@ -841,6 +882,8 @@ function CreateFlowLayoutContent({
|
|||||||
{footer.saveLater}
|
{footer.saveLater}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
|
||||||
buttonType="filled"
|
buttonType="filled"
|
||||||
palette="default"
|
palette="default"
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
@@ -851,9 +894,6 @@ function CreateFlowLayoutContent({
|
|||||||
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
|
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
|
||||||
}
|
}
|
||||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
onClick={() => {
|
|
||||||
void handleCommunitySaveMagicLinkSubmit();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{communitySaveMagicLinkSubmitting
|
{communitySaveMagicLinkSubmitting
|
||||||
? footer.submitEmailSending
|
? footer.submitEmailSending
|
||||||
@@ -884,15 +924,7 @@ function CreateFlowLayoutContent({
|
|||||||
disabled={isPublishing}
|
disabled={isPublishing}
|
||||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// `fromFlow=1` tells `/templates` to skip the fresh-slate
|
router.push(CREATE_ROUTES.templatesPicker);
|
||||||
// draft clear it normally runs on template click, so the
|
|
||||||
// user's in-progress Create Community stage survives this
|
|
||||||
// detour. Direct entries to `/templates` (no marker) and
|
|
||||||
// home "Popular templates" clicks always start fresh by
|
|
||||||
// wiping anonymous draft storage at click time.
|
|
||||||
router.push(
|
|
||||||
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{footer.createFromTemplate}
|
{footer.createFromTemplate}
|
||||||
@@ -977,7 +1009,11 @@ function CreateFlowLayoutContent({
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
onBackClick={
|
onBackClick={
|
||||||
isTemplateReviewRoute
|
isCreateTemplatesPickerRoute
|
||||||
|
? () => {
|
||||||
|
router.push(CREATE_ROUTES.review);
|
||||||
|
}
|
||||||
|
: isTemplateReviewRoute
|
||||||
? () =>
|
? () =>
|
||||||
router.push(
|
router.push(
|
||||||
templateReviewFooterBackToCreateReview
|
templateReviewFooterBackToCreateReview
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import {
|
|||||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||||
import { fetchDraftFromServer } from "../../../lib/create/api";
|
import { fetchDraftFromServer } from "../../../lib/create/api";
|
||||||
import messages from "../../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
|
import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./components/createFlowLayoutTokens";
|
||||||
import Alert from "../../components/modals/Alert";
|
import Alert from "../../components/modals/Alert";
|
||||||
import {
|
import {
|
||||||
isValidStep,
|
isValidStep,
|
||||||
|
isCreateFlowTemplatesPickerPath,
|
||||||
parseCreateFlowScreenFromPathname,
|
parseCreateFlowScreenFromPathname,
|
||||||
} from "./utils/flowSteps";
|
} from "./utils/flowSteps";
|
||||||
import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry";
|
import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry";
|
||||||
@@ -110,6 +112,9 @@ export function SignedInDraftHydration({
|
|||||||
if (pathname?.includes("/create/review-template/")) {
|
if (pathname?.includes("/create/review-template/")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isCreateFlowTemplatesPickerPath(pathname)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (touchedRef.current) {
|
if (touchedRef.current) {
|
||||||
finishedUserIdRef.current = userId;
|
finishedUserIdRef.current = userId;
|
||||||
return;
|
return;
|
||||||
@@ -161,7 +166,7 @@ export function SignedInDraftHydration({
|
|||||||
if (!loadingHydration) return null;
|
if (!loadingHydration) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center px-[var(--spacing-measures-spacing-500,20px)] pt-2 md:top-16 md:px-[var(--measures-spacing-1800,64px)]">
|
<div className={`pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-2 md:top-16`}>
|
||||||
<div className="pointer-events-auto w-full max-w-[960px]">
|
<div className="pointer-events-auto w-full max-w-[960px]">
|
||||||
<Alert
|
<Alert
|
||||||
type="banner"
|
type="banner"
|
||||||
|
|||||||
@@ -74,11 +74,16 @@ function ApplicableScopeFieldComponent({
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{scopes.map((scope) => {
|
{scopes.map((scope) => {
|
||||||
const isSelected = selectedScopes.includes(scope);
|
const isSelected = selectedScopes.includes(scope);
|
||||||
|
const chipState = isSelected
|
||||||
|
? "selected"
|
||||||
|
: readOnly
|
||||||
|
? "disabled"
|
||||||
|
: "unselected";
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
key={scope}
|
key={scope}
|
||||||
label={scope}
|
label={scope}
|
||||||
state={isSelected ? "selected" : "disabled"}
|
state={chipState}
|
||||||
palette="default"
|
palette="default"
|
||||||
size="s"
|
size="s"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup";
|
||||||
|
import { CreateFlowStepShell } from "./CreateFlowStepShell";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||||
|
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
|
||||||
|
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||||
|
CREATE_FLOW_WIDE_MAX_CLASS,
|
||||||
|
} from "./createFlowLayoutTokens";
|
||||||
|
|
||||||
|
type CreateFlowCardStackStepShellProps = {
|
||||||
|
lockupTitle: string;
|
||||||
|
lockupDescription?: ReactNode;
|
||||||
|
/** When true, the stack region exposes `aria-busy` while recommendations load. */
|
||||||
|
stackBusy?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centered lockup + card-stack layout shared by communication, membership, and
|
||||||
|
* conflict-management steps (Figma compact card stack).
|
||||||
|
*/
|
||||||
|
export function CreateFlowCardStackStepShell({
|
||||||
|
lockupTitle,
|
||||||
|
lockupDescription,
|
||||||
|
stackBusy = false,
|
||||||
|
children,
|
||||||
|
}: CreateFlowCardStackStepShellProps) {
|
||||||
|
return (
|
||||||
|
<CreateFlowStepShell
|
||||||
|
variant="wideGridLoosePadding"
|
||||||
|
contentTopBelowMd="space-800"
|
||||||
|
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex w-full min-w-0 flex-col items-center gap-6 ${CREATE_FLOW_WIDE_MAX_CLASS}`}
|
||||||
|
>
|
||||||
|
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
|
||||||
|
<CreateFlowHeaderLockup
|
||||||
|
title={lockupTitle}
|
||||||
|
description={lockupDescription}
|
||||||
|
justification="center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
|
||||||
|
aria-busy={stackBusy}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||||
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
|
import {
|
||||||
|
CreateFlowUploadValidationError,
|
||||||
|
uploadCreateFlowFile,
|
||||||
|
} from "../../../../lib/create/uploadToServer";
|
||||||
import {
|
import {
|
||||||
clearPendingCommunityAvatarFile,
|
clearPendingCommunityAvatarFile,
|
||||||
readPendingCommunityAvatarFile,
|
readPendingCommunityAvatarFile,
|
||||||
@@ -19,13 +22,17 @@ export function CreateFlowPendingAvatarFlush({
|
|||||||
sessionUser: { id: string; email: string } | null | undefined;
|
sessionUser: { id: string; email: string } | null | undefined;
|
||||||
sessionResolved: boolean;
|
sessionResolved: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { updateState } = useCreateFlow();
|
const { state, updateState } = useCreateFlow();
|
||||||
/** One successful flush per signed-in user id (survives React StrictMode remounts). */
|
/** One successful flush per signed-in user id (survives React StrictMode remounts). */
|
||||||
const lastFlushedUserIdRef = useRef<string | null>(null);
|
const lastFlushedUserIdRef = useRef<string | null>(null);
|
||||||
|
const hasServerAvatar =
|
||||||
|
typeof state.communityAvatarUrl === "string" &&
|
||||||
|
state.communityAvatarUrl.trim().length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionResolved || !sessionUser) return;
|
if (!sessionResolved || !sessionUser) return;
|
||||||
if (lastFlushedUserIdRef.current === sessionUser.id) return;
|
if (lastFlushedUserIdRef.current === sessionUser.id) return;
|
||||||
|
if (hasServerAvatar) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -37,15 +44,18 @@ export function CreateFlowPendingAvatarFlush({
|
|||||||
await clearPendingCommunityAvatarFile();
|
await clearPendingCommunityAvatarFile();
|
||||||
updateState({ communityAvatarUrl: url });
|
updateState({ communityAvatarUrl: url });
|
||||||
lastFlushedUserIdRef.current = sessionUser.id;
|
lastFlushedUserIdRef.current = sessionUser.id;
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Leave pending blob in place so the user can retry after fixing auth / UPLOAD_ROOT.
|
if (err instanceof CreateFlowUploadValidationError) {
|
||||||
|
await clearPendingCommunityAvatarFile();
|
||||||
|
}
|
||||||
|
// Leave a transient (auth / UPLOAD_ROOT) failure in place to retry.
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [sessionResolved, sessionUser, updateState]);
|
}, [hasServerAvatar, sessionResolved, sessionUser, updateState]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./createFlowLayoutTokens";
|
||||||
|
|
||||||
export type CreateFlowStepShellVariant =
|
export type CreateFlowStepShellVariant =
|
||||||
| "centeredNarrow"
|
| "centeredNarrow"
|
||||||
@@ -13,15 +14,12 @@ export type CreateFlowStepShellVariant =
|
|||||||
export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800";
|
export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800";
|
||||||
|
|
||||||
const outerByVariant: Record<CreateFlowStepShellVariant, string> = {
|
const outerByVariant: Record<CreateFlowStepShellVariant, string> = {
|
||||||
centeredNarrow:
|
centeredNarrow: `flex w-full min-w-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
|
||||||
"flex w-full min-w-0 flex-col items-center px-5 md:px-16",
|
centeredNarrowBottomPad: `flex w-full min-w-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS} pb-28 md:pb-32`,
|
||||||
centeredNarrowBottomPad:
|
/** Wide two-column steps; inner content supplies the 1328px cap. */
|
||||||
"flex w-full min-w-0 flex-col items-center px-5 pb-28 md:px-[var(--measures-spacing-1800,64px)] md:pb-32",
|
wideGrid: `flex w-full min-w-0 shrink-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
|
||||||
/** Wide two-column steps; 1328px = two 640px columns + 48px gutter. */
|
/** Create Community review + card grid; inner content supplies the 1440px cap. */
|
||||||
wideGrid: "w-full min-w-0 max-w-[1328px] shrink-0 px-5 md:px-12",
|
wideGridLoosePadding: `flex w-full min-w-0 shrink-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
|
||||||
/** Create Community review + card grid (Figma Flow — Review `19706:12135`): max width 1440. */
|
|
||||||
wideGridLoosePadding:
|
|
||||||
"w-full min-w-0 max-w-[1440px] shrink-0 px-5 md:px-16",
|
|
||||||
bare: "w-full min-w-0",
|
bare: "w-full min-w-0",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,7 +39,8 @@ interface CreateFlowStepShellProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared horizontal padding and width constraints for create-flow step pages.
|
* Shared horizontal padding and width constraints for create-flow step pages.
|
||||||
* Horizontal padding uses Tailwind `md:` so it tracks `--breakpoint-md` (640px in `app/tailwind.css`).
|
* Gutters come from {@link CREATE_FLOW_PAGE_GUTTER_CLASS} (`md` / `lg` track
|
||||||
|
* `--breakpoint-md` and `--breakpoint-lg` in `app/tailwind.css`).
|
||||||
*/
|
*/
|
||||||
export function CreateFlowStepShell({
|
export function CreateFlowStepShell({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import {
|
|||||||
CreateFlowStepShell,
|
CreateFlowStepShell,
|
||||||
type CreateFlowContentTopBelowMd,
|
type CreateFlowContentTopBelowMd,
|
||||||
} from "./CreateFlowStepShell";
|
} from "./CreateFlowStepShell";
|
||||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens";
|
import {
|
||||||
|
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||||
|
CREATE_FLOW_SCROLL_REGION_CLASS,
|
||||||
|
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||||
|
} from "./createFlowLayoutTokens";
|
||||||
|
|
||||||
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
|
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
|
||||||
|
|
||||||
@@ -26,10 +30,10 @@ interface CreateFlowTwoColumnSelectShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Two-column layout for create-flow select steps (community size/structure, core values) and
|
* Question-and-options layout for create-flow select steps (community size/structure,
|
||||||
* {@link DecisionApproachesScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls.
|
* core values, stakeholders) and {@link DecisionApproachesScreen}.
|
||||||
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column
|
* Below `lg` (1024px), one column uses available width and main scrolls.
|
||||||
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail).
|
* At `lg+`, static header column + scrollable options column (`min-h-0` + overflow).
|
||||||
*/
|
*/
|
||||||
export function CreateFlowTwoColumnSelectShell({
|
export function CreateFlowTwoColumnSelectShell({
|
||||||
header,
|
header,
|
||||||
@@ -56,24 +60,24 @@ export function CreateFlowTwoColumnSelectShell({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] " +
|
"flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] " +
|
||||||
"max-lg:flex-none lg:max-h-full lg:max-w-[1328px] lg:min-h-0 lg:flex-1 lg:flex-row lg:flex-nowrap " +
|
"max-lg:flex-none lg:max-h-full lg:min-h-0 lg:flex-1 lg:flex-row lg:flex-nowrap " +
|
||||||
`${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden`
|
`${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
`flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` +
|
`flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` +
|
||||||
`lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] lg:max-w-[640px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
|
`lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{header}
|
{header}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
`scrollbar-hide relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` +
|
`relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` +
|
||||||
`overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-[var(--measures-spacing-300,12px)] ` +
|
`overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-[var(--measures-spacing-300,12px)] ` +
|
||||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS
|
`${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
+6
-3
@@ -3,7 +3,10 @@
|
|||||||
import { memo, useCallback, useRef, useState } from "react";
|
import { memo, useCallback, useRef, useState } from "react";
|
||||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
import {
|
||||||
|
createFlowUploadFailureMessageKey,
|
||||||
|
uploadCreateFlowFile,
|
||||||
|
} from "../../../../../lib/create/uploadToServer";
|
||||||
import { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view";
|
import { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view";
|
||||||
import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types";
|
import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types";
|
||||||
|
|
||||||
@@ -68,8 +71,8 @@ function CustomMethodCardUploadBlockRowContainerComponent({
|
|||||||
: b,
|
: b,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (err) {
|
||||||
setErrorMessage(tUpload("errors.generic"));
|
setErrorMessage(tUpload(createFlowUploadFailureMessageKey(err)));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -4,6 +4,7 @@ import { memo } from "react";
|
|||||||
import Upload from "../../../../components/controls/Upload";
|
import Upload from "../../../../components/controls/Upload";
|
||||||
import InputLabel from "../../../../components/type/InputLabel";
|
import InputLabel from "../../../../components/type/InputLabel";
|
||||||
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
||||||
|
import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation";
|
||||||
import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types";
|
import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types";
|
||||||
|
|
||||||
function CustomMethodCardUploadBlockRowViewComponent({
|
function CustomMethodCardUploadBlockRowViewComponent({
|
||||||
@@ -43,7 +44,7 @@ function CustomMethodCardUploadBlockRowViewComponent({
|
|||||||
type="file"
|
type="file"
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif,application/pdf"
|
accept={CUSTOM_ATTACHMENT_ACCEPT}
|
||||||
aria-label={uploadFileInputAriaLabel}
|
aria-label={uploadFileInputAriaLabel}
|
||||||
onChange={onFileInputChange}
|
onChange={onFileInputChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+4
-2
@@ -8,6 +8,7 @@ import {
|
|||||||
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
|
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
|
||||||
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||||
|
import { createFlowUploadFailureMessageKey } from "../../../../../lib/create/uploadToServer";
|
||||||
import {
|
import {
|
||||||
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS,
|
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS,
|
||||||
CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS,
|
CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS,
|
||||||
@@ -462,8 +463,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
try {
|
try {
|
||||||
const { url } = await onPersistCustomUploadFile(file);
|
const { url } = await onPersistCustomUploadFile(file);
|
||||||
setUploadAssetUrl(url);
|
setUploadAssetUrl(url);
|
||||||
} catch {
|
} catch (err) {
|
||||||
setUploadFieldError(tUpload("errors.generic"));
|
setUploadFileName(undefined);
|
||||||
|
setUploadFieldError(tUpload(createFlowUploadFailureMessageKey(err)));
|
||||||
} finally {
|
} finally {
|
||||||
setUploadFieldBusy(false);
|
setUploadFieldBusy(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ function CustomMethodCardWizardViewComponent({
|
|||||||
<TextArea
|
<TextArea
|
||||||
appearance="default"
|
appearance="default"
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
|
showHelpIcon={false}
|
||||||
|
label={copy.step2.title}
|
||||||
placeholder={copy.step2.fieldPlaceholder}
|
placeholder={copy.step2.fieldPlaceholder}
|
||||||
value={policyDescription}
|
value={policyDescription}
|
||||||
maxLength={maxDescriptionChars}
|
maxLength={maxDescriptionChars}
|
||||||
|
|||||||
+10
-2
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo, useId } from "react";
|
||||||
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
||||||
import InputWithCounter from "../../../../components/controls/InputWithCounter";
|
import InputWithCounter from "../../../../components/controls/InputWithCounter";
|
||||||
import TextArea from "../../../../components/controls/TextArea";
|
import TextArea from "../../../../components/controls/TextArea";
|
||||||
@@ -10,6 +10,7 @@ import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
|
|||||||
import InputLabel from "../../../../components/type/InputLabel";
|
import InputLabel from "../../../../components/type/InputLabel";
|
||||||
import ApplicableScopeField from "../ApplicableScopeField";
|
import ApplicableScopeField from "../ApplicableScopeField";
|
||||||
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
|
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
|
||||||
|
import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation";
|
||||||
import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types";
|
import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types";
|
||||||
|
|
||||||
const TEXT_PLACEHOLDER_MAX = 8000;
|
const TEXT_PLACEHOLDER_MAX = 8000;
|
||||||
@@ -39,6 +40,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
onProportionBlockTitleChange,
|
onProportionBlockTitleChange,
|
||||||
onProportionDefaultChange,
|
onProportionDefaultChange,
|
||||||
}: CustomMethodCardWizardFieldBodiesViewProps) {
|
}: CustomMethodCardWizardFieldBodiesViewProps) {
|
||||||
|
const placeholderFieldId = useId();
|
||||||
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
|
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
|
||||||
const hasUploadPreview = uploadPreviewTrimmed.length > 0;
|
const hasUploadPreview = uploadPreviewTrimmed.length > 0;
|
||||||
|
|
||||||
@@ -53,10 +55,14 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]">
|
<label
|
||||||
|
htmlFor={placeholderFieldId}
|
||||||
|
className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"
|
||||||
|
>
|
||||||
{copy.text.placeholderLabel}
|
{copy.text.placeholderLabel}
|
||||||
</label>
|
</label>
|
||||||
<TextArea
|
<TextArea
|
||||||
|
id={placeholderFieldId}
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
appearance="embedded"
|
appearance="embedded"
|
||||||
value={textPlaceholderBody}
|
value={textPlaceholderBody}
|
||||||
@@ -84,6 +90,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
|
label={copy.badges.blockTitleLabel}
|
||||||
placeholder={copy.badges.blockTitlePlaceholder}
|
placeholder={copy.badges.blockTitlePlaceholder}
|
||||||
value={badgeBlockTitle}
|
value={badgeBlockTitle}
|
||||||
onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
|
onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
|
||||||
@@ -113,6 +120,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
type="file"
|
type="file"
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
accept={CUSTOM_ATTACHMENT_ACCEPT}
|
||||||
aria-label={copy.upload.uploadFileInputAriaLabel}
|
aria-label={copy.upload.uploadFileInputAriaLabel}
|
||||||
onChange={onFileChosen}
|
onChange={onFileChosen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
|
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
|
||||||
import Create from "../../../components/modals/Create";
|
import Create from "../../../components/modals/Create";
|
||||||
import TextInput from "../../../components/controls/TextInput";
|
import TextArea from "../../../components/controls/TextArea";
|
||||||
import ContentLockup from "../../../components/type/ContentLockup";
|
import ContentLockup from "../../../components/type/ContentLockup";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
|
||||||
@@ -95,18 +95,20 @@ export function FinalReviewCommunityContextEditModal({
|
|||||||
ariaLabel={tModal("title")}
|
ariaLabel={tModal("title")}
|
||||||
>
|
>
|
||||||
<div className="pb-2">
|
<div className="pb-2">
|
||||||
<TextInput
|
<TextArea
|
||||||
className="!transition-none"
|
className="!transition-none"
|
||||||
type="text"
|
label={tModal("title")}
|
||||||
placeholder={tField("placeholder")}
|
placeholder={tField("placeholder")}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setDraft(e.target.value);
|
setDraft(e.target.value);
|
||||||
}}
|
}}
|
||||||
inputSize="medium"
|
size="medium"
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
|
showHelpIcon={false}
|
||||||
textHint={characterHint}
|
textHint={characterHint}
|
||||||
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
|
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
|
||||||
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Create>
|
</Create>
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export function FinalReviewTitleEditModal({
|
|||||||
<TextInput
|
<TextInput
|
||||||
className="!transition-none"
|
className="!transition-none"
|
||||||
type="text"
|
type="text"
|
||||||
|
label={tModal("title")}
|
||||||
placeholder={tField("placeholder")}
|
placeholder={tField("placeholder")}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -100,8 +101,10 @@ export function FinalReviewTitleEditModal({
|
|||||||
}}
|
}}
|
||||||
inputSize="medium"
|
inputSize="medium"
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
|
showHelpIcon={false}
|
||||||
textHint={characterHint}
|
textHint={characterHint}
|
||||||
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
|
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Create>
|
</Create>
|
||||||
|
|||||||
@@ -1,13 +1,47 @@
|
|||||||
/** Single column/section: full width under `md`, max 640px from `--breakpoint-md` up. */
|
import type { CreateFlowStep } from "../types";
|
||||||
|
import { CREATE_FLOW_SCREEN_REGISTRY } from "../utils/createFlowScreenRegistry";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared page gutters for create-flow chrome and step shells.
|
||||||
|
* Six viewport bands collapse to three values: 20px below `md`, 48px from `md`,
|
||||||
|
* 64px from `lg` (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 =
|
export const CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS =
|
||||||
"w-full min-w-0 md:max-w-[640px]";
|
"w-full min-w-0 lg:max-w-[640px]";
|
||||||
|
|
||||||
/** Grid cell: same cap as column max, centered when the track is wider than 640px. */
|
/** Grid cell: same cap as column max, centered when the track is wider than 640px. */
|
||||||
export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS =
|
export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS =
|
||||||
"w-full min-w-0 md:mx-auto md:max-w-[640px]";
|
"w-full min-w-0 lg:mx-auto lg:max-w-[640px]";
|
||||||
|
|
||||||
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
|
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
|
||||||
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]";
|
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS =
|
||||||
|
"w-full min-w-0 lg:max-w-[1328px]";
|
||||||
|
|
||||||
|
/** Review / card-stack frames (Figma Flow — Review max 1440). */
|
||||||
|
export const CREATE_FLOW_WIDE_MAX_CLASS = "w-full min-w-0 lg:max-w-[1440px]";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lockup+card and card-stack `<main>`: keep `items-start` so a tall card can
|
* Lockup+card and card-stack `<main>`: keep `items-start` so a tall card can
|
||||||
@@ -29,3 +63,31 @@ export const CREATE_FLOW_MD_CENTERED_SHELL_CLASS = "md:my-auto md:pt-0";
|
|||||||
*/
|
*/
|
||||||
export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
|
export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
|
||||||
"w-full min-w-0 md:max-w-[min(100%,860px)]";
|
"w-full min-w-0 md:max-w-[min(100%,860px)]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inner max-width for step content and the action bar, so the primary button
|
||||||
|
* lines up with the nearest content column above 1440.
|
||||||
|
*/
|
||||||
|
export function getCreateFlowContentMaxClass(options: {
|
||||||
|
step: CreateFlowStep | null | undefined;
|
||||||
|
isTemplateReview?: boolean;
|
||||||
|
isTemplatesPicker?: boolean;
|
||||||
|
}): string {
|
||||||
|
if (options.isTemplateReview || options.isTemplatesPicker) {
|
||||||
|
return CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS;
|
||||||
|
}
|
||||||
|
if (!options.step) {
|
||||||
|
return CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS;
|
||||||
|
}
|
||||||
|
switch (CREATE_FLOW_SCREEN_REGISTRY[options.step].layoutKind) {
|
||||||
|
case "select":
|
||||||
|
case "right-rail":
|
||||||
|
case "completed":
|
||||||
|
case "review":
|
||||||
|
return CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS;
|
||||||
|
case "card":
|
||||||
|
return CREATE_FLOW_WIDE_MAX_CLASS;
|
||||||
|
default:
|
||||||
|
return CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function useCompletedRuleShareExport({
|
|||||||
}: {
|
}: {
|
||||||
setActionBanner: (_: CompletedFlowActionBanner | null) => void;
|
setActionBanner: (_: CompletedFlowActionBanner | null) => void;
|
||||||
}): {
|
}): {
|
||||||
copyPublishedRuleLink: () => Promise<void>;
|
copyPublishedRuleLink: () => Promise<boolean>;
|
||||||
mailtoPublishedRule: () => void;
|
mailtoPublishedRule: () => void;
|
||||||
sharePublishedRuleViaSignal: () => Promise<void>;
|
sharePublishedRuleViaSignal: () => Promise<void>;
|
||||||
sharePublishedRuleViaSlack: () => Promise<void>;
|
sharePublishedRuleViaSlack: () => Promise<void>;
|
||||||
@@ -137,32 +137,34 @@ export function useCompletedRuleShareExport({
|
|||||||
url: string,
|
url: string,
|
||||||
banner?: () => void,
|
banner?: () => void,
|
||||||
options?: { suppressFailureWhenDocumentNotFocused?: boolean },
|
options?: { suppressFailureWhenDocumentNotFocused?: boolean },
|
||||||
) => {
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
(banner ?? bannerCopied)();
|
(banner ?? bannerCopied)();
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
if (
|
if (
|
||||||
options?.suppressFailureWhenDocumentNotFocused === true &&
|
options?.suppressFailureWhenDocumentNotFocused === true &&
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
shouldSkipShareClipboardFallback(window)
|
shouldSkipShareClipboardFallback(window)
|
||||||
) {
|
) {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
bannerCopyFailed();
|
bannerCopyFailed();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[bannerCopied, bannerCopyFailed],
|
[bannerCopied, bannerCopyFailed],
|
||||||
);
|
);
|
||||||
|
|
||||||
const copyPublishedRuleLink = useCallback(async () => {
|
const copyPublishedRuleLink = useCallback(async () => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return false;
|
||||||
const ctx = resolvePublishedRuleShareContext(window);
|
const ctx = resolvePublishedRuleShareContext(window);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
bannerNoRule();
|
bannerNoRule();
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
await copyUrlToClipboard(ctx.url);
|
return copyUrlToClipboard(ctx.url);
|
||||||
}, [bannerNoRule, copyUrlToClipboard]);
|
}, [bannerNoRule, copyUrlToClipboard]);
|
||||||
|
|
||||||
const mailtoPublishedRule = useCallback(() => {
|
const mailtoPublishedRule = useCallback(() => {
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ const blurActiveElement = (): void => {
|
|||||||
*
|
*
|
||||||
* Template review footer Back uses {@link buildTemplateReviewHref}’s
|
* Template review footer Back uses {@link buildTemplateReviewHref}’s
|
||||||
* `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so
|
* `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so
|
||||||
* users who came from `/create/review` return there instead of `/`.
|
* users who came from `/create/review` via `/create/templates` return there
|
||||||
|
* instead of `/`.
|
||||||
*/
|
*/
|
||||||
export function useCreateFlowNavigation(
|
export function useCreateFlowNavigation(
|
||||||
options?: CreateFlowNavigationOptions,
|
options?: CreateFlowNavigationOptions,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
export default function CreateFlowRouteLoading() {
|
export default function CreateFlowRouteLoading() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-screen min-h-0 flex-col overflow-hidden bg-[var(--color-surface-default-primary)]"
|
className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-[var(--color-surface-default-primary)]"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*
|
*
|
||||||
* Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
|
* Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
|
||||||
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
|
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
|
||||||
* reuse `CardStack` / `CreateFlowStepShell` as needed.
|
* reuse `CardStack` / `CreateFlowCardStackStepShell` as needed.
|
||||||
*
|
*
|
||||||
* Card click opens the Figma create modal (node `20246-15829`) with three
|
* Card click opens the Figma create modal (node `20246-15829`) with three
|
||||||
* editable sections rendered by {@link CommunicationMethodEditFields}. The
|
* editable sections rendered by {@link CommunicationMethodEditFields}. The
|
||||||
@@ -22,16 +22,10 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
|||||||
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
|
||||||
import CardStack from "../../../../components/cards/CardStack";
|
import CardStack from "../../../../components/cards/CardStack";
|
||||||
import Create from "../../../../components/modals/Create";
|
import Create from "../../../../components/modals/Create";
|
||||||
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
|
||||||
import {
|
|
||||||
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
|
||||||
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
|
|
||||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
|
||||||
} from "../../components/createFlowLayoutTokens";
|
|
||||||
import { CommunicationMethodEditFields } from "../../components/methodEditFields";
|
import { CommunicationMethodEditFields } from "../../components/methodEditFields";
|
||||||
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||||
@@ -700,22 +694,10 @@ export function CommunicationMethodsScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateFlowStepShell
|
<CreateFlowCardStackStepShell
|
||||||
variant="wideGridLoosePadding"
|
lockupTitle={title}
|
||||||
contentTopBelowMd="space-800"
|
lockupDescription={description}
|
||||||
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
|
stackBusy={!recommendationsReady}
|
||||||
>
|
|
||||||
<div className="flex w-full min-w-0 flex-col items-center gap-6">
|
|
||||||
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
|
|
||||||
<CreateFlowHeaderLockup
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
justification="center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
|
|
||||||
aria-busy={!recommendationsReady}
|
|
||||||
>
|
>
|
||||||
{recommendationsReady && (
|
{recommendationsReady && (
|
||||||
<CardStack
|
<CardStack
|
||||||
@@ -735,9 +717,7 @@ export function CommunicationMethodsScreen() {
|
|||||||
headerLockupSize={mdUp ? "L" : "M"}
|
headerLockupSize={mdUp ? "L" : "M"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CreateFlowCardStackStepShell>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Create
|
<Create
|
||||||
isOpen={createModalOpen}
|
isOpen={createModalOpen}
|
||||||
onClose={handleCreateModalClose}
|
onClose={handleCreateModalClose}
|
||||||
@@ -780,7 +760,6 @@ export function CommunicationMethodsScreen() {
|
|||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</Create>
|
</Create>
|
||||||
</CreateFlowStepShell>
|
|
||||||
<CustomMethodCardWizard
|
<CustomMethodCardWizard
|
||||||
isOpen={addCustomWizardOpen}
|
isOpen={addCustomWizardOpen}
|
||||||
onClose={handleCloseAddWizard}
|
onClose={handleCloseAddWizard}
|
||||||
|
|||||||
@@ -19,16 +19,10 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
|||||||
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
|
||||||
import CardStack from "../../../../components/cards/CardStack";
|
import CardStack from "../../../../components/cards/CardStack";
|
||||||
import Create from "../../../../components/modals/Create";
|
import Create from "../../../../components/modals/Create";
|
||||||
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
|
||||||
import {
|
|
||||||
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
|
||||||
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
|
|
||||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
|
||||||
} from "../../components/createFlowLayoutTokens";
|
|
||||||
import { ConflictManagementEditFields } from "../../components/methodEditFields";
|
import { ConflictManagementEditFields } from "../../components/methodEditFields";
|
||||||
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||||
@@ -701,22 +695,10 @@ export function ConflictManagementScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateFlowStepShell
|
<CreateFlowCardStackStepShell
|
||||||
variant="wideGridLoosePadding"
|
lockupTitle={title}
|
||||||
contentTopBelowMd="space-800"
|
lockupDescription={description}
|
||||||
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
|
stackBusy={!recommendationsReady}
|
||||||
>
|
|
||||||
<div className="flex w-full min-w-0 flex-col items-center gap-6">
|
|
||||||
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
|
|
||||||
<CreateFlowHeaderLockup
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
justification="center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
|
|
||||||
aria-busy={!recommendationsReady}
|
|
||||||
>
|
>
|
||||||
{recommendationsReady && (
|
{recommendationsReady && (
|
||||||
<CardStack
|
<CardStack
|
||||||
@@ -736,9 +718,7 @@ export function ConflictManagementScreen() {
|
|||||||
headerLockupSize={mdUp ? "L" : "M"}
|
headerLockupSize={mdUp ? "L" : "M"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CreateFlowCardStackStepShell>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Create
|
<Create
|
||||||
isOpen={createModalOpen}
|
isOpen={createModalOpen}
|
||||||
onClose={handleCreateModalClose}
|
onClose={handleCreateModalClose}
|
||||||
@@ -781,7 +761,6 @@ export function ConflictManagementScreen() {
|
|||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</Create>
|
</Create>
|
||||||
</CreateFlowStepShell>
|
|
||||||
<CustomMethodCardWizard
|
<CustomMethodCardWizard
|
||||||
isOpen={addCustomWizardOpen}
|
isOpen={addCustomWizardOpen}
|
||||||
onClose={handleCloseAddWizard}
|
onClose={handleCloseAddWizard}
|
||||||
|
|||||||
@@ -20,16 +20,10 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
|||||||
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
|
||||||
import CardStack from "../../../../components/cards/CardStack";
|
import CardStack from "../../../../components/cards/CardStack";
|
||||||
import Create from "../../../../components/modals/Create";
|
import Create from "../../../../components/modals/Create";
|
||||||
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
|
||||||
import {
|
|
||||||
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
|
||||||
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
|
|
||||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
|
||||||
} from "../../components/createFlowLayoutTokens";
|
|
||||||
import { MembershipMethodEditFields } from "../../components/methodEditFields";
|
import { MembershipMethodEditFields } from "../../components/methodEditFields";
|
||||||
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||||
@@ -694,22 +688,10 @@ export function MembershipMethodsScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateFlowStepShell
|
<CreateFlowCardStackStepShell
|
||||||
variant="wideGridLoosePadding"
|
lockupTitle={title}
|
||||||
contentTopBelowMd="space-800"
|
lockupDescription={description}
|
||||||
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
|
stackBusy={!recommendationsReady}
|
||||||
>
|
|
||||||
<div className="flex w-full min-w-0 flex-col items-center gap-6">
|
|
||||||
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
|
|
||||||
<CreateFlowHeaderLockup
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
justification="center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
|
|
||||||
aria-busy={!recommendationsReady}
|
|
||||||
>
|
>
|
||||||
{recommendationsReady && (
|
{recommendationsReady && (
|
||||||
<CardStack
|
<CardStack
|
||||||
@@ -729,9 +711,7 @@ export function MembershipMethodsScreen() {
|
|||||||
headerLockupSize={mdUp ? "L" : "M"}
|
headerLockupSize={mdUp ? "L" : "M"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CreateFlowCardStackStepShell>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Create
|
<Create
|
||||||
isOpen={createModalOpen}
|
isOpen={createModalOpen}
|
||||||
onClose={handleCreateModalClose}
|
onClose={handleCreateModalClose}
|
||||||
@@ -774,7 +754,6 @@ export function MembershipMethodsScreen() {
|
|||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</Create>
|
</Create>
|
||||||
</CreateFlowStepShell>
|
|
||||||
<CustomMethodCardWizard
|
<CustomMethodCardWizard
|
||||||
isOpen={addCustomWizardOpen}
|
isOpen={addCustomWizardOpen}
|
||||||
onClose={handleCloseAddWizard}
|
onClose={handleCloseAddWizard}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
|||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import {
|
import {
|
||||||
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
||||||
|
CREATE_FLOW_PAGE_GUTTER_CLASS,
|
||||||
|
CREATE_FLOW_SCROLL_REGION_CLASS,
|
||||||
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||||
} from "../../components/createFlowLayoutTokens";
|
} from "../../components/createFlowLayoutTokens";
|
||||||
import {
|
import {
|
||||||
@@ -163,7 +165,7 @@ export function CompletedScreen() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
|
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
|
||||||
<div
|
<div
|
||||||
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 ${CREATE_FLOW_PAGE_GUTTER_CLASS} max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col justify-start max-md:min-h-min max-md:overflow-visible min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
className={`flex flex-col justify-start max-md:min-h-min max-md:overflow-visible min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||||
@@ -177,7 +179,7 @@ export function CompletedScreen() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`scrollbar-hide relative flex min-h-0 flex-col self-stretch overflow-x-hidden md:max-h-full md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
className={`relative flex min-h-0 flex-col self-stretch overflow-x-hidden md:max-h-full md:overflow-y-auto ${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
|
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
|
|||||||
messageNamespace="create.community.communityName"
|
messageNamespace="create.community.communityName"
|
||||||
stateField="title"
|
stateField="title"
|
||||||
maxLength={48}
|
maxLength={48}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "community-structure":
|
case "community-structure":
|
||||||
@@ -126,6 +127,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
|
|||||||
stateField="communityContext"
|
stateField="communityContext"
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
mainAlign="center"
|
mainAlign="center"
|
||||||
|
multiline
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "community-size":
|
case "community-size":
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import NumberedList from "../../../../components/type/NumberedList";
|
import NumberedList from "../../../../components/type/NumberedList";
|
||||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
@@ -31,22 +30,6 @@ export function InformationalScreen() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const description: ReactNode = (
|
|
||||||
<>
|
|
||||||
{copy.descriptionLead}{" "}
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="font-normal text-[var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] cursor-pointer"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copy.workshopLabel}
|
|
||||||
</a>{" "}
|
|
||||||
{copy.descriptionTrail}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateFlowStepShell
|
<CreateFlowStepShell
|
||||||
variant="centeredNarrow"
|
variant="centeredNarrow"
|
||||||
@@ -57,7 +40,7 @@ export function InformationalScreen() {
|
|||||||
>
|
>
|
||||||
<CreateFlowHeaderLockup
|
<CreateFlowHeaderLockup
|
||||||
title={copy.title}
|
title={copy.title}
|
||||||
description={description}
|
description={copy.description}
|
||||||
justification="left"
|
justification="left"
|
||||||
/>
|
/>
|
||||||
<NumberedList items={items} size={mdUp ? "M" : "S"} />
|
<NumberedList items={items} size={mdUp ? "M" : "S"} />
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Rule from "../../../../components/cards/Rule";
|
import Rule from "../../../../components/cards/Rule";
|
||||||
|
import Button from "../../../../components/buttons/Button";
|
||||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
@@ -17,7 +18,7 @@ import {
|
|||||||
vectorMarkPath,
|
vectorMarkPath,
|
||||||
} from "../../../../../lib/assetUtils";
|
} from "../../../../../lib/assetUtils";
|
||||||
import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState";
|
import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState";
|
||||||
import { createFlowStepPath } from "../../utils/createFlowPaths";
|
import { CREATE_ROUTES, createFlowStepPath } from "../../utils/createFlowPaths";
|
||||||
|
|
||||||
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
|
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
|
||||||
export function CommunityReviewScreen() {
|
export function CommunityReviewScreen() {
|
||||||
@@ -25,6 +26,15 @@ export function CommunityReviewScreen() {
|
|||||||
const lgUp = useCreateFlowLgUp();
|
const lgUp = useCreateFlowLgUp();
|
||||||
const t = useTranslation("create.community.review");
|
const t = useTranslation("create.community.review");
|
||||||
const { state, updateState } = useCreateFlow();
|
const { state, updateState } = useCreateFlow();
|
||||||
|
/**
|
||||||
|
* Server layout has an empty context; the client layout may already hold a
|
||||||
|
* named draft. Defer empty-vs-congrats until after mount so the first paint
|
||||||
|
* matches SSR (`null`) instead of swapping HeaderLockup titles.
|
||||||
|
*/
|
||||||
|
const [reviewReady, setReviewReady] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setReviewReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the user picked **Customize** from a template before finishing community
|
* If the user picked **Customize** from a template before finishing community
|
||||||
@@ -70,11 +80,10 @@ export function CommunityReviewScreen() {
|
|||||||
const cardTitle =
|
const cardTitle =
|
||||||
typeof state.title === "string" && state.title.trim().length > 0
|
typeof state.title === "string" && state.title.trim().length > 0
|
||||||
? state.title.trim()
|
? state.title.trim()
|
||||||
: t("ruleCard.title");
|
: "";
|
||||||
/**
|
/**
|
||||||
* No placeholder fallback: if the user skipped `community-context`, leave
|
* No placeholder fallback: if the user skipped `community-context`, leave
|
||||||
* the card description off rather than render the old "Mutual Aid Monday
|
* the card description off rather than render sample copy as real user data.
|
||||||
* is a grassroots community…" sample, which read as real user copy.
|
|
||||||
*/
|
*/
|
||||||
const cardDescription =
|
const cardDescription =
|
||||||
typeof state.communityContext === "string" &&
|
typeof state.communityContext === "string" &&
|
||||||
@@ -88,6 +97,36 @@ export function CommunityReviewScreen() {
|
|||||||
? state.communityAvatarUrl.trim()
|
? state.communityAvatarUrl.trim()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (state.pendingTemplateAction || !reviewReady) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cardTitle) {
|
||||||
|
return (
|
||||||
|
<CreateFlowStepShell
|
||||||
|
variant="centeredNarrow"
|
||||||
|
contentTopBelowMd="space-1400"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex flex-col items-start gap-6 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||||
|
>
|
||||||
|
<CreateFlowHeaderLockup
|
||||||
|
title={t("empty.title")}
|
||||||
|
description={t("empty.description")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
size="xsmall"
|
||||||
|
href={CREATE_ROUTES.createFirstStep}
|
||||||
|
>
|
||||||
|
{t("empty.startLabel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateFlowStepShell
|
<CreateFlowStepShell
|
||||||
variant="wideGridLoosePadding"
|
variant="wideGridLoosePadding"
|
||||||
|
|||||||
@@ -303,7 +303,6 @@ export function CommunityStructureSelectScreen() {
|
|||||||
<>
|
<>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label={cs.organizationMultiSelect.label}
|
label={cs.organizationMultiSelect.label}
|
||||||
showHelpIcon
|
|
||||||
size="s"
|
size="s"
|
||||||
options={organizationTypeOptions}
|
options={organizationTypeOptions}
|
||||||
onChipClick={handleOrganizationTypeClick}
|
onChipClick={handleOrganizationTypeClick}
|
||||||
@@ -313,7 +312,6 @@ export function CommunityStructureSelectScreen() {
|
|||||||
/>
|
/>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label={cs.scaleMultiSelect.label}
|
label={cs.scaleMultiSelect.label}
|
||||||
showHelpIcon
|
|
||||||
size="s"
|
size="s"
|
||||||
options={scaleOptions}
|
options={scaleOptions}
|
||||||
onChipClick={handleScaleClick}
|
onChipClick={handleScaleClick}
|
||||||
@@ -323,7 +321,6 @@ export function CommunityStructureSelectScreen() {
|
|||||||
/>
|
/>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label={cs.maturityMultiSelect.label}
|
label={cs.maturityMultiSelect.label}
|
||||||
showHelpIcon
|
|
||||||
size="s"
|
size="s"
|
||||||
options={maturityOptions}
|
options={maturityOptions}
|
||||||
onChipClick={handleMaturityClick}
|
onChipClick={handleMaturityClick}
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { useTranslation } from "../../../../contexts/MessagesContext";
|
|||||||
import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits";
|
import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
|
||||||
import {
|
import {
|
||||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
|
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
|
||||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
||||||
@@ -131,42 +130,31 @@ export function ConfirmStakeholdersScreen() {
|
|||||||
|
|
||||||
if (managePublishedMode) {
|
if (managePublishedMode) {
|
||||||
return (
|
return (
|
||||||
<CreateFlowStepShell
|
<CreateFlowTwoColumnSelectShell
|
||||||
variant="centeredNarrowBottomPad"
|
header={
|
||||||
contentTopBelowMd="space-1400"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
|
||||||
>
|
|
||||||
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
|
|
||||||
<CreateFlowHeaderLockup
|
<CreateFlowHeaderLockup
|
||||||
title={t("managePublished.lockupTitle")}
|
title={t("managePublished.lockupTitle")}
|
||||||
description={t("managePublished.lockupDescription")}
|
description={t("managePublished.lockupDescription")}
|
||||||
justification="left"
|
justification="left"
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
|
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
|
||||||
</div>
|
</CreateFlowTwoColumnSelectShell>
|
||||||
</CreateFlowStepShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateFlowStepShell
|
<CreateFlowTwoColumnSelectShell
|
||||||
variant="centeredNarrowBottomPad"
|
header={
|
||||||
contentTopBelowMd="space-1400"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
|
||||||
>
|
|
||||||
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
|
|
||||||
<CreateFlowHeaderLockup
|
<CreateFlowHeaderLockup
|
||||||
title={t("title")}
|
title={t("title")}
|
||||||
description={t("description")}
|
description={t("description")}
|
||||||
justification="left"
|
justification="left"
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
{chipError ? (
|
{chipError ? (
|
||||||
<p
|
<p
|
||||||
className="text-small-paragraph text-[var(--color-border-default-utility-negative)]"
|
className="text-small-paragraph text-[var(--color-border-default-utility-negative)]"
|
||||||
@@ -187,8 +175,7 @@ export function ConfirmStakeholdersScreen() {
|
|||||||
addButton
|
addButton
|
||||||
addButtonText={t("addStakeholder")}
|
addButtonText={t("addStakeholder")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</CreateFlowTwoColumnSelectShell>
|
||||||
</CreateFlowStepShell>
|
|
||||||
|
|
||||||
{!toastDismissed && (
|
{!toastDismissed && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -587,17 +587,23 @@ export function CoreValuesSelectScreen() {
|
|||||||
[draft, markCreateFlowInteraction, replaceState, wizardCustomizeChipId],
|
[draft, markCreateFlowInteraction, replaceState, wizardCustomizeChipId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectedCount = useMemo(
|
||||||
|
() => coreValueOptions.filter((o) => o.state === "selected").length,
|
||||||
|
[coreValueOptions],
|
||||||
|
);
|
||||||
|
const atSelectionLimit = selectedCount >= MAX_CORE_VALUES;
|
||||||
|
const selectionCountText = cv.multiSelect.selectionCount
|
||||||
|
.replace("{count}", String(selectedCount))
|
||||||
|
.replace("{max}", String(MAX_CORE_VALUES));
|
||||||
|
|
||||||
const kebabMenuItems = useMemo(() => {
|
const kebabMenuItems = useMemo(() => {
|
||||||
if (!modalSession || !activeModalChipId) return [];
|
if (!modalSession || !activeModalChipId) return [];
|
||||||
const selectedCount = coreValueOptions.filter(
|
|
||||||
(o) => o.state === "selected",
|
|
||||||
).length;
|
|
||||||
return buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
return buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||||
showCustomize: true,
|
showCustomize: true,
|
||||||
onCustomize: handleCustomize,
|
onCustomize: handleCustomize,
|
||||||
onDuplicate:
|
onDuplicate:
|
||||||
(state.editingPublishedRuleId?.trim() ?? "") !== "" ||
|
(state.editingPublishedRuleId?.trim() ?? "") !== "" ||
|
||||||
selectedCount >= MAX_CORE_VALUES
|
atSelectionLimit
|
||||||
? undefined
|
? undefined
|
||||||
: handleDuplicateCoreChip,
|
: handleDuplicateCoreChip,
|
||||||
showRemove: modalSession === "editing",
|
showRemove: modalSession === "editing",
|
||||||
@@ -605,7 +611,7 @@ export function CoreValuesSelectScreen() {
|
|||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
activeModalChipId,
|
activeModalChipId,
|
||||||
coreValueOptions,
|
atSelectionLimit,
|
||||||
handleCustomize,
|
handleCustomize,
|
||||||
handleDuplicateCoreChip,
|
handleDuplicateCoreChip,
|
||||||
handleRemoveFromKebab,
|
handleRemoveFromKebab,
|
||||||
@@ -695,7 +701,8 @@ export function CoreValuesSelectScreen() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addHandlers.onAddClick}
|
onClick={addHandlers.onAddClick}
|
||||||
className="cursor-pointer font-normal leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90"
|
disabled={atSelectionLimit}
|
||||||
|
className="cursor-pointer font-normal leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90 disabled:cursor-not-allowed disabled:no-underline disabled:opacity-60 disabled:hover:opacity-60"
|
||||||
>
|
>
|
||||||
{cv.header.addLink}
|
{cv.header.addLink}
|
||||||
</button>
|
</button>
|
||||||
@@ -730,6 +737,9 @@ export function CoreValuesSelectScreen() {
|
|||||||
onCustomChipClose={addHandlers.onCustomChipClose}
|
onCustomChipClose={addHandlers.onCustomChipClose}
|
||||||
addButton
|
addButton
|
||||||
addButtonText={cv.multiSelect.addButtonText}
|
addButtonText={cv.multiSelect.addButtonText}
|
||||||
|
maxSelections={MAX_CORE_VALUES}
|
||||||
|
selectionCountText={selectionCountText}
|
||||||
|
limitReachedAnnouncement={cv.multiSelect.limitReached}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{detailModal && (
|
{detailModal && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
|
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
|
||||||
import TextInput from "../../../../components/controls/TextInput";
|
import TextInput from "../../../../components/controls/TextInput";
|
||||||
|
import TextArea from "../../../../components/controls/TextArea";
|
||||||
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
|
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
|
||||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
type CreateFlowContentTopBelowMd,
|
type CreateFlowContentTopBelowMd,
|
||||||
} from "../../components/CreateFlowStepShell";
|
} from "../../components/CreateFlowStepShell";
|
||||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||||
|
import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../utils/createFlowPaths";
|
||||||
import type { CreateFlowTextStateField } from "../../types";
|
import type { CreateFlowTextStateField } from "../../types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -21,6 +23,10 @@ type Props = {
|
|||||||
/** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */
|
/** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */
|
||||||
mainAlign?: "start" | "center";
|
mainAlign?: "start" | "center";
|
||||||
inputType?: HTMLInputTypeAttribute;
|
inputType?: HTMLInputTypeAttribute;
|
||||||
|
/** Multi-line control (community description). */
|
||||||
|
multiline?: boolean;
|
||||||
|
/** Native `required` (community name and save-progress email). */
|
||||||
|
required?: boolean;
|
||||||
showCharacterCount?: boolean;
|
showCharacterCount?: boolean;
|
||||||
headerJustification?: HeaderLockupJustificationValue;
|
headerJustification?: HeaderLockupJustificationValue;
|
||||||
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
|
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
|
||||||
@@ -28,7 +34,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared narrow-column + TextInput pattern for Create Community text frames.
|
* Shared narrow-column + labelled field pattern for Create Community text frames.
|
||||||
*/
|
*/
|
||||||
export function CreateFlowTextFieldScreen({
|
export function CreateFlowTextFieldScreen({
|
||||||
messageNamespace,
|
messageNamespace,
|
||||||
@@ -36,6 +42,8 @@ export function CreateFlowTextFieldScreen({
|
|||||||
maxLength,
|
maxLength,
|
||||||
mainAlign = "start",
|
mainAlign = "start",
|
||||||
inputType = "text",
|
inputType = "text",
|
||||||
|
multiline = false,
|
||||||
|
required = false,
|
||||||
showCharacterCount = true,
|
showCharacterCount = true,
|
||||||
headerJustification = "left",
|
headerJustification = "left",
|
||||||
contentTopBelowMd = "space-1400",
|
contentTopBelowMd = "space-1400",
|
||||||
@@ -69,6 +77,15 @@ export function CreateFlowTextFieldScreen({
|
|||||||
const mainItems =
|
const mainItems =
|
||||||
mainAlign === "center" ? "items-center" : "items-start";
|
mainAlign === "center" ? "items-center" : "items-start";
|
||||||
|
|
||||||
|
const isEmail = inputType === "email";
|
||||||
|
const persistValue = (next: string) => {
|
||||||
|
setValue(next);
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
updateState({ [stateField]: next } as Record<string, string>);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldLabel = t("inputLabel");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateFlowStepShell
|
<CreateFlowStepShell
|
||||||
variant="centeredNarrow"
|
variant="centeredNarrow"
|
||||||
@@ -85,22 +102,60 @@ export function CreateFlowTextFieldScreen({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
{multiline ? (
|
||||||
|
<TextArea
|
||||||
|
className="!transition-none"
|
||||||
|
label={fieldLabel}
|
||||||
|
placeholder={t("placeholder")}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => persistValue(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed !== value) persistValue(trimmed);
|
||||||
|
}}
|
||||||
|
size={mdUp ? "medium" : "small"}
|
||||||
|
formHeader={false}
|
||||||
|
showHelpIcon={false}
|
||||||
|
textHint={hint}
|
||||||
|
maxLength={maxLength}
|
||||||
|
required={required}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<TextInput
|
<TextInput
|
||||||
className="!transition-none"
|
className="!transition-none"
|
||||||
type={inputType}
|
type={inputType}
|
||||||
|
name={isEmail ? "email" : undefined}
|
||||||
|
label={fieldLabel}
|
||||||
placeholder={t("placeholder")}
|
placeholder={t("placeholder")}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => {
|
onChange={(e) => persistValue(e.target.value)}
|
||||||
const v = e.target.value;
|
onBlur={() => {
|
||||||
setValue(v);
|
const trimmed = isEmail
|
||||||
markCreateFlowInteraction();
|
? value.trim().toLowerCase()
|
||||||
updateState({ [stateField]: v } as Record<string, string>);
|
: value.trim();
|
||||||
|
if (trimmed !== value) persistValue(trimmed);
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={
|
||||||
|
isEmail
|
||||||
|
? (e) => {
|
||||||
|
if (e.key !== "Enter") return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.form?.requestSubmit();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
inputSize={mdUp ? "medium" : "small"}
|
inputSize={mdUp ? "medium" : "small"}
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
|
showHelpIcon={false}
|
||||||
textHint={hint}
|
textHint={hint}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
|
required={required || isEmail}
|
||||||
|
autoComplete={isEmail ? "email" : undefined}
|
||||||
|
inputMode={isEmail ? "email" : undefined}
|
||||||
|
form={isEmail ? CREATE_FLOW_COMMUNITY_SAVE_FORM_ID : undefined}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CreateFlowStepShell>
|
</CreateFlowStepShell>
|
||||||
|
|||||||
@@ -16,14 +16,24 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
|
|||||||
import { fetchAuthSession } from "../../../../../lib/create/api";
|
import { fetchAuthSession } from "../../../../../lib/create/api";
|
||||||
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
||||||
import {
|
import {
|
||||||
UploadToServerError,
|
createFlowUploadFailureMessageKey,
|
||||||
uploadCreateFlowFile,
|
uploadCreateFlowFile,
|
||||||
} from "../../../../../lib/create/uploadToServer";
|
} from "../../../../../lib/create/uploadToServer";
|
||||||
|
import {
|
||||||
|
COMMUNITY_AVATAR_ACCEPT,
|
||||||
|
messageKeyForCreateFlowUploadReason,
|
||||||
|
validateCreateFlowUploadFile,
|
||||||
|
} from "../../../../../lib/create/createFlowUploadValidation";
|
||||||
import {
|
import {
|
||||||
clearPendingCommunityAvatarFile,
|
clearPendingCommunityAvatarFile,
|
||||||
|
readPendingCommunityAvatarFile,
|
||||||
storePendingCommunityAvatarFile,
|
storePendingCommunityAvatarFile,
|
||||||
} from "../../../../../lib/create/pendingCommunityAvatarUpload";
|
} from "../../../../../lib/create/pendingCommunityAvatarUpload";
|
||||||
|
|
||||||
|
function hasCommunityAvatarUrl(url: string | undefined): boolean {
|
||||||
|
return typeof url === "string" && url.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Create Community — Figma Flow — Upload `20094:41524`. */
|
/** Create Community — Figma Flow — Upload `20094:41524`. */
|
||||||
export function CommunityUploadScreen() {
|
export function CommunityUploadScreen() {
|
||||||
const m = useMessages();
|
const m = useMessages();
|
||||||
@@ -54,17 +64,57 @@ export function CommunityUploadScreen() {
|
|||||||
[localPreviewUrl],
|
[localPreviewUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const serverAvatarUrl = hasCommunityAvatarUrl(state.communityAvatarUrl)
|
||||||
|
? state.communityAvatarUrl!.trim()
|
||||||
|
: null;
|
||||||
|
const serverAvatarUrlRef = useRef(serverAvatarUrl);
|
||||||
|
serverAvatarUrlRef.current = serverAvatarUrl;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverAvatarUrl) {
|
||||||
|
setLocalPreviewUrl((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [serverAvatarUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const file = await readPendingCommunityAvatarFile();
|
||||||
|
if (cancelled || !file) return;
|
||||||
|
const validated = await validateCreateFlowUploadFile(
|
||||||
|
file,
|
||||||
|
"communityAvatar",
|
||||||
|
);
|
||||||
|
if (validated.ok === false) {
|
||||||
|
await clearPendingCommunityAvatarFile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
if (serverAvatarUrlRef.current) return;
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
if (cancelled) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocalPreviewUrl((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
|
return objectUrl;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Missing IndexedDB / quota: leave the picker empty.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const resolveUploadError = useCallback(
|
const resolveUploadError = useCallback(
|
||||||
(err: unknown) => {
|
(err: unknown) => tUpload(createFlowUploadFailureMessageKey(err)),
|
||||||
if (err instanceof UploadToServerError) {
|
|
||||||
if (err.status === 413) return tUpload("errors.tooLarge");
|
|
||||||
if (err.status === 401) return tUpload("errors.unauthorized");
|
|
||||||
if (err.code === "server_misconfigured") {
|
|
||||||
return tUpload("errors.misconfigured");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tUpload("errors.generic");
|
|
||||||
},
|
|
||||||
[tUpload],
|
[tUpload],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -94,6 +144,16 @@ export function CommunityUploadScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (signedIn === false) {
|
if (signedIn === false) {
|
||||||
|
const validated = await validateCreateFlowUploadFile(
|
||||||
|
file,
|
||||||
|
"communityAvatar",
|
||||||
|
);
|
||||||
|
if (validated.ok === false) {
|
||||||
|
setErrorMessage(
|
||||||
|
tUpload(messageKeyForCreateFlowUploadReason(validated.reason)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await storePendingCommunityAvatarFile(file);
|
await storePendingCommunityAvatarFile(file);
|
||||||
setLocalPreviewUrl((prev) => {
|
setLocalPreviewUrl((prev) => {
|
||||||
@@ -121,24 +181,16 @@ export function CommunityUploadScreen() {
|
|||||||
if (prev) URL.revokeObjectURL(prev);
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
if (
|
if (hasCommunityAvatarUrl(state.communityAvatarUrl)) {
|
||||||
typeof state.communityAvatarUrl === "string" &&
|
|
||||||
state.communityAvatarUrl.trim().length > 0
|
|
||||||
) {
|
|
||||||
updateState({ communityAvatarUrl: undefined });
|
updateState({ communityAvatarUrl: undefined });
|
||||||
}
|
}
|
||||||
// Clear any anonymous staged blob so the post-sign-in flush won't resurrect it.
|
|
||||||
void clearPendingCommunityAvatarFile();
|
void clearPendingCommunityAvatarFile();
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = "";
|
fileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
}, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]);
|
}, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]);
|
||||||
|
|
||||||
const displaySrc =
|
const displaySrc = serverAvatarUrl ?? localPreviewUrl;
|
||||||
typeof state.communityAvatarUrl === "string" &&
|
|
||||||
state.communityAvatarUrl.trim().length > 0
|
|
||||||
? state.communityAvatarUrl.trim()
|
|
||||||
: localPreviewUrl;
|
|
||||||
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
|
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -161,7 +213,7 @@ export function CommunityUploadScreen() {
|
|||||||
type="file"
|
type="file"
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
accept={COMMUNITY_AVATAR_ACCEPT}
|
||||||
aria-label={u.hintText}
|
aria-label={u.hintText}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { GovernanceTemplateGrid } from "../../../components/sections/GovernanceTemplateGrid";
|
||||||
|
import type { TemplateGridCardEntry } from "../../../../lib/templates/templateGridPresentation";
|
||||||
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
import { useTemplatesFacetGridEntries } from "../../../(marketing)/templates/useTemplatesFacetGridEntries";
|
||||||
|
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
|
||||||
|
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
|
||||||
|
import { CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS } from "../components/createFlowLayoutTokens";
|
||||||
|
import { buildTemplateReviewHref } from "../utils/flowSteps";
|
||||||
|
|
||||||
|
export function CreateFlowTemplatesPageClient({
|
||||||
|
initialGridEntries,
|
||||||
|
}: {
|
||||||
|
initialGridEntries: TemplateGridCardEntry[];
|
||||||
|
}) {
|
||||||
|
const t = useTranslation("pages.templates");
|
||||||
|
const entries = useTemplatesFacetGridEntries({
|
||||||
|
initialGridEntries,
|
||||||
|
enableFacetRecommendations: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreateFlowStepShell
|
||||||
|
variant="wideGridLoosePadding"
|
||||||
|
contentTopBelowMd="space-1400"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`mx-auto flex w-full min-w-0 flex-col gap-6 pb-8 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||||
|
>
|
||||||
|
<CreateFlowHeaderLockup
|
||||||
|
title={t("title")}
|
||||||
|
description={t("subtitle")}
|
||||||
|
justification="left"
|
||||||
|
/>
|
||||||
|
<GovernanceTemplateGrid
|
||||||
|
entries={entries}
|
||||||
|
hrefForTemplate={(slug) =>
|
||||||
|
buildTemplateReviewHref(slug, { fromCreateWizard: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -215,7 +215,7 @@ export interface CreateFlowState {
|
|||||||
templateReviewBackSlug?: string;
|
templateReviewBackSlug?: string;
|
||||||
/**
|
/**
|
||||||
* True when the user opened `/create/review-template/{slug}` from the create
|
* True when the user opened `/create/review-template/{slug}` from the create
|
||||||
* wizard (`/templates?fromFlow=1` after `/create/review`). Persisted so Back
|
* wizard (`/create/templates` after `/create/review`). Persisted so Back
|
||||||
* from template review targets `/create/review` and so returning from
|
* from template review targets `/create/review` and so returning from
|
||||||
* `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL.
|
* `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,12 +10,18 @@ import {
|
|||||||
FIRST_STEP,
|
FIRST_STEP,
|
||||||
} from "./flowSteps";
|
} from "./flowSteps";
|
||||||
|
|
||||||
|
/** Associates the save-progress email field with the layout footer submit. */
|
||||||
|
export const CREATE_FLOW_COMMUNITY_SAVE_FORM_ID =
|
||||||
|
"create-flow-community-save";
|
||||||
|
|
||||||
export const CREATE_ROUTES = {
|
export const CREATE_ROUTES = {
|
||||||
root: "/",
|
root: "/",
|
||||||
createRoot: "/create",
|
createRoot: "/create",
|
||||||
/** Direct path to the first wizard step so client navigations skip the redirect hop. */
|
/** Direct path to the first wizard step so client navigations skip the redirect hop. */
|
||||||
createFirstStep: `/create/${FIRST_STEP}`,
|
createFirstStep: `/create/${FIRST_STEP}`,
|
||||||
review: "/create/review",
|
review: "/create/review",
|
||||||
|
/** In-flow template catalog (wizard chrome). Marketing catalog remains `/templates`. */
|
||||||
|
templatesPicker: "/create/templates",
|
||||||
finalReview: "/create/final-review",
|
finalReview: "/create/final-review",
|
||||||
completed: "/create/completed",
|
completed: "/create/completed",
|
||||||
editRule: "/create/edit-rule",
|
editRule: "/create/edit-rule",
|
||||||
|
|||||||
@@ -195,13 +195,14 @@ export function shouldOfferCreateFlowSaveAndExit(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Steps where below `lg` the main column scrolls with split layout
|
* Steps where below `lg` the main column scrolls with split layout
|
||||||
* (`CreateFlowLayoutClient` — Linear CR-92 §4).
|
* (`CreateFlowLayoutClient`).
|
||||||
*/
|
*/
|
||||||
export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [
|
export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [
|
||||||
"community-size",
|
"community-size",
|
||||||
"community-structure",
|
"community-structure",
|
||||||
"core-values",
|
"core-values",
|
||||||
"decision-approaches",
|
"decision-approaches",
|
||||||
|
"confirm-stakeholders",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function createFlowStepUsesSelectSplitScroll(
|
export function createFlowStepUsesSelectSplitScroll(
|
||||||
@@ -234,18 +235,34 @@ export function parseCreateFlowScreenFromPathname(
|
|||||||
): CreateFlowStep | null {
|
): CreateFlowStep | null {
|
||||||
if (!pathname || pathname.length === 0) return null;
|
if (!pathname || pathname.length === 0) return null;
|
||||||
if (pathname.includes("/create/review-template/")) return null;
|
if (pathname.includes("/create/review-template/")) return null;
|
||||||
|
if (isCreateFlowTemplatesPickerPath(pathname)) return null;
|
||||||
|
|
||||||
const parts = pathname.split("/").filter(Boolean);
|
const parts = pathname.split("/").filter(Boolean);
|
||||||
const createIdx = parts.indexOf("create");
|
const createIdx = parts.indexOf("create");
|
||||||
if (createIdx === -1 || createIdx >= parts.length - 1) return null;
|
if (createIdx === -1 || createIdx >= parts.length - 1) return null;
|
||||||
|
|
||||||
const segment = parts[createIdx + 1];
|
const segment = parts[createIdx + 1];
|
||||||
if (segment === "review-template") return null;
|
if (segment === "review-template" || segment === "templates") return null;
|
||||||
|
|
||||||
return isValidStep(segment) ? segment : null;
|
return isValidStep(segment) ? segment : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same query as `/templates?fromFlow=1` — template was picked after `/create/review`. */
|
/**
|
||||||
|
* `/create/templates` — in-flow catalog after review “Create from template”.
|
||||||
|
* Not a wizard step; layout keeps Back / Save & Exit and does not use
|
||||||
|
* marketing `/templates` chrome.
|
||||||
|
*/
|
||||||
|
export function isCreateFlowTemplatesPickerPath(
|
||||||
|
pathname: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!pathname) return false;
|
||||||
|
const parts = pathname.split("/").filter(Boolean);
|
||||||
|
const createIdx = parts.indexOf("create");
|
||||||
|
if (createIdx === -1 || createIdx !== parts.length - 2) return false;
|
||||||
|
return parts[createIdx + 1] === "templates";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same query as `/create/review-template/…?fromFlow=1` — template was picked in-flow. */
|
||||||
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
|
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
|
||||||
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
|
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -1,9 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Suspense, type ReactNode } from "react";
|
import { Suspense, type ReactNode } from "react";
|
||||||
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
|
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
|
||||||
|
import SkipToContent from "../components/navigation/SkipToContent";
|
||||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||||
import messages from "../../messages/en/index";
|
import messages from "../../messages/en/index";
|
||||||
|
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
|
||||||
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
|
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -27,10 +29,17 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<MessagesProvider messages={messages}>
|
<MessagesProvider messages={messages}>
|
||||||
<AuthModalProvider>
|
<AuthModalProvider>
|
||||||
|
<SkipToContent />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ConditionalNavigation />
|
<ConditionalNavigation />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<main className="flex-1">{children}</main>
|
<main
|
||||||
|
id={MAIN_CONTENT_ID}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="flex-1 outline-none"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</AuthModalProvider>
|
</AuthModalProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||||
import messages from "../../messages/en/index";
|
import messages from "../../messages/en/index";
|
||||||
|
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
|
||||||
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
|
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -18,7 +19,13 @@ export default function DevLayout({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<MessagesProvider messages={messages}>
|
<MessagesProvider messages={messages}>
|
||||||
<AuthModalProvider>
|
<AuthModalProvider>
|
||||||
<main className="flex-1">{children}</main>
|
<main
|
||||||
|
id={MAIN_CONTENT_ID}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="flex-1 outline-none"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</AuthModalProvider>
|
</AuthModalProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,11 +36,6 @@ export default function AboutPage() {
|
|||||||
const headerSegments = asArray<AboutHeaderSegment>(page.aboutHeader.segments);
|
const headerSegments = asArray<AboutHeaderSegment>(page.aboutHeader.segments);
|
||||||
const statsItems = asArray<StatItem>(page.stats.items);
|
const statsItems = asArray<StatItem>(page.stats.items);
|
||||||
|
|
||||||
const statsAsOf =
|
|
||||||
typeof page.stats.asOf === "string"
|
|
||||||
? page.stats.asOf
|
|
||||||
: String(page.stats.asOf ?? "");
|
|
||||||
|
|
||||||
const faqItems = asArray<FaqAccordionItem>(page.faq.items);
|
const faqItems = asArray<FaqAccordionItem>(page.faq.items);
|
||||||
const tripleColumns = asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns);
|
const tripleColumns = asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns);
|
||||||
|
|
||||||
@@ -57,10 +52,8 @@ export default function AboutPage() {
|
|||||||
titlePrefix={page.stats.titlePrefix}
|
titlePrefix={page.stats.titlePrefix}
|
||||||
titleEmphasis={page.stats.titleEmphasis}
|
titleEmphasis={page.stats.titleEmphasis}
|
||||||
titleSuffix={page.stats.titleSuffix}
|
titleSuffix={page.stats.titleSuffix}
|
||||||
items={statsItems.map((item) => ({
|
asOfPrefix={page.stats.asOfPrefix}
|
||||||
...item,
|
items={statsItems}
|
||||||
asOf: statsAsOf,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
<TripleTextBlock columns={tripleColumns} />
|
<TripleTextBlock columns={tripleColumns} />
|
||||||
<Book
|
<Book
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Suspense, type ReactNode } from "react";
|
import { Suspense, type ReactNode } from "react";
|
||||||
import MarketingNavigation from "../components/navigation/MarketingNavigation";
|
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
|
||||||
|
import SkipToContent from "../components/navigation/SkipToContent";
|
||||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||||
|
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
|
||||||
import marketingMessages from "../../messages/en/marketing";
|
import marketingMessages from "../../messages/en/marketing";
|
||||||
|
|
||||||
// Site footer is part of the public marketing chrome only — not rendered for
|
// Site footer is part of the public marketing chrome only — not rendered for
|
||||||
@@ -20,14 +22,21 @@ export default function MarketingLayout({ children }: { children: ReactNode }) {
|
|||||||
<MessagesProvider messages={marketingMessages}>
|
<MessagesProvider messages={marketingMessages}>
|
||||||
<AuthModalProvider>
|
<AuthModalProvider>
|
||||||
{/*
|
{/*
|
||||||
* MarketingNavigation reads `usePathname()` to decide chromeless paths
|
* Same session-aware shell as `(app)` / `(admin)`: `ConditionalNavigation`
|
||||||
* (uncached data under `cacheComponents`). Suspense lets the static
|
* reads the cookie behind Suspense so the static page shell can prerender
|
||||||
* shell prerender; the nav streams in with the correct visibility.
|
* while the header streams signed-in vs signed-out correctly.
|
||||||
*/}
|
*/}
|
||||||
|
<SkipToContent />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<MarketingNavigation />
|
<ConditionalNavigation />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<main className="flex-1">{children}</main>
|
<main
|
||||||
|
id={MAIN_CONTENT_ID}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="flex-1 outline-none"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</AuthModalProvider>
|
</AuthModalProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||||
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
||||||
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
||||||
@@ -68,8 +68,8 @@ export default function TemplatesPageClient({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* - `fromFlow=1` — skip `prepareFreshCreateFlowEntry` on template click
|
* - `fromFlow=1` — skip `prepareFreshCreateFlowEntry` on template click
|
||||||
* (draft preserved). Used by review “Create from template” and profile.
|
* (draft preserved). Used by profile “Create from template”.
|
||||||
* - `recommendTemplates=1` (with review only) — rank templates + “RECOMMENDED”
|
* - `recommendTemplates=1` — rank templates + “RECOMMENDED”
|
||||||
* from `GET /api/templates?facet.*` using the persisted community draft.
|
* from `GET /api/templates?facet.*` using the persisted community draft.
|
||||||
*/
|
*/
|
||||||
function TemplatesGridWithSearchParams({
|
function TemplatesGridWithSearchParams({
|
||||||
@@ -96,19 +96,18 @@ function TemplatesGrid({
|
|||||||
entries: TemplateGridCardEntry[];
|
entries: TemplateGridCardEntry[];
|
||||||
fromFlow: boolean;
|
fromFlow: boolean;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
|
||||||
return (
|
return (
|
||||||
<GovernanceTemplateGrid
|
<GovernanceTemplateGrid
|
||||||
entries={entries}
|
entries={entries}
|
||||||
onTemplateClick={(slug) => {
|
hrefForTemplate={(slug) =>
|
||||||
|
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow })
|
||||||
|
}
|
||||||
|
onTemplateClick={() => {
|
||||||
if (!fromFlow) {
|
if (!fromFlow) {
|
||||||
// Marketing /templates has no session in hand; DELETE is
|
// Marketing /templates has no session in hand; DELETE is
|
||||||
// best-effort and the sentinel blocks stale-draft hydration.
|
// best-effort and the sentinel blocks stale-draft hydration.
|
||||||
prepareFreshCreateFlowEntrySync({ signedIn: true });
|
prepareFreshCreateFlowEntrySync({ signedIn: true });
|
||||||
}
|
}
|
||||||
router.push(
|
|
||||||
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ type UseTemplatesFacetGridEntriesArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When `enableFacetRecommendations` (review → “Create from template” only),
|
* When `enableFacetRecommendations` (in-flow `/create/templates`, or marketing
|
||||||
* re-fetch ranked templates from `GET /api/templates?facet.*` using the
|
* `/templates?recommendTemplates=1`), re-fetch ranked templates from
|
||||||
* persisted create-flow draft. Otherwise returns `initialGridEntries` from SSR.
|
* `GET /api/templates?facet.*` using the persisted create-flow draft.
|
||||||
|
* Otherwise returns `initialGridEntries` from SSR.
|
||||||
*/
|
*/
|
||||||
export function useTemplatesFacetGridEntries({
|
export function useTemplatesFacetGridEntries({
|
||||||
initialGridEntries,
|
initialGridEntries,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||||
|
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
|
||||||
import marketingMessages from "../../messages/en/marketing";
|
import marketingMessages from "../../messages/en/marketing";
|
||||||
|
|
||||||
/** Full-viewport case-study surfaces (completed rule demos) — no marketing footer. */
|
/** Full-viewport case-study surfaces (completed rule demos) — no marketing footer. */
|
||||||
@@ -12,7 +13,11 @@ export default function MarketingCaseStudyLayout({
|
|||||||
return (
|
return (
|
||||||
<MessagesProvider messages={marketingMessages}>
|
<MessagesProvider messages={marketingMessages}>
|
||||||
<AuthModalProvider>
|
<AuthModalProvider>
|
||||||
<main className="flex h-dvh min-h-0 flex-col overflow-hidden">
|
<main
|
||||||
|
id={MAIN_CONTENT_ID}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="flex h-dvh min-h-0 flex-col overflow-hidden outline-none"
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</AuthModalProvider>
|
</AuthModalProvider>
|
||||||
|
|||||||
+3
-1
@@ -34,7 +34,7 @@ export function useUseCaseCompletedRuleActions({
|
|||||||
const [duplicateBusy, setDuplicateBusy] = useState(false);
|
const [duplicateBusy, setDuplicateBusy] = useState(false);
|
||||||
|
|
||||||
const copyPageLink = useCallback(async () => {
|
const copyPageLink = useCallback(async () => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return false;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(window.location.href);
|
await navigator.clipboard.writeText(window.location.href);
|
||||||
setActionBanner({
|
setActionBanner({
|
||||||
@@ -43,6 +43,7 @@ export function useUseCaseCompletedRuleActions({
|
|||||||
title: t("shareLinkCopiedTitle"),
|
title: t("shareLinkCopiedTitle"),
|
||||||
description: t("shareLinkCopiedDescription"),
|
description: t("shareLinkCopiedDescription"),
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
setActionBanner({
|
setActionBanner({
|
||||||
key: "shareCopyFailed",
|
key: "shareCopyFailed",
|
||||||
@@ -50,6 +51,7 @@ export function useUseCaseCompletedRuleActions({
|
|||||||
title: t("shareCopyFailedTitle"),
|
title: t("shareCopyFailedTitle"),
|
||||||
description: t("shareCopyFailedDescription"),
|
description: t("shareCopyFailedDescription"),
|
||||||
});
|
});
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}, [setActionBanner, t]);
|
}, [setActionBanner, t]);
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,40 @@ import { saveCreateFlowUpload } from "../../../lib/server/uploads/saveCreateFlow
|
|||||||
import { getUploadRootFromEnv } from "../../../lib/server/uploads/uploadRoot";
|
import { getUploadRootFromEnv } from "../../../lib/server/uploads/uploadRoot";
|
||||||
import {
|
import {
|
||||||
CREATE_FLOW_UPLOAD_MAX_BYTES,
|
CREATE_FLOW_UPLOAD_MAX_BYTES,
|
||||||
|
maxBytesForPurpose,
|
||||||
type CreateFlowUploadPurpose,
|
type CreateFlowUploadPurpose,
|
||||||
} from "../../../lib/server/uploads/uploadConstants";
|
} from "../../../lib/server/uploads/uploadConstants";
|
||||||
|
import type { CreateFlowUploadValidationReason } from "../../../lib/create/createFlowUploadValidation";
|
||||||
|
|
||||||
|
function asUploadedBlob(value: FormDataEntryValue | null): Blob | null {
|
||||||
|
if (typeof value !== "object" || value === null) return null;
|
||||||
|
const candidate = value as Partial<Blob>;
|
||||||
|
if (typeof candidate.arrayBuffer !== "function") return null;
|
||||||
|
if (typeof candidate.size !== "number") return null;
|
||||||
|
return value as Blob;
|
||||||
|
}
|
||||||
|
|
||||||
function isPurpose(x: string): x is CreateFlowUploadPurpose {
|
function isPurpose(x: string): x is CreateFlowUploadPurpose {
|
||||||
return x === "communityAvatar" || x === "customMethodAttachment";
|
return x === "communityAvatar" || x === "customMethodAttachment";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function messageForValidationReason(
|
||||||
|
reason: CreateFlowUploadValidationReason,
|
||||||
|
): string {
|
||||||
|
switch (reason) {
|
||||||
|
case "empty":
|
||||||
|
return "File is empty.";
|
||||||
|
case "tooLarge":
|
||||||
|
return "File exceeds the maximum allowed size for this upload purpose.";
|
||||||
|
case "svg":
|
||||||
|
return "SVG uploads are not allowed.";
|
||||||
|
case "undecodable":
|
||||||
|
return "File could not be decoded as a valid image.";
|
||||||
|
case "invalidType":
|
||||||
|
return "File type is not allowed for this upload purpose.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
|
export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
|
||||||
if (!isDatabaseConfigured()) {
|
if (!isDatabaseConfigured()) {
|
||||||
return dbUnavailable();
|
return dbUnavailable();
|
||||||
@@ -54,7 +81,7 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const purposeRaw = formData.get("purpose");
|
const purposeRaw = formData.get("purpose");
|
||||||
const file = formData.get("file");
|
const file = asUploadedBlob(formData.get("file"));
|
||||||
|
|
||||||
if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) {
|
if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) {
|
||||||
return errorJson(
|
return errorJson(
|
||||||
@@ -64,7 +91,7 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(file instanceof File)) {
|
if (!file) {
|
||||||
return errorJson(
|
return errorJson(
|
||||||
"validation_error",
|
"validation_error",
|
||||||
"Missing `file` field (multipart file).",
|
"Missing `file` field (multipart file).",
|
||||||
@@ -72,21 +99,29 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > CREATE_FLOW_UPLOAD_MAX_BYTES) {
|
if (file.size === 0) {
|
||||||
|
return errorJson("validation_error", messageForValidationReason("empty"), 400, {
|
||||||
|
details: { reason: "empty" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
file.size > CREATE_FLOW_UPLOAD_MAX_BYTES ||
|
||||||
|
file.size > maxBytesForPurpose(purposeRaw)
|
||||||
|
) {
|
||||||
return errorJson(
|
return errorJson(
|
||||||
"payload_too_large",
|
"payload_too_large",
|
||||||
`File exceeds maximum allowed size (${CREATE_FLOW_UPLOAD_MAX_BYTES} bytes).`,
|
messageForValidationReason("tooLarge"),
|
||||||
413,
|
413,
|
||||||
|
{ details: { reason: "tooLarge" } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const buf = Buffer.from(await file.arrayBuffer());
|
const buf = Buffer.from(await file.arrayBuffer());
|
||||||
const mimeType = file.type || "application/octet-stream";
|
|
||||||
|
|
||||||
const saved = await saveCreateFlowUpload({
|
const saved = await saveCreateFlowUpload({
|
||||||
purpose: purposeRaw,
|
purpose: purposeRaw,
|
||||||
buffer: buf,
|
buffer: buf,
|
||||||
mimeType,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if ("error" in saved) {
|
if ("error" in saved) {
|
||||||
@@ -95,10 +130,20 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
|
|||||||
"File uploads are not configured (UPLOAD_ROOT is unset).",
|
"File uploads are not configured (UPLOAD_ROOT is unset).",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const reason = saved.reason ?? "invalidType";
|
||||||
|
if (reason === "tooLarge") {
|
||||||
|
return errorJson(
|
||||||
|
"payload_too_large",
|
||||||
|
messageForValidationReason("tooLarge"),
|
||||||
|
413,
|
||||||
|
{ details: { reason } },
|
||||||
|
);
|
||||||
|
}
|
||||||
return errorJson(
|
return errorJson(
|
||||||
"validation_error",
|
"validation_error",
|
||||||
"File type or size is not allowed for this upload purpose.",
|
messageForValidationReason(reason),
|
||||||
400,
|
400,
|
||||||
|
{ details: { reason } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,9 +93,9 @@ const Logo = memo<LogoProps>(
|
|||||||
? "text-[var(--color-content-invert-primary)]"
|
? "text-[var(--color-content-invert-primary)]"
|
||||||
: "text-[var(--color-content-default-primary)]";
|
: "text-[var(--color-content-default-primary)]";
|
||||||
const wordmarkVisibilityClass =
|
const wordmarkVisibilityClass =
|
||||||
size === "topNavFolderTop" || size === "topNavHeader"
|
size === "topNavHeader"
|
||||||
? wordmark
|
? wordmark
|
||||||
? "hidden sm:block"
|
? "hidden md:block"
|
||||||
: "hidden"
|
: "hidden"
|
||||||
: wordmark
|
: wordmark
|
||||||
? ""
|
? ""
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const RuleContainer = memo<RuleProps>(
|
|||||||
backgroundColor = "bg-[var(--color-community-teal-100)]",
|
backgroundColor = "bg-[var(--color-community-teal-100)]",
|
||||||
className = "",
|
className = "",
|
||||||
onClick,
|
onClick,
|
||||||
|
href,
|
||||||
expanded = false,
|
expanded = false,
|
||||||
size: sizeProp,
|
size: sizeProp,
|
||||||
categories,
|
categories,
|
||||||
@@ -95,8 +96,9 @@ const RuleContainer = memo<RuleProps>(
|
|||||||
icon={icon}
|
icon={icon}
|
||||||
backgroundColor={backgroundColor}
|
backgroundColor={backgroundColor}
|
||||||
className={className}
|
className={className}
|
||||||
|
href={hasBottomLinks ? undefined : href}
|
||||||
onClick={hasBottomLinks ? undefined : handleClick}
|
onClick={hasBottomLinks ? undefined : handleClick}
|
||||||
onKeyDown={hasBottomLinks ? undefined : handleKeyDown}
|
onKeyDown={hasBottomLinks || href ? undefined : handleKeyDown}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
size={size}
|
size={size}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ export interface RuleProps {
|
|||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
/**
|
||||||
|
* When set, the card is a real link (focusable, Enter, open in a new tab)
|
||||||
|
* instead of a click-only `role="button"` surface.
|
||||||
|
*/
|
||||||
|
href?: string;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
size?: RuleSizeValue;
|
size?: RuleSizeValue;
|
||||||
categories?: Category[];
|
categories?: Category[];
|
||||||
@@ -93,6 +98,7 @@ export interface RuleViewProps {
|
|||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
className: string;
|
className: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
href?: string;
|
||||||
onKeyDown?: (_event: React.KeyboardEvent<HTMLDivElement>) => void;
|
onKeyDown?: (_event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
size: RuleSizeValue;
|
size: RuleSizeValue;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import NextLink from "next/link";
|
||||||
import MultiSelect from "../../controls/MultiSelect";
|
import MultiSelect from "../../controls/MultiSelect";
|
||||||
import InlineTextButton from "../../buttons/InlineTextButton";
|
import InlineTextButton from "../../buttons/InlineTextButton";
|
||||||
import NavigationLink from "../../navigation/Link";
|
import NavigationLink from "../../navigation/Link";
|
||||||
@@ -19,6 +20,7 @@ export function RuleView({
|
|||||||
backgroundColor,
|
backgroundColor,
|
||||||
className,
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
|
href,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
expanded,
|
expanded,
|
||||||
size,
|
size,
|
||||||
@@ -264,16 +266,11 @@ export function RuleView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const cardClassName = `${backgroundColor} ${cardPadding} ${cardGap} ${borderRadiusClass} shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] ${interactiveCard ? "hover:shadow-[0px_0px_64px_0px_rgba(0,0,0,0.15)] transition-shadow duration-200" : ""} flex flex-col items-start justify-center relative ${cardWidth || "w-full"} ${href ? "no-underline text-inherit" : ""} ${className || ""}`;
|
||||||
<div
|
const isLinkCard = Boolean(href) && interactiveCard;
|
||||||
className={`${backgroundColor} ${cardPadding} ${cardGap} ${borderRadiusClass} shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] ${interactiveCard ? "hover:shadow-[0px_0px_64px_0px_rgba(0,0,0,0.15)] transition-shadow duration-200" : ""} flex flex-col items-start justify-center relative ${cardWidth || "w-full"} ${className || ""}`}
|
|
||||||
tabIndex={interactiveCard ? 0 : undefined}
|
const cardBody = (
|
||||||
role={interactiveCard ? "button" : "article"}
|
<>
|
||||||
aria-label={ariaLabel}
|
|
||||||
aria-expanded={interactiveCard ? expanded : undefined}
|
|
||||||
onClick={interactiveCard ? onClick : undefined}
|
|
||||||
onKeyDown={interactiveCard ? onKeyDown : undefined}
|
|
||||||
>
|
|
||||||
{/* Figma: Header = `border-b` row, `gap-px`, icon `pl-1 pr-2 py-2` + `border-l` on title. */}
|
{/* Figma: Header = `border-b` row, `gap-px`, icon `pl-1 pr-2 py-2` + `border-l` on title. */}
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
@@ -483,6 +480,33 @@ export function RuleView({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLinkCard && href) {
|
||||||
|
return (
|
||||||
|
<NextLink
|
||||||
|
href={href}
|
||||||
|
className={cardClassName}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{cardBody}
|
||||||
|
</NextLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cardClassName}
|
||||||
|
tabIndex={interactiveCard ? 0 : undefined}
|
||||||
|
role={interactiveCard ? "button" : "article"}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-expanded={interactiveCard ? expanded : undefined}
|
||||||
|
onClick={interactiveCard ? onClick : undefined}
|
||||||
|
onKeyDown={interactiveCard ? onKeyDown : undefined}
|
||||||
|
>
|
||||||
|
{cardBody}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export interface StatProps {
|
|||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
asOf?: string;
|
asOf?: string;
|
||||||
|
asOfPrefix?: string;
|
||||||
|
sourceHref?: string;
|
||||||
|
sourceName?: string;
|
||||||
shapeVariant?: StatShapeVariant;
|
shapeVariant?: StatShapeVariant;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ function StatView({
|
|||||||
value,
|
value,
|
||||||
label,
|
label,
|
||||||
asOf,
|
asOf,
|
||||||
|
asOfPrefix,
|
||||||
|
sourceHref,
|
||||||
|
sourceName,
|
||||||
shapeVariant,
|
shapeVariant,
|
||||||
className = "",
|
className = "",
|
||||||
}: StatViewProps) {
|
}: StatViewProps) {
|
||||||
|
const sourcedYear = Boolean(asOfPrefix || sourceHref);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`relative flex h-auto min-h-[182px] w-full flex-col items-start justify-between rounded-[var(--radius-measures-radius-xlarge,20px)] bg-[var(--color-surface-invert-primary,white)] px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)] sm:h-[170px] sm:min-h-0 sm:p-[var(--spacing-scale-024)] ${className}`.trim()}
|
className={`relative flex h-auto min-h-[182px] w-full flex-col items-start justify-between rounded-[var(--radius-measures-radius-xlarge,20px)] bg-[var(--color-surface-invert-primary,white)] px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)] sm:h-[170px] sm:min-h-0 sm:p-[var(--spacing-scale-024)] ${className}`.trim()}
|
||||||
@@ -34,7 +39,23 @@ function StatView({
|
|||||||
</div>
|
</div>
|
||||||
{asOf ? (
|
{asOf ? (
|
||||||
<p className="w-full text-xx-small-paragraph text-[var(--color-content-invert-tertiary,#2d2d2d)]">
|
<p className="w-full text-xx-small-paragraph text-[var(--color-content-invert-tertiary,#2d2d2d)]">
|
||||||
|
{sourcedYear ? (
|
||||||
|
<>
|
||||||
|
{asOfPrefix ? <span>{`${asOfPrefix} `}</span> : null}
|
||||||
|
{sourceHref ? (
|
||||||
|
<a href={sourceHref} className="underline">
|
||||||
{asOf}
|
{asOf}
|
||||||
|
{sourceName ? (
|
||||||
|
<span className="sr-only">{` (${sourceName})`}</span>
|
||||||
|
) : null}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="underline">{asOf}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
asOf
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type { ContentContainerProps } from "./ContentContainer.types";
|
|||||||
const ContentContainerContainer = memo<ContentContainerProps>(
|
const ContentContainerContainer = memo<ContentContainerProps>(
|
||||||
({
|
({
|
||||||
post,
|
post,
|
||||||
width = "200px",
|
width: widthProp,
|
||||||
size: sizeProp = "responsive",
|
size: sizeProp = "responsive",
|
||||||
tone: toneProp = "inverse",
|
tone: toneProp = "inverse",
|
||||||
leadingImageSrc,
|
leadingImageSrc,
|
||||||
@@ -26,6 +26,8 @@ const ContentContainerContainer = memo<ContentContainerProps>(
|
|||||||
const size = sizeProp;
|
const size = sizeProp;
|
||||||
const tone = toneProp;
|
const tone = toneProp;
|
||||||
const showLeadingImage = showLeadingImageProp;
|
const showLeadingImage = showLeadingImageProp;
|
||||||
|
const width =
|
||||||
|
widthProp ?? (size === "useCase" ? "100%" : "200px");
|
||||||
const onLight = tone === "onLight";
|
const onLight = tone === "onLight";
|
||||||
const titleColor = onLight
|
const titleColor = onLight
|
||||||
? "text-[var(--color-content-default-primary)] group-hover:text-[var(--color-content-default-brand-primary)]"
|
? "text-[var(--color-content-default-primary)] group-hover:text-[var(--color-content-default-brand-primary)]"
|
||||||
@@ -57,39 +59,55 @@ const ContentContainerContainer = memo<ContentContainerProps>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isUseCase = size === "useCase";
|
||||||
|
|
||||||
const containerClasses =
|
const containerClasses =
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? "relative z-20 flex h-full flex-col gap-[var(--measures-spacing-012)]"
|
? "relative z-20 flex h-full flex-col gap-[var(--measures-spacing-012)]"
|
||||||
|
: isUseCase
|
||||||
|
? "relative z-20 flex h-full w-full min-w-0 flex-col gap-[var(--measures-spacing-024)]"
|
||||||
: "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]";
|
: "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]";
|
||||||
|
|
||||||
const contentGapClasses =
|
const contentGapClasses =
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? "flex flex-col gap-[var(--measures-spacing-008)]"
|
? "flex flex-col gap-[var(--measures-spacing-008)]"
|
||||||
|
: isUseCase
|
||||||
|
? "flex w-full min-w-0 flex-col gap-[var(--measures-spacing-016)]"
|
||||||
: "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]";
|
: "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]";
|
||||||
|
|
||||||
const textGapClasses =
|
const textGapClasses =
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? "flex flex-col gap-[var(--measures-spacing-004)]"
|
? "flex flex-col gap-[var(--measures-spacing-004)]"
|
||||||
|
: isUseCase
|
||||||
|
? "flex w-full min-w-0 flex-col gap-[var(--measures-spacing-004)]"
|
||||||
: "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]";
|
: "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]";
|
||||||
|
|
||||||
const titleClasses =
|
const titleClasses =
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? `font-bricolage-grotesque font-medium text-[18px] leading-[22px] transition-colors ${titleColor}`
|
? `font-bricolage-grotesque font-medium text-[18px] leading-[22px] transition-colors ${titleColor}`
|
||||||
|
: isUseCase
|
||||||
|
? `w-full font-bricolage-grotesque font-medium text-[32px] leading-[110%] lg:text-medium-display transition-colors ${titleColor}`
|
||||||
: `font-bricolage-grotesque font-medium text-xx-small-display sm:text-x-small-display md:text-[32px] md:leading-[110%] lg:text-medium-display xl:text-x-large-display transition-colors ${titleColor}`;
|
: `font-bricolage-grotesque font-medium text-xx-small-display sm:text-x-small-display md:text-[32px] md:leading-[110%] lg:text-medium-display xl:text-x-large-display transition-colors ${titleColor}`;
|
||||||
|
|
||||||
const descriptionClasses =
|
const descriptionClasses =
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? `text-x-small-paragraph max-w-md ${bodyColor}`
|
? `text-x-small-paragraph max-w-md ${bodyColor}`
|
||||||
|
: isUseCase
|
||||||
|
? `w-full text-small-paragraph lg:text-large-paragraph ${bodyColor}`
|
||||||
: `text-x-small-paragraph sm:text-small-paragraph md:text-small-paragraph lg:text-large-paragraph xl:text-x-large-paragraph ${bodyColor}`;
|
: `text-x-small-paragraph sm:text-small-paragraph md:text-small-paragraph lg:text-large-paragraph xl:text-x-large-paragraph ${bodyColor}`;
|
||||||
|
|
||||||
const authorClasses =
|
const authorClasses =
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-paragraph ${bodyColor}`
|
? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-paragraph ${bodyColor}`
|
||||||
|
: isUseCase
|
||||||
|
? `overflow-hidden text-ellipsis whitespace-nowrap text-x-small-paragraph lg:text-small-paragraph ${bodyColor}`
|
||||||
: `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`;
|
: `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`;
|
||||||
|
|
||||||
const dateClasses =
|
const dateClasses =
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-paragraph ${bodyColor}`
|
? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-paragraph ${bodyColor}`
|
||||||
|
: isUseCase
|
||||||
|
? `overflow-hidden text-ellipsis whitespace-nowrap text-x-small-paragraph lg:text-small-paragraph ${bodyColor}`
|
||||||
: `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`;
|
: `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { BlogPost } from "../../../../lib/content";
|
import type { BlogPost } from "../../../../lib/content";
|
||||||
|
import type { ContentContainerSizeValue } from "../../../../lib/propNormalization";
|
||||||
export type ContentContainerSizeValue = "xs" | "responsive";
|
|
||||||
|
|
||||||
/** `inverse` — blog hero on imagery; `onLight` — marketing pages on default surface. */
|
/** `inverse` — blog hero on imagery; `onLight` — marketing pages on default surface. */
|
||||||
export type ContentContainerToneValue = "inverse" | "onLight";
|
export type ContentContainerToneValue = "inverse" | "onLight";
|
||||||
@@ -9,7 +8,8 @@ export interface ContentContainerProps {
|
|||||||
post: BlogPost;
|
post: BlogPost;
|
||||||
width?: string;
|
width?: string;
|
||||||
/**
|
/**
|
||||||
* Content container size.
|
* `xs` — catalog thumbnail. `responsive` — article banner (scales through xl).
|
||||||
|
* `useCase` — case-study ContentBanner lockup (Figma 365px / medium-display).
|
||||||
*/
|
*/
|
||||||
size?: ContentContainerSizeValue;
|
size?: ContentContainerSizeValue;
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +27,7 @@ export interface ContentContainerProps {
|
|||||||
export interface ContentContainerViewProps {
|
export interface ContentContainerViewProps {
|
||||||
post: BlogPost;
|
post: BlogPost;
|
||||||
width: string;
|
width: string;
|
||||||
size: "xs" | "responsive";
|
size: ContentContainerSizeValue;
|
||||||
tone: ContentContainerToneValue;
|
tone: ContentContainerToneValue;
|
||||||
iconImage: string;
|
iconImage: string;
|
||||||
iconAlt: string;
|
iconAlt: string;
|
||||||
|
|||||||
@@ -29,10 +29,9 @@ export interface ChipProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Whether the chip should be non-interactive. Defaults to `true` when
|
* Whether the chip should be non-interactive. Defaults to `true` when
|
||||||
* `state === "disabled"` to preserve historical behavior. Pass
|
* `state === "disabled"`. Toggle groups use `selected` / `unselected`
|
||||||
* `disabled={false}` alongside `state="disabled"` to render the dimmed
|
* (Chip sets `aria-pressed`); pass `disabled` only when the chip cannot
|
||||||
* "disabled" visual while keeping the chip clickable — useful for toggle
|
* be activated.
|
||||||
* groups where the unselected state is the disabled visual.
|
|
||||||
*/
|
*/
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
|||||||
@@ -1,8 +1,81 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo, type CSSProperties } from "react";
|
||||||
import type { ChipViewProps } from "./Chip.types";
|
import type { ChipViewProps } from "./Chip.types";
|
||||||
|
|
||||||
|
function chipSurfaceStyle(
|
||||||
|
state: ChipViewProps["state"],
|
||||||
|
palette: ChipViewProps["palette"],
|
||||||
|
size: ChipViewProps["size"],
|
||||||
|
): CSSProperties {
|
||||||
|
const borderWidth = size === "s" ? "1.25px" : "2px";
|
||||||
|
const reset: CSSProperties = {
|
||||||
|
appearance: "none",
|
||||||
|
WebkitAppearance: "none",
|
||||||
|
backgroundImage: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
const paint = (
|
||||||
|
backgroundColor: string,
|
||||||
|
color: string,
|
||||||
|
borderColor?: string,
|
||||||
|
): CSSProperties => ({
|
||||||
|
...reset,
|
||||||
|
backgroundColor,
|
||||||
|
color,
|
||||||
|
WebkitTextFillColor: color,
|
||||||
|
...(borderColor
|
||||||
|
? { borderWidth, borderStyle: "solid", borderColor }
|
||||||
|
: { borderWidth: 0, borderStyle: "none", borderColor: "transparent" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (palette === "inverse") {
|
||||||
|
if (state === "disabled") {
|
||||||
|
return paint(
|
||||||
|
"var(--color-surface-inverse-tertiary)",
|
||||||
|
"var(--color-content-inverse-primary)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === "selected") {
|
||||||
|
return paint(
|
||||||
|
"var(--color-surface-default-semi-opaque)",
|
||||||
|
"var(--color-content-inverse-primary)",
|
||||||
|
"var(--color-border-default-primary)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return paint(
|
||||||
|
"transparent",
|
||||||
|
"var(--color-content-inverse-primary)",
|
||||||
|
"var(--color-border-default-primary)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "custom") {
|
||||||
|
return paint(
|
||||||
|
"var(--color-surface-default-secondary)",
|
||||||
|
"var(--color-content-default-tertiary)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === "disabled") {
|
||||||
|
return paint(
|
||||||
|
"var(--color-surface-default-secondary)",
|
||||||
|
"var(--color-content-invert-tertiary)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === "selected") {
|
||||||
|
return paint(
|
||||||
|
"var(--color-surface-invert-brand-primary, #fefcc9)",
|
||||||
|
"var(--color-content-invert-primary, #000)",
|
||||||
|
"var(--color-border-default-brand-primary, #fdfaa8)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return paint(
|
||||||
|
"transparent",
|
||||||
|
"var(--color-content-default-brand-primary, #fefcc9)",
|
||||||
|
"var(--color-border-default-tertiary, #464646)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ChipView({
|
function ChipView({
|
||||||
label,
|
label,
|
||||||
state,
|
state,
|
||||||
@@ -23,17 +96,15 @@ function ChipView({
|
|||||||
typeToAddPlaceholder,
|
typeToAddPlaceholder,
|
||||||
closeAriaLabel,
|
closeAriaLabel,
|
||||||
}: ChipViewProps) {
|
}: ChipViewProps) {
|
||||||
// The container is the source of truth for `disabled`. This allows
|
// The container is the source of truth for `disabled`. `state="disabled"`
|
||||||
// `state="disabled"` to be used purely as a visual (for toggle-group chips
|
// is the non-interactive visual only — toggle groups use selected/unselected.
|
||||||
// that look dimmed while remaining clickable) by passing `disabled={false}`.
|
|
||||||
const isDisabled = disabled ?? false;
|
const isDisabled = disabled ?? false;
|
||||||
const isSelected = state === "selected";
|
const isSelected = state === "selected";
|
||||||
const isCustom = state === "custom";
|
const isCustom = state === "custom";
|
||||||
|
const isToggle = !isCustom;
|
||||||
const isInverse = palette === "inverse";
|
|
||||||
const isDefault = palette === "default";
|
|
||||||
|
|
||||||
const isSmall = size === "s";
|
const isSmall = size === "s";
|
||||||
|
const surfaceStyle = chipSurfaceStyle(state, palette, size);
|
||||||
|
|
||||||
// Size-based styles from Figma tokens
|
// Size-based styles from Figma tokens
|
||||||
// Custom state has different padding
|
// Custom state has different padding
|
||||||
@@ -45,59 +116,8 @@ function ChipView({
|
|||||||
? "h-[30px] px-[var(--measures-spacing-200,8px)] gap-[var(--measures-spacing-050,2px)] text-x-small-label"
|
? "h-[30px] px-[var(--measures-spacing-200,8px)] gap-[var(--measures-spacing-050,2px)] text-x-small-label"
|
||||||
: "px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-300,12px)] gap-[var(--measures-spacing-150,6px)] text-medium-label";
|
: "px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-300,12px)] gap-[var(--measures-spacing-150,6px)] text-medium-label";
|
||||||
|
|
||||||
// Palette + state styling based on Figma examples
|
|
||||||
// Use consistent border width to prevent layout shift
|
|
||||||
const borderWidth = isSmall ? "border-[1.25px]" : "border-2";
|
|
||||||
|
|
||||||
let background =
|
|
||||||
"bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]";
|
|
||||||
let border = `${borderWidth} border-[var(--color-border-default-tertiary,#464646)]`;
|
|
||||||
let textColor =
|
|
||||||
"text-[color:var(--color-content-default-brand-primary,#fefcc9)]";
|
|
||||||
|
|
||||||
if (isDefault) {
|
|
||||||
if (state === "custom") {
|
|
||||||
background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background for custom
|
|
||||||
border = "border-none";
|
|
||||||
textColor = "text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
|
|
||||||
} else if (state === "disabled") {
|
|
||||||
background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background
|
|
||||||
border = "border-none";
|
|
||||||
// Per Figma (node 19839:13842) disabled uses invert-tertiary for the
|
|
||||||
// strongly dimmed look, not default-tertiary.
|
|
||||||
textColor = "text-[color:var(--color-content-invert-tertiary,#2d2d2d)]";
|
|
||||||
} else if (isSelected) {
|
|
||||||
background = "bg-[var(--color-surface-invert-brand-primary,#fefcc9)]"; // yellow selected
|
|
||||||
border = `${borderWidth} border-[var(--color-border-default-brand-primary,#fdfaa8)]`;
|
|
||||||
textColor = "text-[color:var(--color-content-invert-primary,black)]";
|
|
||||||
} else {
|
|
||||||
// Unselected default
|
|
||||||
background =
|
|
||||||
"bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]";
|
|
||||||
border = `${borderWidth} border-[var(--color-border-default-tertiary,#464646)]`;
|
|
||||||
textColor =
|
|
||||||
"text-[color:var(--color-content-default-brand-primary,#fefcc9)]";
|
|
||||||
}
|
|
||||||
} else if (isInverse) {
|
|
||||||
if (state === "disabled") {
|
|
||||||
background = "bg-[var(--color-surface-inverse-tertiary,#d2d2d2)]";
|
|
||||||
border = "border-none";
|
|
||||||
textColor = "text-[color:var(--color-content-inverse-primary,black)]";
|
|
||||||
} else if (isSelected) {
|
|
||||||
background =
|
|
||||||
"bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.05))]";
|
|
||||||
border = `${borderWidth} border-[var(--color-border-default-primary,#141414)]`;
|
|
||||||
textColor = "text-[color:var(--color-content-inverse-primary,black)]";
|
|
||||||
} else {
|
|
||||||
// Unselected / custom inverse
|
|
||||||
background =
|
|
||||||
"bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]";
|
|
||||||
border = `${borderWidth} border-[var(--color-border-default-primary,#141414)]`;
|
|
||||||
textColor = "text-[color:var(--color-content-inverse-primary,black)]";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseClasses = `
|
const baseClasses = `
|
||||||
|
appearance-none
|
||||||
inline-flex
|
inline-flex
|
||||||
max-w-full
|
max-w-full
|
||||||
items-center
|
items-center
|
||||||
@@ -107,7 +127,7 @@ function ChipView({
|
|||||||
box-border
|
box-border
|
||||||
focus:outline-none
|
focus:outline-none
|
||||||
focus-visible:ring-2
|
focus-visible:ring-2
|
||||||
focus-visible:ring-[var(--color-border-default-primary,#141414)]
|
focus-visible:ring-[var(--color-border-default-primary)]
|
||||||
focus-visible:ring-offset-2
|
focus-visible:ring-offset-2
|
||||||
focus-visible:ring-offset-transparent
|
focus-visible:ring-offset-transparent
|
||||||
transition-[background,border-color,color,box-shadow]
|
transition-[background,border-color,color,box-shadow]
|
||||||
@@ -121,15 +141,7 @@ function ChipView({
|
|||||||
? "cursor-not-allowed opacity-60"
|
? "cursor-not-allowed opacity-60"
|
||||||
: "cursor-pointer";
|
: "cursor-pointer";
|
||||||
|
|
||||||
const combinedClasses = [
|
const combinedClasses = [baseClasses, sizeClasses, stateClasses, className]
|
||||||
baseClasses,
|
|
||||||
sizeClasses,
|
|
||||||
background,
|
|
||||||
border,
|
|
||||||
textColor,
|
|
||||||
stateClasses,
|
|
||||||
className,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
@@ -145,6 +157,7 @@ function ChipView({
|
|||||||
|
|
||||||
const sharedA11y = {
|
const sharedA11y = {
|
||||||
"aria-label": ariaLabel,
|
"aria-label": ariaLabel,
|
||||||
|
...(isToggle ? { "aria-pressed": isSelected } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Custom state rendering with check/close buttons
|
// Custom state rendering with check/close buttons
|
||||||
@@ -152,6 +165,7 @@ function ChipView({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={combinedClasses}
|
className={combinedClasses}
|
||||||
|
style={surfaceStyle}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -261,11 +275,37 @@ function ChipView({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={combinedClasses}
|
className={combinedClasses}
|
||||||
|
style={surfaceStyle}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
{...sharedA11y}
|
{...sharedA11y}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate">{label}</span>
|
{isSelected ? (
|
||||||
|
<svg
|
||||||
|
aria-hidden
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={`shrink-0 ${isSmall ? "size-[12px]" : "size-[16px]"}`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10 3L4.5 8.5L2 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
className="min-w-0 truncate"
|
||||||
|
style={{
|
||||||
|
color: surfaceStyle.color,
|
||||||
|
WebkitTextFillColor: surfaceStyle.WebkitTextFillColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
{onRemove && !isDisabled && (
|
{onRemove && !isDisabled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId } from "react";
|
||||||
import type { InputWithCounterProps } from "./InputWithCounter.types";
|
import type { InputWithCounterProps } from "./InputWithCounter.types";
|
||||||
|
|
||||||
export function InputWithCounterView({
|
export function InputWithCounterView({
|
||||||
@@ -10,12 +13,16 @@ export function InputWithCounterView({
|
|||||||
className = "",
|
className = "",
|
||||||
inputClassName = "",
|
inputClassName = "",
|
||||||
}: InputWithCounterProps) {
|
}: InputWithCounterProps) {
|
||||||
|
const inputId = useId();
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-[var(--spacing-scale-008)] ${className}`}>
|
<div className={`space-y-[var(--spacing-scale-008)] ${className}`}>
|
||||||
{/* Label with help icon */}
|
{/* Label with help icon */}
|
||||||
{label && (
|
{label && (
|
||||||
<div className="flex items-center gap-[var(--spacing-scale-002)]">
|
<div className="flex items-center gap-[var(--spacing-scale-002)]">
|
||||||
<label className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]">
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
{showHelpIcon && (
|
{showHelpIcon && (
|
||||||
@@ -52,6 +59,7 @@ export function InputWithCounterView({
|
|||||||
{/* Input field */}
|
{/* Input field */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
|
id={inputId}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { MultiSelectProps } from "./MultiSelect.types";
|
|||||||
const MultiSelectContainer = memo<MultiSelectProps>(
|
const MultiSelectContainer = memo<MultiSelectProps>(
|
||||||
({
|
({
|
||||||
label,
|
label,
|
||||||
showHelpIcon = true,
|
showHelpIcon = false,
|
||||||
size: sizeProp = "m",
|
size: sizeProp = "m",
|
||||||
palette: paletteProp = "default",
|
palette: paletteProp = "default",
|
||||||
options,
|
options,
|
||||||
@@ -23,6 +23,9 @@ const MultiSelectContainer = memo<MultiSelectProps>(
|
|||||||
formHeader = true,
|
formHeader = true,
|
||||||
onCustomChipConfirm,
|
onCustomChipConfirm,
|
||||||
onCustomChipClose,
|
onCustomChipClose,
|
||||||
|
maxSelections,
|
||||||
|
selectionCountText,
|
||||||
|
limitReachedAnnouncement,
|
||||||
className = "",
|
className = "",
|
||||||
}) => {
|
}) => {
|
||||||
const t = useTranslation("controlsChrome");
|
const t = useTranslation("controlsChrome");
|
||||||
@@ -46,6 +49,9 @@ const MultiSelectContainer = memo<MultiSelectProps>(
|
|||||||
formHeader={formHeader}
|
formHeader={formHeader}
|
||||||
onCustomChipConfirm={onCustomChipConfirm}
|
onCustomChipConfirm={onCustomChipConfirm}
|
||||||
onCustomChipClose={onCustomChipClose}
|
onCustomChipClose={onCustomChipClose}
|
||||||
|
maxSelections={maxSelections}
|
||||||
|
selectionCountText={selectionCountText}
|
||||||
|
limitReachedAnnouncement={limitReachedAnnouncement}
|
||||||
className={className}
|
className={className}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ export interface MultiSelectProps {
|
|||||||
* Callback when a custom chip is closed/removed
|
* Callback when a custom chip is closed/removed
|
||||||
*/
|
*/
|
||||||
onCustomChipClose?: (chipId: string) => void;
|
onCustomChipClose?: (chipId: string) => void;
|
||||||
|
/**
|
||||||
|
* When set, unselected chips and the add control become non-interactive
|
||||||
|
* once this many options are `selected`.
|
||||||
|
*/
|
||||||
|
maxSelections?: number;
|
||||||
|
/** Visible selection counter, e.g. "3 of 5". */
|
||||||
|
selectionCountText?: string;
|
||||||
|
/** Announced when the selection limit is reached. */
|
||||||
|
limitReachedAnnouncement?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,5 +88,8 @@ export interface MultiSelectViewProps {
|
|||||||
formHeader: boolean;
|
formHeader: boolean;
|
||||||
onCustomChipConfirm?: (chipId: string, value: string) => void;
|
onCustomChipConfirm?: (chipId: string, value: string) => void;
|
||||||
onCustomChipClose?: (chipId: string) => void;
|
onCustomChipClose?: (chipId: string) => void;
|
||||||
|
maxSelections?: number;
|
||||||
|
selectionCountText?: string;
|
||||||
|
limitReachedAnnouncement?: string;
|
||||||
className: string;
|
className: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ function MultiSelectView({
|
|||||||
formHeader = true,
|
formHeader = true,
|
||||||
onCustomChipConfirm,
|
onCustomChipConfirm,
|
||||||
onCustomChipClose,
|
onCustomChipClose,
|
||||||
|
maxSelections,
|
||||||
|
selectionCountText,
|
||||||
|
limitReachedAnnouncement,
|
||||||
className,
|
className,
|
||||||
}: MultiSelectViewProps) {
|
}: MultiSelectViewProps) {
|
||||||
const isSmall = size === "s";
|
const isSmall = size === "s";
|
||||||
@@ -30,6 +33,11 @@ function MultiSelectView({
|
|||||||
: "gap-[var(--measures-spacing-300,12px)]";
|
: "gap-[var(--measures-spacing-300,12px)]";
|
||||||
|
|
||||||
const chipSize = size;
|
const chipSize = size;
|
||||||
|
const selectedCount = options.filter((o) => o.state === "selected").length;
|
||||||
|
const atLimit =
|
||||||
|
maxSelections != null && selectedCount >= maxSelections;
|
||||||
|
const countCaption = selectionCountText?.trim() ?? "";
|
||||||
|
const helperText = countCaption.length > 0 ? countCaption : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -41,25 +49,47 @@ function MultiSelectView({
|
|||||||
label={label}
|
label={label}
|
||||||
helpIcon={showHelpIcon}
|
helpIcon={showHelpIcon}
|
||||||
asterisk={false}
|
asterisk={false}
|
||||||
helperText={false}
|
helperText={helperText}
|
||||||
size={size}
|
size={size}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!formHeader || !label ? (
|
||||||
|
countCaption.length > 0 ? (
|
||||||
|
<p className="w-full text-x-small-paragraph text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
|
||||||
|
{countCaption}
|
||||||
|
</p>
|
||||||
|
) : null
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{limitReachedAnnouncement ? (
|
||||||
|
<p className="sr-only" aria-live="polite">
|
||||||
|
{atLimit ? limitReachedAnnouncement : ""}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Chips container */}
|
{/* Chips container */}
|
||||||
<div
|
<div
|
||||||
className={`flex flex-wrap ${gapClass} items-center relative shrink-0 w-full`}
|
className={`flex flex-wrap ${gapClass} items-center relative shrink-0 w-full`}
|
||||||
>
|
>
|
||||||
{options.map((option) => (
|
{options.map((option) => {
|
||||||
|
const isCustom = option.state === "custom";
|
||||||
|
const isSelected = option.state === "selected";
|
||||||
|
const chipDisabled = !isCustom && !isSelected && atLimit;
|
||||||
|
const chipState = chipDisabled
|
||||||
|
? "disabled"
|
||||||
|
: option.state || "unselected";
|
||||||
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
key={option.id}
|
key={option.id}
|
||||||
label={option.state === "custom" ? "" : option.label}
|
label={isCustom ? "" : option.label}
|
||||||
state={option.state || "unselected"}
|
state={chipState}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
size={chipSize}
|
size={chipSize}
|
||||||
|
disabled={chipDisabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (option.state !== "custom" && onChipClick) {
|
if (!isCustom && !chipDisabled && onChipClick) {
|
||||||
onChipClick(option.id);
|
onChipClick(option.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -76,23 +106,26 @@ function MultiSelectView({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Add button — icon-only: bordered circle + brand icon (chips stay yellow). With label: Figma 19688:38288 — brand + icon, primary label text, no fill/border. */}
|
{/* Add button — icon-only: bordered circle + brand icon (chips stay yellow). With label: Figma 19688:38288 — brand + icon, primary label text, no fill/border. */}
|
||||||
{addButton && (
|
{addButton && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={addButtonAriaLabel}
|
aria-label={addButtonAriaLabel}
|
||||||
|
disabled={atLimit}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (atLimit) return;
|
||||||
onAddClick?.();
|
onAddClick?.();
|
||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
!addButtonText
|
!addButtonText
|
||||||
? // Circular button with border (Rule style)
|
? // Circular button with border (Rule style)
|
||||||
`cursor-pointer bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary,#141414)]" : "border-[var(--color-border-default-tertiary,#464646)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`
|
`${atLimit ? "cursor-not-allowed opacity-60" : "cursor-pointer hover:opacity-80"} bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary,#141414)]" : "border-[var(--color-border-default-tertiary,#464646)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 transition-opacity`
|
||||||
: // Text add control (default palette: white label + brand “+”; inverse: inverse primary for both)
|
: // Text add control (default palette: white label + brand “+”; inverse: inverse primary for both)
|
||||||
`cursor-pointer flex items-center justify-center overflow-hidden rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity ${
|
`${atLimit ? "cursor-not-allowed opacity-60" : "cursor-pointer hover:opacity-80"} flex items-center justify-center overflow-hidden rounded-[var(--measures-radius-full,9999px)] shrink-0 transition-opacity ${
|
||||||
isSmall
|
isSmall
|
||||||
? "gap-[var(--measures-spacing-100,4px)] px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-200,8px)]"
|
? "gap-[var(--measures-spacing-100,4px)] px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-200,8px)]"
|
||||||
: "gap-[var(--measures-spacing-150,6px)] px-[var(--space-400,16px)] py-[var(--measures-spacing-300,12px)]"
|
: "gap-[var(--measures-spacing-150,6px)] px-[var(--space-400,16px)] py-[var(--measures-spacing-300,12px)]"
|
||||||
|
|||||||
@@ -36,9 +36,8 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
|
|||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
return (
|
const labelEl = label ? (
|
||||||
<div className={containerClasses}>
|
formHeader ? (
|
||||||
{formHeader && label && (
|
|
||||||
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
|
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
|
||||||
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
|
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
|
||||||
<label
|
<label
|
||||||
@@ -60,7 +59,16 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
<label id={labelId} htmlFor={textareaId} className="sr-only">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClasses}>
|
||||||
|
{labelEl}
|
||||||
<div className={disabled ? "opacity-40" : ""}>
|
<div className={disabled ? "opacity-40" : ""}>
|
||||||
<textarea
|
<textarea
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
|
|||||||
textHint = false,
|
textHint = false,
|
||||||
formHeader = true,
|
formHeader = true,
|
||||||
maxLength,
|
maxLength,
|
||||||
|
autoComplete,
|
||||||
|
inputMode,
|
||||||
|
required = false,
|
||||||
|
form,
|
||||||
|
onKeyDown,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -250,6 +255,11 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
|
|||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
helpIconAlt={t("helpIconAlt")}
|
helpIconAlt={t("helpIconAlt")}
|
||||||
hintDefault={t("hintDefault")}
|
hintDefault={t("hintDefault")}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
inputMode={inputMode}
|
||||||
|
required={required}
|
||||||
|
form={form}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -67,4 +67,9 @@ export interface TextInputViewProps {
|
|||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
helpIconAlt: string;
|
helpIconAlt: string;
|
||||||
hintDefault: string;
|
hintDefault: string;
|
||||||
|
autoComplete?: string;
|
||||||
|
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
|
||||||
|
required?: boolean;
|
||||||
|
form?: string;
|
||||||
|
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
|
|||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
disabled,
|
disabled,
|
||||||
error: _error,
|
error = false,
|
||||||
className: _className,
|
className: _className,
|
||||||
containerClasses,
|
containerClasses,
|
||||||
labelClasses,
|
labelClasses,
|
||||||
@@ -31,12 +31,18 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
|
|||||||
maxLength,
|
maxLength,
|
||||||
helpIconAlt,
|
helpIconAlt,
|
||||||
hintDefault,
|
hintDefault,
|
||||||
|
autoComplete,
|
||||||
|
inputMode,
|
||||||
|
required = false,
|
||||||
|
form,
|
||||||
|
onKeyDown,
|
||||||
|
state: _state,
|
||||||
|
isFilled: _isFilled,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
return (
|
const labelEl = label ? (
|
||||||
<div className={containerClasses}>
|
formHeader ? (
|
||||||
{formHeader && label && (
|
|
||||||
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
|
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
|
||||||
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
|
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
|
||||||
<label
|
<label
|
||||||
@@ -58,7 +64,16 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
<label id={labelId} htmlFor={inputId} className="sr-only">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClasses}>
|
||||||
|
{labelEl}
|
||||||
<div className={inputWrapperClasses}>
|
<div className={inputWrapperClasses}>
|
||||||
<div className={disabled ? "opacity-40" : ""}>
|
<div className={disabled ? "opacity-40" : ""}>
|
||||||
<input
|
<input
|
||||||
@@ -71,9 +86,16 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
inputMode={inputMode}
|
||||||
|
required={required}
|
||||||
|
form={form}
|
||||||
|
aria-invalid={error || undefined}
|
||||||
|
aria-required={required || undefined}
|
||||||
className={inputClasses}
|
className={inputClasses}
|
||||||
style={{ borderRadius }}
|
style={{ borderRadius }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,93 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR =
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
let lastInteractedFocusable: HTMLElement | null = null;
|
||||||
|
let interactionTrackingBound = false;
|
||||||
|
|
||||||
|
function closestFocusable(target: EventTarget | null): HTMLElement | null {
|
||||||
|
if (!(target instanceof Element)) return null;
|
||||||
|
const match = target.closest(FOCUSABLE_SELECTOR);
|
||||||
|
return match instanceof HTMLElement ? match : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Menu items unmount on select; restore to the control that opened the menu. */
|
||||||
|
function menuTriggerFor(item: HTMLElement): HTMLElement | null {
|
||||||
|
if (item.getAttribute("role") !== "menuitem") return null;
|
||||||
|
const menu = item.closest("[role='menu']");
|
||||||
|
const menuId = menu?.getAttribute("id");
|
||||||
|
if (menuId) {
|
||||||
|
const trigger = document.querySelector(
|
||||||
|
`[aria-controls="${CSS.escape(menuId)}"]`,
|
||||||
|
);
|
||||||
|
if (trigger instanceof HTMLElement) return trigger;
|
||||||
|
}
|
||||||
|
const expanded = document.querySelector(
|
||||||
|
'[aria-haspopup="menu"][aria-expanded="true"]',
|
||||||
|
);
|
||||||
|
return expanded instanceof HTMLElement ? expanded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableFocusableFrom(target: EventTarget | null): HTMLElement | null {
|
||||||
|
const focusable = closestFocusable(target);
|
||||||
|
if (!focusable) return null;
|
||||||
|
return menuTriggerFor(focusable) ?? focusable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRestorable(
|
||||||
|
node: HTMLElement | null,
|
||||||
|
dialog: HTMLElement | null,
|
||||||
|
): node is HTMLElement {
|
||||||
|
if (!node?.isConnected) return false;
|
||||||
|
if (node === document.body || node === document.documentElement) return false;
|
||||||
|
if (dialog?.contains(node)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retainLastInteracted(): void {
|
||||||
|
if (interactionTrackingBound || typeof document === "undefined") return;
|
||||||
|
interactionTrackingBound = true;
|
||||||
|
const save = (event: Event) => {
|
||||||
|
const el = stableFocusableFrom(event.target);
|
||||||
|
if (!el) return;
|
||||||
|
lastInteractedFocusable = el;
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", save, true);
|
||||||
|
document.addEventListener("focusin", save);
|
||||||
|
}
|
||||||
|
|
||||||
|
retainLastInteracted();
|
||||||
|
|
||||||
|
function snapshotTrigger(dialog: HTMLElement | null): HTMLElement | null {
|
||||||
|
if (lastInteractedFocusable && !lastInteractedFocusable.isConnected) {
|
||||||
|
lastInteractedFocusable = null;
|
||||||
|
}
|
||||||
|
const active =
|
||||||
|
document.activeElement instanceof HTMLElement
|
||||||
|
? document.activeElement
|
||||||
|
: null;
|
||||||
|
const fromActive = isRestorable(active, dialog)
|
||||||
|
? (menuTriggerFor(active) ?? active)
|
||||||
|
: null;
|
||||||
|
if (fromActive && isRestorable(fromActive, dialog)) return fromActive;
|
||||||
|
if (isRestorable(lastInteractedFocusable, dialog)) {
|
||||||
|
return lastInteractedFocusable;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreFocus(node: HTMLElement | null): void {
|
||||||
|
if (!node?.isConnected) return;
|
||||||
|
node.focus();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escape-to-close, body scroll lock, focus move-in and tab trap for Create-shell modals.
|
* Escape-to-close, body scroll lock, focus move-in, tab trap, and restore
|
||||||
|
* focus to the control that opened a Create-shell modal.
|
||||||
*/
|
*/
|
||||||
export function useCreateModalA11y(
|
export function useCreateModalA11y(
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
@@ -28,17 +111,17 @@ export function useCreateModalA11y(
|
|||||||
};
|
};
|
||||||
}, [isOpen, onClose]);
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
previousActiveElementRef.current = document.activeElement as HTMLElement;
|
previousActiveElementRef.current = snapshotTrigger(dialogRef.current);
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
if (dialogRef.current) {
|
if (dialogRef.current) {
|
||||||
const focusableElements = dialogRef.current.querySelectorAll(
|
const focusableElements = dialogRef.current.querySelectorAll(
|
||||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
FOCUSABLE_SELECTOR,
|
||||||
);
|
);
|
||||||
const firstElement = focusableElements[0] as HTMLElement;
|
const firstElement = focusableElements[0] as HTMLElement | undefined;
|
||||||
if (firstElement) {
|
if (firstElement) {
|
||||||
firstElement.focus();
|
firstElement.focus();
|
||||||
} else {
|
} else {
|
||||||
@@ -51,24 +134,22 @@ export function useCreateModalA11y(
|
|||||||
if (e.key !== "Tab" || !dialogRef.current) return;
|
if (e.key !== "Tab" || !dialogRef.current) return;
|
||||||
|
|
||||||
const focusableElements = dialogRef.current.querySelectorAll(
|
const focusableElements = dialogRef.current.querySelectorAll(
|
||||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
FOCUSABLE_SELECTOR,
|
||||||
);
|
);
|
||||||
const firstElement = focusableElements[0] as HTMLElement;
|
const firstElement = focusableElements[0] as HTMLElement | undefined;
|
||||||
const lastElement = focusableElements[
|
const lastElement = focusableElements[
|
||||||
focusableElements.length - 1
|
focusableElements.length - 1
|
||||||
] as HTMLElement;
|
] as HTMLElement | undefined;
|
||||||
|
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
if (document.activeElement === firstElement) {
|
if (document.activeElement === firstElement) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
lastElement?.focus();
|
lastElement?.focus();
|
||||||
}
|
}
|
||||||
} else {
|
} else if (document.activeElement === lastElement) {
|
||||||
if (document.activeElement === lastElement) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
firstElement?.focus();
|
firstElement?.focus();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("keydown", handleTab);
|
document.addEventListener("keydown", handleTab);
|
||||||
@@ -76,7 +157,7 @@ export function useCreateModalA11y(
|
|||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = "";
|
document.body.style.overflow = "";
|
||||||
document.removeEventListener("keydown", handleTab);
|
document.removeEventListener("keydown", handleTab);
|
||||||
previousActiveElementRef.current?.focus();
|
restoreFocus(previousActiveElementRef.current);
|
||||||
};
|
};
|
||||||
}, [dialogRef, isOpen]);
|
}, [dialogRef, isOpen]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
/**
|
/**
|
||||||
* Figma: Community Rule System — "Modal / Share"
|
* Figma: Community Rule System — "Modal / Share"
|
||||||
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22073-30884
|
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22073-30884
|
||||||
|
* Copy-link hover / Copied! : 23769:27494 / 23769:27443
|
||||||
*/
|
*/
|
||||||
import { memo, useId, useRef } from "react";
|
import { memo, useCallback, useEffect, useId, useRef, useState } from "react";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
import { useCreateModalA11y } from "../Create/useCreateModalA11y";
|
import { useCreateModalA11y } from "../Create/useCreateModalA11y";
|
||||||
import { ShareView } from "./Share.view";
|
import { ShareView } from "./Share.view";
|
||||||
@@ -15,9 +16,27 @@ const ShareContainer = memo<ShareProps>((props) => {
|
|||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const t = useTranslation("modals.share");
|
const t = useTranslation("modals.share");
|
||||||
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
|
|
||||||
useCreateModalA11y(props.isOpen, props.onClose, dialogRef);
|
useCreateModalA11y(props.isOpen, props.onClose, dialogRef);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!props.isOpen) {
|
||||||
|
setLinkCopied(false);
|
||||||
|
}
|
||||||
|
}, [props.isOpen]);
|
||||||
|
|
||||||
|
const onCopyLinkClick = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await props.onCopyLink();
|
||||||
|
if (result !== false) {
|
||||||
|
setLinkCopied(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setLinkCopied(false);
|
||||||
|
}
|
||||||
|
}, [props.onCopyLink]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ShareView
|
<ShareView
|
||||||
{...props}
|
{...props}
|
||||||
@@ -27,6 +46,10 @@ const ShareContainer = memo<ShareProps>((props) => {
|
|||||||
title={t("title")}
|
title={t("title")}
|
||||||
description={t("description")}
|
description={t("description")}
|
||||||
copyLinkLabel={t("copyLink")}
|
copyLinkLabel={t("copyLink")}
|
||||||
|
copiedLabel={t("copied")}
|
||||||
|
copiedLive={t("copiedLive")}
|
||||||
|
linkCopied={linkCopied}
|
||||||
|
onCopyLinkClick={onCopyLinkClick}
|
||||||
signalLabel={t("signal")}
|
signalLabel={t("signal")}
|
||||||
slackLabel={t("slack")}
|
slackLabel={t("slack")}
|
||||||
discordLabel={t("discord")}
|
discordLabel={t("discord")}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type { CreateModalBackdropVariant } from "../Create/CreateModalFrame.view
|
|||||||
export type ShareProps = {
|
export type ShareProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCopyLink: () => void | Promise<void>;
|
/** Return `false` when the clipboard write did not succeed. */
|
||||||
|
onCopyLink: () => void | boolean | Promise<void | boolean>;
|
||||||
onEmailShare: () => void;
|
onEmailShare: () => void;
|
||||||
onSignalShare: () => void | Promise<void>;
|
onSignalShare: () => void | Promise<void>;
|
||||||
onSlackShare: () => void | Promise<void>;
|
onSlackShare: () => void | Promise<void>;
|
||||||
@@ -20,6 +21,10 @@ export type ShareViewProps = ShareProps & {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
copyLinkLabel: string;
|
copyLinkLabel: string;
|
||||||
|
copiedLabel: string;
|
||||||
|
copiedLive: string;
|
||||||
|
linkCopied: boolean;
|
||||||
|
onCopyLinkClick: () => void | Promise<void>;
|
||||||
signalLabel: string;
|
signalLabel: string;
|
||||||
slackLabel: string;
|
slackLabel: string;
|
||||||
discordLabel: string;
|
discordLabel: string;
|
||||||
@@ -34,4 +39,5 @@ export type ShareChannelTileProps = {
|
|||||||
onClick: () => void | Promise<void>;
|
onClick: () => void | Promise<void>;
|
||||||
circleClassName: string;
|
circleClassName: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
|
copied?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,19 +34,32 @@ function ShareAssetIcon(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShareChannelTile({ label, onClick, circleClassName, icon }: ShareChannelTileProps) {
|
function ShareChannelTile({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
circleClassName,
|
||||||
|
icon,
|
||||||
|
copied = false,
|
||||||
|
}: ShareChannelTileProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void onClick()}
|
onClick={() => void onClick()}
|
||||||
className="flex w-16 shrink-0 flex-col items-center gap-2 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
|
aria-pressed={copied || undefined}
|
||||||
|
className="group flex w-16 shrink-0 flex-col items-center gap-2 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex h-[60px] w-[60px] items-center justify-center rounded-full border border-solid ${circleClassName}`}
|
className={`flex h-[60px] w-[60px] items-center justify-center rounded-full border border-solid transition-[transform,background-color,border-color,filter] duration-150 ease-out group-active:scale-90 ${circleClassName}`}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<span className="max-w-[4.5rem] text-center text-x-small-label text-[var(--color-content-default-tertiary)]">
|
<span
|
||||||
|
className={`max-w-[4.5rem] text-center text-x-small-label ${
|
||||||
|
copied
|
||||||
|
? "text-[var(--color-border-default-positive-primary)]"
|
||||||
|
: "text-[var(--color-content-default-tertiary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -56,7 +69,6 @@ function ShareChannelTile({ label, onClick, circleClassName, icon }: ShareChanne
|
|||||||
export const ShareView = memo(function ShareView({
|
export const ShareView = memo(function ShareView({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onCopyLink,
|
|
||||||
onEmailShare,
|
onEmailShare,
|
||||||
onSignalShare,
|
onSignalShare,
|
||||||
onSlackShare,
|
onSlackShare,
|
||||||
@@ -69,6 +81,10 @@ export const ShareView = memo(function ShareView({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
copyLinkLabel,
|
copyLinkLabel,
|
||||||
|
copiedLabel,
|
||||||
|
copiedLive,
|
||||||
|
linkCopied,
|
||||||
|
onCopyLinkClick,
|
||||||
signalLabel,
|
signalLabel,
|
||||||
slackLabel,
|
slackLabel,
|
||||||
discordLabel,
|
discordLabel,
|
||||||
@@ -109,36 +125,50 @@ export const ShareView = memo(function ShareView({
|
|||||||
{/* Channel circle hexes are third-party brand colors (copy/link, Signal, Slack, Discord), not DS tokens. */}
|
{/* Channel circle hexes are third-party brand colors (copy/link, Signal, Slack, Discord), not DS tokens. */}
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<ShareChannelTile
|
<ShareChannelTile
|
||||||
label={copyLinkLabel}
|
label={linkCopied ? copiedLabel : copyLinkLabel}
|
||||||
onClick={onCopyLink}
|
onClick={onCopyLinkClick}
|
||||||
circleClassName="border-[#444444] bg-[#333333]"
|
copied={linkCopied}
|
||||||
icon={<ShareAssetIcon name="link" width={24} height={24} />}
|
circleClassName={
|
||||||
|
linkCopied
|
||||||
|
? "border-2 border-[#444444] bg-[#333333]"
|
||||||
|
: "border-[#444444] bg-[#333333] group-hover:border-2 group-hover:border-[var(--color-border-default-positive-primary)] group-hover:bg-[#444444]"
|
||||||
|
}
|
||||||
|
icon={
|
||||||
|
<ShareAssetIcon
|
||||||
|
name={linkCopied ? "check" : "link"}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<ShareChannelTile
|
<ShareChannelTile
|
||||||
label={signalLabel}
|
label={signalLabel}
|
||||||
onClick={onSignalShare}
|
onClick={onSignalShare}
|
||||||
circleClassName="border-[#3a76f0] bg-[#3a76f0]"
|
circleClassName="border-[#3a76f0] bg-[#3a76f0] group-hover:brightness-110"
|
||||||
icon={<ShareAssetIcon name="signal" width={26} height={26} />}
|
icon={<ShareAssetIcon name="signal" width={26} height={26} />}
|
||||||
/>
|
/>
|
||||||
<ShareChannelTile
|
<ShareChannelTile
|
||||||
label={slackLabel}
|
label={slackLabel}
|
||||||
onClick={onSlackShare}
|
onClick={onSlackShare}
|
||||||
circleClassName="border-[#4a154b] bg-[#4a154b]"
|
circleClassName="border-[#4a154b] bg-[#4a154b] group-hover:brightness-110"
|
||||||
icon={<ShareAssetIcon name="slack" width={26} height={26} />}
|
icon={<ShareAssetIcon name="slack" width={26} height={26} />}
|
||||||
/>
|
/>
|
||||||
<ShareChannelTile
|
<ShareChannelTile
|
||||||
label={discordLabel}
|
label={discordLabel}
|
||||||
onClick={onDiscordShare}
|
onClick={onDiscordShare}
|
||||||
circleClassName="border-[#5865f2] bg-[#5865f2]"
|
circleClassName="border-[#5865f2] bg-[#5865f2] group-hover:brightness-110"
|
||||||
icon={<ShareAssetIcon name="discord" width={30} height={30} />}
|
icon={<ShareAssetIcon name="discord" width={30} height={30} />}
|
||||||
/>
|
/>
|
||||||
<ShareChannelTile
|
<ShareChannelTile
|
||||||
label={emailLabel}
|
label={emailLabel}
|
||||||
onClick={onEmailShare}
|
onClick={onEmailShare}
|
||||||
circleClassName="border-[var(--color-surface-default-brand-kiwi)] bg-[var(--color-surface-default-brand-kiwi)]"
|
circleClassName="border-[var(--color-surface-default-brand-kiwi)] bg-[var(--color-surface-default-brand-kiwi)] group-hover:brightness-110"
|
||||||
icon={<ShareAssetIcon name="mail" width={24} height={24} />}
|
icon={<ShareAssetIcon name="mail" width={24} height={24} />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="sr-only" aria-live="polite">
|
||||||
|
{linkCopied ? copiedLive : ""}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModalFooter
|
<ModalFooter
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const CreateFlowFooterContainer = memo<CreateFlowFooterProps>(
|
|||||||
proportionBarVariant,
|
proportionBarVariant,
|
||||||
onBackClick,
|
onBackClick,
|
||||||
className = "",
|
className = "",
|
||||||
|
contentMaxClass,
|
||||||
footerAriaLabel,
|
footerAriaLabel,
|
||||||
}) => {
|
}) => {
|
||||||
const t = useTranslation("controlsChrome");
|
const t = useTranslation("controlsChrome");
|
||||||
@@ -29,6 +30,7 @@ const CreateFlowFooterContainer = memo<CreateFlowFooterProps>(
|
|||||||
proportionBarVariant={proportionBarVariant}
|
proportionBarVariant={proportionBarVariant}
|
||||||
onBackClick={onBackClick}
|
onBackClick={onBackClick}
|
||||||
className={className}
|
className={className}
|
||||||
|
contentMaxClass={contentMaxClass}
|
||||||
footerAriaLabel={footerAriaLabel ?? t("createFlowFooterAriaLabel")}
|
footerAriaLabel={footerAriaLabel ?? t("createFlowFooterAriaLabel")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export interface CreateFlowFooterProps {
|
|||||||
* Additional CSS classes
|
* Additional CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Inner max-width for the progress bar and actions, matching the step
|
||||||
|
* content column so the primary button does not drift at wide viewports.
|
||||||
|
*/
|
||||||
|
contentMaxClass?: string;
|
||||||
/**
|
/**
|
||||||
* Accessible name for the footer landmark.
|
* Accessible name for the footer landmark.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import ProportionBar from "../../progress/ProportionBar";
|
import ProportionBar from "../../progress/ProportionBar";
|
||||||
import Button from "../../buttons/Button";
|
import Button from "../../buttons/Button";
|
||||||
import type { CreateFlowFooterProps } from "./CreateFlowFooter.types";
|
import type { CreateFlowFooterProps } from "./CreateFlowFooter.types";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_FOOTER_SCRIM_CLASS,
|
||||||
|
CREATE_FLOW_PAGE_GUTTER_CLASS,
|
||||||
|
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||||
|
} from "../../../(app)/create/components/createFlowLayoutTokens";
|
||||||
|
|
||||||
export function CreateFlowFooterView({
|
export function CreateFlowFooterView({
|
||||||
secondButton,
|
secondButton,
|
||||||
@@ -9,18 +14,21 @@ export function CreateFlowFooterView({
|
|||||||
proportionBarVariant: proportionBarVariantProp,
|
proportionBarVariant: proportionBarVariantProp,
|
||||||
onBackClick,
|
onBackClick,
|
||||||
className = "",
|
className = "",
|
||||||
|
contentMaxClass = CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||||
footerAriaLabel,
|
footerAriaLabel,
|
||||||
}: CreateFlowFooterProps) {
|
}: CreateFlowFooterProps) {
|
||||||
const proportionBarVariant = proportionBarVariantProp ?? "default";
|
const proportionBarVariant = proportionBarVariantProp ?? "default";
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
className={`bg-black w-full ${className}`}
|
className={`relative bg-black w-full pb-[env(safe-area-inset-bottom,0px)] ${className}`}
|
||||||
role="contentinfo"
|
role="contentinfo"
|
||||||
aria-label={footerAriaLabel}
|
aria-label={footerAriaLabel}
|
||||||
>
|
>
|
||||||
{/* Progress Bar - Top */}
|
<div className={CREATE_FLOW_FOOTER_SCRIM_CLASS} aria-hidden />
|
||||||
|
<div className={CREATE_FLOW_PAGE_GUTTER_CLASS}>
|
||||||
|
<div className={`mx-auto w-full ${contentMaxClass}`}>
|
||||||
{progressBar && (
|
{progressBar && (
|
||||||
<div className="px-[var(--spacing-measures-spacing-500,20px)] md:px-[var(--spacing-measures-spacing-1200,48px)] pt-[var(--spacing-measures-spacing-300,12px)]">
|
<div className="pt-[var(--spacing-measures-spacing-300,12px)]">
|
||||||
<ProportionBar
|
<ProportionBar
|
||||||
progress={proportionBarProgress}
|
progress={proportionBarProgress}
|
||||||
variant={proportionBarVariant}
|
variant={proportionBarVariant}
|
||||||
@@ -28,9 +36,7 @@ export function CreateFlowFooterView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Buttons Container */}
|
<div className="flex items-center justify-between py-[var(--spacing-measures-spacing-300,12px)] gap-[var(--spacing-measures-spacing-300,12px)]">
|
||||||
<div className="flex items-center justify-between mx-auto max-w-[639px] md:max-w-[1920px] px-[var(--spacing-measures-spacing-500,20px)] md:px-[var(--spacing-measures-spacing-1200,48px)] py-[var(--spacing-measures-spacing-300,12px)] gap-[var(--spacing-measures-spacing-300,12px)]">
|
|
||||||
{/* Back Button - Left */}
|
|
||||||
<Button
|
<Button
|
||||||
buttonType="ghost"
|
buttonType="ghost"
|
||||||
palette="default"
|
palette="default"
|
||||||
@@ -42,9 +48,10 @@ export function CreateFlowFooterView({
|
|||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Second Button - Right */}
|
|
||||||
{secondButton && <div className="flex-shrink-0">{secondButton}</div>}
|
{secondButton && <div className="flex-shrink-0">{secondButton}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Button from "../../buttons/Button";
|
|||||||
import ListItem from "../../layout/ListItem";
|
import ListItem from "../../layout/ListItem";
|
||||||
import Popover from "../../modals/Popover";
|
import Popover from "../../modals/Popover";
|
||||||
import type { CreateFlowTopNavViewProps } from "./CreateFlowTopNav.types";
|
import type { CreateFlowTopNavViewProps } from "./CreateFlowTopNav.types";
|
||||||
|
import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "../../../(app)/create/components/createFlowLayoutTokens";
|
||||||
|
|
||||||
const outlineButtonClass =
|
const outlineButtonClass =
|
||||||
"md:!text-x-small-label !text-xx-small-label !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]";
|
"md:!text-x-small-label !text-xx-small-label !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]";
|
||||||
@@ -229,7 +230,7 @@ export function CreateFlowTopNavView({
|
|||||||
aria-label={bannerAriaLabel}
|
aria-label={bannerAriaLabel}
|
||||||
>
|
>
|
||||||
<nav
|
<nav
|
||||||
className="flex items-center justify-between mx-auto max-w-[639px] md:max-w-[1920px] px-[var(--spacing-measures-spacing-500,20px)] md:px-[48px] py-[var(--spacing-measures-spacing-300,12px)] md:py-[var(--spacing-measures-spacing-016,16px)]"
|
className={`flex items-center justify-between mx-auto w-full max-w-[1920px] ${CREATE_FLOW_PAGE_GUTTER_CLASS} py-[var(--spacing-measures-spacing-300,12px)] md:py-[var(--spacing-measures-spacing-016,16px)]`}
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label={navAriaLabel}
|
aria-label={navAriaLabel}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo } from "react";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { isChromelessNavigationPath } from "../../../lib/navigationChromelessPath";
|
|
||||||
import TopWithPathname from "./Top/TopWithPathname";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marketing-only navigation. Skips the server-side `getNavAuthSignedIn()` call
|
|
||||||
* so marketing pages can render statically (no `force-dynamic`); `TopWithPathname`
|
|
||||||
* fetches `/api/auth/session` on mount and updates the header from "Log in" to
|
|
||||||
* "Profile" when the user is signed in. Brief mismatch is acceptable here —
|
|
||||||
* `(app)` / `(admin)` keep the server-rendered nav.
|
|
||||||
*/
|
|
||||||
const MarketingNavigation = memo(() => {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
if (isChromelessNavigationPath(pathname)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <TopWithPathname initialSignedIn={false} />;
|
|
||||||
});
|
|
||||||
|
|
||||||
MarketingNavigation.displayName = "MarketingNavigation";
|
|
||||||
|
|
||||||
export default MarketingNavigation;
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import header from "../../../messages/en/components/header.json";
|
||||||
|
import { MAIN_CONTENT_ID } from "../../../lib/mainContent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First focusable control in the shell. Revealed on focus so keyboard users
|
||||||
|
* can bypass the header and land on the group `<main>`.
|
||||||
|
*/
|
||||||
|
export default function SkipToContent() {
|
||||||
|
return (
|
||||||
|
<a href={`#${MAIN_CONTENT_ID}`} className="skip-to-content text-medium-label">
|
||||||
|
{header.skipToContent}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Figma: "Navigation / Top" (22078-808559)
|
* Figma: "Navigation / Top" (22078-808559); Folder-top 430–639 (18582:22056),
|
||||||
|
* 640–1023 (18580:17113).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { memo, useCallback } from "react";
|
import { memo, useCallback } from "react";
|
||||||
@@ -15,18 +16,7 @@ import Avatar from "../../asset/Avatar";
|
|||||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
||||||
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||||
import { TopView } from "./Top.view";
|
import { TopView } from "./Top.view";
|
||||||
import type { TopProps, NavSize } from "./Top.types";
|
import type { TopProps } from "./Top.types";
|
||||||
|
|
||||||
type MenuClusterSize = "X Small" | "Small" | "Medium" | "Large" | "X Large";
|
|
||||||
|
|
||||||
/** Map responsive `NavSize` breakpoints to Figma menu item sizes (shared by nav links + login). */
|
|
||||||
const NAV_SIZE_TO_MENU_ITEM_SIZE: Record<NavSize, MenuClusterSize> = {
|
|
||||||
xsmall: "X Small",
|
|
||||||
homeMd: "Medium",
|
|
||||||
large: "Large",
|
|
||||||
homeXlarge: "X Large",
|
|
||||||
xlarge: "X Large",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const avatarImageSources = [
|
export const avatarImageSources = [
|
||||||
getAssetPath(ASSETS.AVATAR_3),
|
getAssetPath(ASSETS.AVATAR_3),
|
||||||
@@ -40,6 +30,19 @@ export const avatarImages = avatarImageSources.map((src, index) => ({
|
|||||||
alt: `Avatar ${3 - index}`,
|
alt: `Avatar ${3 - index}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/** Padding/type that tracks lg/xl Menu sizes without cloning the items per breakpoint. */
|
||||||
|
const NAV_ITEM_RESPONSIVE_CLASS =
|
||||||
|
"lg:px-[var(--spacing-scale-016)] lg:py-[var(--spacing-scale-016)] lg:h-[44px] lg:text-medium-label xl:text-x-large-label";
|
||||||
|
|
||||||
|
const FOLDER_NAV_ITEM_RESPONSIVE_CLASS =
|
||||||
|
"md:px-[var(--spacing-scale-008)] md:py-[var(--spacing-scale-008)] md:h-[32px] md:text-x-small-label lg:px-[var(--spacing-scale-016)] lg:py-[var(--spacing-scale-016)] lg:h-[44px] lg:text-medium-label xl:text-x-large-label";
|
||||||
|
|
||||||
|
const CREATE_RULE_RESPONSIVE_CLASS =
|
||||||
|
"lg:p-[var(--spacing-scale-012)] lg:gap-[var(--spacing-scale-006)] lg:text-medium-label xl:p-[var(--spacing-scale-016)] xl:gap-[var(--spacing-scale-008)] xl:text-x-large-label";
|
||||||
|
|
||||||
|
const FOLDER_CREATE_RULE_RESPONSIVE_CLASS =
|
||||||
|
"md:p-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-002)] md:text-x-small-label lg:p-[var(--spacing-scale-012)] lg:gap-[var(--spacing-scale-006)] lg:text-medium-label xl:p-[var(--spacing-scale-016)] xl:gap-[var(--spacing-scale-008)] xl:text-x-large-label";
|
||||||
|
|
||||||
const TopContainer = memo<TopProps>(
|
const TopContainer = memo<TopProps>(
|
||||||
({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => {
|
({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -61,7 +64,6 @@ const TopContainer = memo<TopProps>(
|
|||||||
router.push("/create/informational");
|
router.push("/create/informational");
|
||||||
}, [loggedIn, router]);
|
}, [loggedIn, router]);
|
||||||
|
|
||||||
// Schema markup for site navigation
|
|
||||||
const schemaData = {
|
const schemaData = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebSite",
|
"@type": "WebSite",
|
||||||
@@ -77,32 +79,32 @@ const TopContainer = memo<TopProps>(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Logo size based on folderTop prop
|
|
||||||
const logoSize = folderTop ? "topNavFolderTop" : "topNavHeader";
|
const logoSize = folderTop ? "topNavFolderTop" : "topNavHeader";
|
||||||
|
|
||||||
// Navigation items with translations
|
|
||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
{ href: "/use-cases", text: t("navigation.useCases"), extraPadding: true },
|
{ href: "/use-cases", text: t("navigation.useCases"), extraPadding: true },
|
||||||
{ href: "/learn", text: t("navigation.learn") },
|
{ href: "/learn", text: t("navigation.learn") },
|
||||||
{ href: "/about", text: t("navigation.about") },
|
{ href: "/about", text: t("navigation.about") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderNavigationItems = (size: NavSize) => {
|
const renderNavigationItems = () => {
|
||||||
const mode = folderTop ? "inverse" : "default";
|
const mode = folderTop ? "inverse" : "default";
|
||||||
|
const sizeClass = folderTop
|
||||||
|
? FOLDER_NAV_ITEM_RESPONSIVE_CLASS
|
||||||
|
: NAV_ITEM_RESPONSIVE_CLASS;
|
||||||
|
|
||||||
return navigationItems.map((item, index) => {
|
return navigationItems.map((item) => {
|
||||||
const itemSize = NAV_SIZE_TO_MENU_ITEM_SIZE[size];
|
|
||||||
|
|
||||||
const isUseCases = item.extraPadding === true;
|
const isUseCases = item.extraPadding === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={index}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
size={itemSize}
|
size="X Small"
|
||||||
mode={mode}
|
mode={mode}
|
||||||
state={pathname === item.href ? "selected" : "default"}
|
state={pathname === item.href ? "selected" : "default"}
|
||||||
reducedPadding={isUseCases}
|
reducedPadding={isUseCases}
|
||||||
|
className={sizeClass}
|
||||||
ariaLabel={t("ariaLabels.navigateToPage").replace(
|
ariaLabel={t("ariaLabels.navigateToPage").replace(
|
||||||
"{text}",
|
"{text}",
|
||||||
item.text,
|
item.text,
|
||||||
@@ -114,34 +116,7 @@ const TopContainer = memo<TopProps>(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAvatarGroup = (
|
const renderLoginButton = () => {
|
||||||
containerSize: "small" | "medium" | "large" | "xlarge",
|
|
||||||
avatarSize: "small" | "medium" | "large" | "xlarge",
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<AvatarContainer size={containerSize}>
|
|
||||||
{avatarImageSources.map((src, index) => (
|
|
||||||
<Avatar
|
|
||||||
key={index}
|
|
||||||
src={src}
|
|
||||||
alt={tTopNav(`avatarAlts.${3 - index}`)}
|
|
||||||
size={avatarSize}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AvatarContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLoginButton = (size: NavSize) => {
|
|
||||||
const itemSize = NAV_SIZE_TO_MENU_ITEM_SIZE[size];
|
|
||||||
|
|
||||||
// Determine mode based on folderTop and breakpoint size
|
|
||||||
// folderTop: inverse mode (black text) for smallest breakpoints (xsmall/home)
|
|
||||||
// folderTop: default mode (yellow text) for 640px+ breakpoints (homeMd/large/homeXlarge/xlarge)
|
|
||||||
// false folderTop: always default mode (yellow text on dark background)
|
|
||||||
const isSmallBreakpoint = size === "xsmall";
|
|
||||||
const mode = folderTop && isSmallBreakpoint ? "inverse" : "default";
|
|
||||||
|
|
||||||
const label = loggedIn ? t("buttons.profile") : t("buttons.logIn");
|
const label = loggedIn ? t("buttons.profile") : t("buttons.logIn");
|
||||||
const ariaLabel = loggedIn
|
const ariaLabel = loggedIn
|
||||||
? t("ariaLabels.goToProfile")
|
? t("ariaLabels.goToProfile")
|
||||||
@@ -149,14 +124,18 @@ const TopContainer = memo<TopProps>(
|
|||||||
const navSelected =
|
const navSelected =
|
||||||
(loggedIn && pathname === "/profile") ||
|
(loggedIn && pathname === "/profile") ||
|
||||||
(!loggedIn && pathname === "/login");
|
(!loggedIn && pathname === "/login");
|
||||||
|
const loginClassName = folderTop
|
||||||
|
? FOLDER_NAV_ITEM_RESPONSIVE_CLASS
|
||||||
|
: NAV_ITEM_RESPONSIVE_CLASS;
|
||||||
|
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
href="/profile"
|
href="/profile"
|
||||||
size={itemSize}
|
size="X Small"
|
||||||
mode={mode}
|
mode="default"
|
||||||
state={navSelected ? "selected" : "default"}
|
state={navSelected ? "selected" : "default"}
|
||||||
|
className={loginClassName}
|
||||||
ariaLabel={ariaLabel}
|
ariaLabel={ariaLabel}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -174,9 +153,10 @@ const TopContainer = memo<TopProps>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
href="/login"
|
href="/login"
|
||||||
size={itemSize}
|
size="X Small"
|
||||||
mode={mode}
|
mode="default"
|
||||||
state={navSelected ? "selected" : "default"}
|
state={navSelected ? "selected" : "default"}
|
||||||
|
className={loginClassName}
|
||||||
ariaLabel={ariaLabel}
|
ariaLabel={ariaLabel}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -184,20 +164,42 @@ const TopContainer = memo<TopProps>(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCreateRuleButton = (
|
const renderCreateRuleButton = () => {
|
||||||
buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
|
|
||||||
containerSize: "small" | "medium" | "large" | "xlarge",
|
|
||||||
avatarSize: "small" | "medium" | "large" | "xlarge",
|
|
||||||
) => {
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size={buttonSize}
|
size="xsmall"
|
||||||
buttonType="filled"
|
buttonType="filled"
|
||||||
palette="inverse"
|
palette="inverse"
|
||||||
onClick={handleCreateRuleClick}
|
onClick={handleCreateRuleClick}
|
||||||
ariaLabel={t("ariaLabels.createNewRule")}
|
ariaLabel={t("ariaLabels.createNewRule")}
|
||||||
|
className={
|
||||||
|
folderTop
|
||||||
|
? FOLDER_CREATE_RULE_RESPONSIVE_CLASS
|
||||||
|
: CREATE_RULE_RESPONSIVE_CLASS
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{renderAvatarGroup(containerSize, avatarSize)}
|
<AvatarContainer
|
||||||
|
size="small"
|
||||||
|
className={
|
||||||
|
folderTop
|
||||||
|
? "md:-space-x-[9px] lg:-space-x-[var(--spacing-scale-010)] xl:-space-x-[13px]"
|
||||||
|
: "lg:-space-x-[var(--spacing-scale-010)] xl:-space-x-[13px]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{avatarImageSources.map((src, index) => (
|
||||||
|
<Avatar
|
||||||
|
key={src}
|
||||||
|
src={src}
|
||||||
|
alt={tTopNav(`avatarAlts.${3 - index}`)}
|
||||||
|
size="small"
|
||||||
|
className={
|
||||||
|
folderTop
|
||||||
|
? "md:h-[var(--spacing-scale-018)] md:w-[var(--spacing-scale-018)] lg:h-[var(--spacing-scale-024)] lg:w-[var(--spacing-scale-024)] xl:h-[var(--spacing-scale-032)] xl:w-[var(--spacing-scale-032)]"
|
||||||
|
: "lg:h-[var(--spacing-scale-024)] lg:w-[var(--spacing-scale-024)] xl:h-[var(--spacing-scale-032)] xl:w-[var(--spacing-scale-032)]"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AvatarContainer>
|
||||||
<span>{t("buttons.createRule")}</span>
|
<span>{t("buttons.createRule")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,14 +9,6 @@ export interface TopProps {
|
|||||||
logIn?: boolean;
|
logIn?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Breakpoint slot passed from {@link Top.view} into nav render helpers. */
|
|
||||||
export type NavSize =
|
|
||||||
| "xsmall"
|
|
||||||
| "homeMd"
|
|
||||||
| "large"
|
|
||||||
| "homeXlarge"
|
|
||||||
| "xlarge";
|
|
||||||
|
|
||||||
export interface TopViewProps {
|
export interface TopViewProps {
|
||||||
folderTop: boolean;
|
folderTop: boolean;
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
@@ -35,11 +27,7 @@ export interface TopViewProps {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
logoSize: "topNavFolderTop" | "topNavHeader";
|
logoSize: "topNavFolderTop" | "topNavHeader";
|
||||||
renderNavigationItems: (_size: NavSize) => React.ReactNode;
|
renderNavigationItems: () => React.ReactNode;
|
||||||
renderLoginButton: (_size: NavSize) => React.ReactNode;
|
renderLoginButton: () => React.ReactNode;
|
||||||
renderCreateRuleButton: (
|
renderCreateRuleButton: () => React.ReactNode;
|
||||||
_buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
|
|
||||||
_containerSize: "small" | "medium" | "large" | "xlarge",
|
|
||||||
_avatarSize: "small" | "medium" | "large" | "xlarge",
|
|
||||||
) => React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ function TopView({
|
|||||||
}: TopViewProps) {
|
}: TopViewProps) {
|
||||||
const t = useTranslation(folderTop ? "homeHeader" : "header");
|
const t = useTranslation(folderTop ? "homeHeader" : "header");
|
||||||
|
|
||||||
// Render folderTop variant (HomeHeader style)
|
const loginControl = logIn ? (
|
||||||
|
<Menu size="X Small" className="lg:gap-[var(--spacing-scale-012)]">
|
||||||
|
{renderLoginButton()}
|
||||||
|
</Menu>
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (folderTop) {
|
if (folderTop) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -32,116 +37,65 @@ function TopView({
|
|||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
|
||||||
/>
|
/>
|
||||||
<header
|
<header
|
||||||
className="w-full bg-transparent overflow-hidden"
|
className="w-full overflow-visible bg-transparent"
|
||||||
role="banner"
|
role="banner"
|
||||||
aria-label={t("ariaLabels.homePageNavigationHeader")}
|
aria-label={t("ariaLabels.homePageNavigationHeader")}
|
||||||
>
|
>
|
||||||
<nav
|
<nav
|
||||||
className="relative flex items-center justify-between mx-auto h-[50px] sm:h-[62px] md:h-[68px] lg:h-[68px] xl:h-[88px] pl-[var(--spacing-scale-008)] pr-[var(--spacing-scale-016)] pt-[var(--spacing-scale-010)] sm:px-[var(--spacing-scale-010)] sm:pr-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] md:pr-[var(--spacing-scale-032)] md:pt-[var(--spacing-scale-016)] lg:pl-[var(--spacing-scale-024)] lg:pt-[var(--spacing-scale-016)] lg:pr-[var(--spacing-scale-056)] xl:pl-[var(--spacing-scale-048)] xl:pt-[var(--spacing-scale-024)] xl:pr-[var(--spacing-scale-056)]"
|
className="relative mx-auto flex h-[50px] items-end justify-between pl-[var(--spacing-scale-008)] pr-[var(--spacing-scale-016)] pt-[var(--spacing-scale-010)] sm:h-[62px] sm:px-[var(--spacing-scale-010)] sm:pr-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-010)] md:h-[68px] md:px-[var(--spacing-scale-016)] md:pr-[var(--spacing-scale-032)] md:pt-[var(--spacing-scale-016)] lg:h-[68px] lg:pl-[var(--spacing-scale-024)] lg:pr-[var(--spacing-scale-056)] lg:pt-[var(--spacing-scale-016)] xl:h-[88px] xl:pl-[var(--spacing-scale-048)] xl:pr-[var(--spacing-scale-056)] xl:pt-[var(--spacing-scale-024)]"
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label={t("ariaLabels.mainNavigation")}
|
aria-label={t("ariaLabels.mainNavigation")}
|
||||||
>
|
>
|
||||||
{/* Header Tab - Yellow tab container with decorative Union images */}
|
<div className="HeaderTab header-breakpoint-transition relative flex h-[var(--spacing-scale-040)] w-fit min-w-0 items-center self-end overflow-visible rounded-tl-[var(--radius-measures-radius-medium)] rounded-tr-[var(--radius-measures-radius-medium)] bg-[var(--color-surface-inverse-brand-primary)] pl-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] sm:mr-[var(--spacing-scale-008)] sm:h-[52px] sm:w-auto sm:flex-1 sm:rounded-t-[var(--radius-measures-radius-xlarge)] sm:pr-[var(--spacing-scale-006)] md:h-[52px] md:min-w-0 md:rounded-t-[var(--radius-measures-radius-xlarge)] md:pl-[var(--spacing-scale-024)] md:pr-[var(--spacing-scale-012)] lg:mr-[var(--spacing-scale-024)] lg:h-[52px] lg:rounded-t-[var(--radius-measures-radius-xlarge)] lg:pl-[var(--spacing-scale-024)] lg:pr-[var(--spacing-scale-048)] xl:mr-[var(--spacing-scale-032)] xl:h-[64px] xl:rounded-t-[var(--radius-measures-radius-xlarge)] xl:pl-[var(--spacing-scale-032)] xl:pr-[var(--spacing-scale-120)]">
|
||||||
<div className="HeaderTab header-breakpoint-transition relative bg-[var(--color-surface-inverse-brand-primary)] rounded-tl-[var(--radius-measures-radius-medium)] rounded-tr-[var(--radius-measures-radius-medium)] sm:rounded-t-[var(--radius-measures-radius-xlarge)] md:rounded-t-[var(--radius-measures-radius-xlarge)] lg:rounded-t-[var(--radius-measures-radius-xlarge)] xl:rounded-t-[var(--radius-measures-radius-xlarge)] pl-[var(--spacing-scale-012)] pr-[var(--spacing-scale-048)] h-[var(--spacing-scale-040)] sm:pl-[var(--spacing-scale-012)] sm:h-[52px] sm:pr-[var(--spacing-scale-006)] md:h-[52px] md:pl-[var(--spacing-scale-024)] md:pr-[var(--spacing-scale-012)] lg:h-[52px] lg:pl-[var(--spacing-scale-024)] lg:pr-[var(--spacing-scale-048)] xl:h-[64px] xl:pl-[var(--spacing-scale-032)] xl:pr-[var(--spacing-scale-120)] md:gap-[var(--spacing-scale-032)] flex-1 min-w-0 min-w-[197px] sm:min-w-0 sm:mr-[var(--spacing-scale-008)] md:mr-[185px] lg:mr-[var(--spacing-scale-024)] xl:mr-[var(--spacing-scale-032)] flex items-center self-end">
|
<div className="relative z-[1] shrink-0">
|
||||||
{/* Logo - Consistent left positioning within HeaderTab */}
|
|
||||||
<Logo
|
<Logo
|
||||||
size={logoSize}
|
size={logoSize}
|
||||||
wordmark
|
wordmark
|
||||||
palette={folderTop ? "inverse" : "default"}
|
palette={folderTop ? "inverse" : "default"}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* XSmall menu — positioned next to logo */}
|
<div
|
||||||
<div className="block sm:hidden -me-[2px]">
|
className="relative z-[1] shrink-0 md:absolute md:top-1/2 md:left-[calc(50vw-var(--spacing-scale-016))] md:-translate-x-1/2 md:-translate-y-1/2 lg:left-[calc(50vw-var(--spacing-scale-024))] xl:left-[calc(50vw-var(--spacing-scale-048))]"
|
||||||
<Menu size="X Small">
|
data-top="nav"
|
||||||
{renderNavigationItems("xsmall")}
|
>
|
||||||
{logIn && renderLoginButton("xsmall")}
|
<Menu
|
||||||
|
size="X Small"
|
||||||
|
className="md:gap-[var(--spacing-scale-004)] lg:gap-[var(--spacing-scale-012)]"
|
||||||
|
>
|
||||||
|
{renderNavigationItems()}
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Decorative Union images for tab appearance */}
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG, not content */}
|
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG, not content */}
|
||||||
<img
|
<img
|
||||||
src={getAssetPath(ASSETS.UNION_XSM)}
|
src={getAssetPath(ASSETS.UNION_XSM)}
|
||||||
alt=""
|
alt=""
|
||||||
role="presentation"
|
role="presentation"
|
||||||
className="absolute -bottom-[3px] -right-[52px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] sm:hidden -z-10"
|
className="pointer-events-none absolute -bottom-[3px] -right-[52px] z-0 h-[24px] w-[61px] sm:hidden sm:h-[31.5px] sm:w-[61px]"
|
||||||
/>
|
/>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG */}
|
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG */}
|
||||||
<img
|
<img
|
||||||
src={getAssetPath(ASSETS.UNION_SM_MD_LG)}
|
src={getAssetPath(ASSETS.UNION_SM_MD_LG)}
|
||||||
alt=""
|
alt=""
|
||||||
role="presentation"
|
role="presentation"
|
||||||
className="absolute -bottom-[3.7px] -right-[53px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] hidden sm:block xl:hidden -z-10"
|
className="pointer-events-none absolute -bottom-[3.7px] -right-[53px] z-0 hidden h-[24px] w-[61px] sm:block sm:h-[31.5px] sm:w-[61px] xl:hidden"
|
||||||
/>
|
/>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG */}
|
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG */}
|
||||||
<img
|
<img
|
||||||
src={getAssetPath(ASSETS.UNION_XLG)}
|
src={getAssetPath(ASSETS.UNION_XLG)}
|
||||||
alt=""
|
alt=""
|
||||||
role="presentation"
|
role="presentation"
|
||||||
className="absolute -bottom-[6px] -right-[94px] w-[105px] h-[53px] hidden xl:block -z-10"
|
className="pointer-events-none absolute -bottom-[6px] -right-[94px] z-0 hidden h-[53px] w-[105px] xl:block"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Links - Centered in header for SM and up */}
|
<div
|
||||||
<div className="absolute left-1/2 transform -translate-x-1/2 hidden sm:block">
|
className="flex shrink-0 items-center gap-[var(--spacing-scale-004)] self-center md:gap-[var(--spacing-scale-010)]"
|
||||||
{/* 430-639px (sm: breakpoint): Menu X Small */}
|
data-top="auth"
|
||||||
<div className="hidden sm:block md:hidden">
|
>
|
||||||
<Menu size="X Small">
|
{loginControl}
|
||||||
{renderNavigationItems("xsmall")}
|
{renderCreateRuleButton()}
|
||||||
{logIn && renderLoginButton("xsmall")}
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 640-1023px (md: breakpoint): Menu Small */}
|
|
||||||
<div className="hidden md:block lg:hidden">
|
|
||||||
<Menu size="Small">
|
|
||||||
{renderNavigationItems("homeMd")}
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 1024-1440px (lg: breakpoint): Menu Large */}
|
|
||||||
<div className="hidden lg:block xl:hidden">
|
|
||||||
<Menu size="Large">{renderNavigationItems("large")}</Menu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 1440px+ (xl: breakpoint): Menu X Large */}
|
|
||||||
<div className="hidden xl:block">
|
|
||||||
<Menu size="X Large">
|
|
||||||
{renderNavigationItems("homeXlarge")}
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Authentication Elements - Consistent right alignment outside HeaderTab */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
{/* XSmall and Small breakpoints - create rule button outside HeaderTab */}
|
|
||||||
<div className="block md:hidden">
|
|
||||||
{renderCreateRuleButton("xsmall", "small", "small")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Medium breakpoint - login outside HeaderTab, create rule outside */}
|
|
||||||
<div className="hidden md:block lg:hidden absolute right-[var(--spacing-measures-spacing-016)]">
|
|
||||||
<div className="flex items-center gap-[var(--spacing-scale-010)]">
|
|
||||||
{logIn && renderLoginButton("homeMd")}
|
|
||||||
{renderCreateRuleButton("small", "medium", "medium")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Large breakpoint */}
|
|
||||||
<div className="hidden lg:flex xl:hidden items-center">
|
|
||||||
<div className="flex items-center gap-[var(--spacing-scale-004)]">
|
|
||||||
{logIn && renderLoginButton("large")}
|
|
||||||
{renderCreateRuleButton("large", "large", "large")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* XLarge breakpoint */}
|
|
||||||
<div className="hidden xl:flex items-center">
|
|
||||||
<div className="flex items-center gap-[var(--spacing-scale-004)]">
|
|
||||||
{logIn && renderLoginButton("homeXlarge")}
|
|
||||||
{renderCreateRuleButton("xlarge", "xlarge", "xlarge")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -151,11 +105,12 @@ function TopView({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard marketing / app top nav.
|
* Standard marketing / app top nav.
|
||||||
* Figma: "Navigation / Top" (Community-Rule-System, node 22078-808559) — horizontal
|
* Figma: "Navigation / Top" (Community-Rule-System, node 22078-808559).
|
||||||
* padding, logo ~200px left, menu cluster centered in the bar (`left-1/2` + translate),
|
* Below md: auto | 1fr | auto so the logo cannot paint over the cluster.
|
||||||
* log in + create rule on the right. Breakpoints and Menu sizes unchanged from prior map.
|
* From md: 1fr | auto | 1fr so the cluster sits on the same page midline
|
||||||
|
* as Folder-top (Create rule is wider than the logo, so a leftover-column
|
||||||
|
* center sits off-center).
|
||||||
*/
|
*/
|
||||||
// Render standard variant (Header style)
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<script
|
<script
|
||||||
@@ -168,19 +123,11 @@ function TopView({
|
|||||||
aria-label={t("ariaLabels.mainNavigationHeader")}
|
aria-label={t("ariaLabels.mainNavigationHeader")}
|
||||||
>
|
>
|
||||||
<nav
|
<nav
|
||||||
className="relative flex w-full items-center
|
className="grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)] sm:px-[var(--spacing-measures-spacing-016)] md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] lg:px-[var(--spacing-measures-spacing-64,64px)] lg:py-[var(--spacing-scale-016)]"
|
||||||
px-[var(--spacing-scale-016)]
|
|
||||||
py-[var(--spacing-scale-008)]
|
|
||||||
sm:px-[var(--spacing-measures-spacing-016)]
|
|
||||||
lg:px-[var(--spacing-measures-spacing-64,64px)]
|
|
||||||
lg:py-[var(--spacing-scale-016)]"
|
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label={t("ariaLabels.mainNavigation")}
|
aria-label={t("ariaLabels.mainNavigation")}
|
||||||
>
|
>
|
||||||
<div
|
<div className="min-w-0 shrink-0 justify-self-start lg:w-[200px] lg:max-w-[200px]" data-top="logo">
|
||||||
className="relative z-20 min-w-0 shrink-0 sm:w-[200px] sm:max-w-[200px] sm:shrink-0"
|
|
||||||
data-top="logo"
|
|
||||||
>
|
|
||||||
<Logo
|
<Logo
|
||||||
size={logoSize}
|
size={logoSize}
|
||||||
wordmark
|
wordmark
|
||||||
@@ -188,100 +135,24 @@ function TopView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* XSmall: nav + login in flow (flex-1) — same as before */}
|
|
||||||
<div
|
<div
|
||||||
className="flex min-w-0 flex-1 items-center justify-end sm:hidden"
|
className="flex min-w-0 items-center justify-end md:justify-center"
|
||||||
data-top="nav-xs-flow"
|
data-top="nav"
|
||||||
>
|
>
|
||||||
<div className="block" data-testid="nav-xs">
|
<Menu
|
||||||
<Menu size="X Small">
|
size="X Small"
|
||||||
{renderNavigationItems("xsmall")}
|
className="min-w-0 lg:gap-[var(--spacing-scale-012)]"
|
||||||
{logIn && renderLoginButton("xsmall")}
|
>
|
||||||
|
{renderNavigationItems()}
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* sm+ — Figma: nav cluster centered in bar (not between logo and actions) */}
|
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none hidden sm:absolute sm:left-1/2 sm:top-1/2 sm:z-10 sm:flex sm:-translate-x-1/2 sm:-translate-y-1/2 sm:items-center sm:justify-center"
|
className="flex shrink-0 items-center justify-self-end gap-[var(--spacing-scale-004)] md:gap-[var(--spacing-measures-spacing-010)] lg:gap-[var(--spacing-measures-spacing-004)]"
|
||||||
data-top="nav-center"
|
data-top="auth"
|
||||||
>
|
>
|
||||||
<div
|
{loginControl}
|
||||||
className="pointer-events-auto hidden sm:flex md:hidden"
|
{renderCreateRuleButton()}
|
||||||
data-testid="nav-sm"
|
|
||||||
>
|
|
||||||
<Menu size="X Small">
|
|
||||||
{renderNavigationItems("xsmall")}
|
|
||||||
{logIn && renderLoginButton("xsmall")}
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="pointer-events-auto hidden md:flex lg:hidden"
|
|
||||||
data-testid="nav-md"
|
|
||||||
>
|
|
||||||
<Menu size="X Small">
|
|
||||||
{renderNavigationItems("xsmall")}
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="pointer-events-auto hidden lg:flex xl:hidden"
|
|
||||||
data-testid="nav-lg"
|
|
||||||
>
|
|
||||||
<Menu size="Large">{renderNavigationItems("large")}</Menu>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="pointer-events-auto hidden xl:flex"
|
|
||||||
data-testid="nav-xl"
|
|
||||||
>
|
|
||||||
<Menu size="X Large">
|
|
||||||
{renderNavigationItems("xlarge")}
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Authentication Elements - Consistent right alignment across all breakpoints */}
|
|
||||||
<div className="relative z-20 ml-auto flex shrink-0 items-center">
|
|
||||||
{/* XSmall breakpoint - Only Create Rule button */}
|
|
||||||
<div className="block sm:hidden shrink-0" data-testid="auth-xs">
|
|
||||||
{renderCreateRuleButton("xsmall", "small", "small")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Small breakpoint - Only Create Rule button */}
|
|
||||||
<div className="hidden sm:block md:hidden" data-testid="auth-sm">
|
|
||||||
<div className="flex items-center gap-[var(--spacing-scale-004)]">
|
|
||||||
{renderCreateRuleButton("xsmall", "small", "small")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Medium breakpoint */}
|
|
||||||
<div className="hidden md:block lg:hidden" data-testid="auth-md">
|
|
||||||
<div className="flex items-center gap-[var(--spacing-measures-spacing-010)]">
|
|
||||||
<Menu size="Small">
|
|
||||||
{logIn && renderLoginButton("xsmall")}
|
|
||||||
</Menu>
|
|
||||||
{renderCreateRuleButton("xsmall", "medium", "medium")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Large breakpoint */}
|
|
||||||
<div className="hidden lg:block xl:hidden" data-testid="auth-lg">
|
|
||||||
<div className="flex items-center gap-[var(--spacing-measures-spacing-004)]">
|
|
||||||
<Menu size="Large">
|
|
||||||
{logIn && renderLoginButton("large")}
|
|
||||||
</Menu>
|
|
||||||
{renderCreateRuleButton("large", "xlarge", "xlarge")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* XLarge breakpoint */}
|
|
||||||
<div className="hidden xl:block" data-testid="auth-xl">
|
|
||||||
<div className="flex items-center gap-[var(--spacing-measures-spacing-004)]">
|
|
||||||
<Menu size="X Large">
|
|
||||||
{logIn && renderLoginButton("xlarge")}
|
|
||||||
</Menu>
|
|
||||||
{renderCreateRuleButton("xlarge", "xlarge", "xlarge")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { default } from "./Top.container";
|
export { default } from "./Top.container";
|
||||||
export type { TopProps, NavSize } from "./Top.types";
|
export type { TopProps } from "./Top.types";
|
||||||
export { avatarImages } from "./Top.container";
|
export { avatarImages } from "./Top.container";
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ function ContentBannerUseCaseView({
|
|||||||
>
|
>
|
||||||
<ContentContainer
|
<ContentContainer
|
||||||
post={post}
|
post={post}
|
||||||
size="responsive"
|
size="useCase"
|
||||||
tone={contentTone}
|
tone={contentTone}
|
||||||
showLeadingImage={false}
|
showLeadingImage={false}
|
||||||
leadingImageSrc={leadingImageSrc}
|
leadingImageSrc={leadingImageSrc}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Figma: "Section / Feature-Grid" (18847:22410)
|
* Figma: "Section / Feature-Grid" (18632:10668)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import { getAssetPath, featurePanelLayout, featurePanelPath } from "../../../../lib/assetUtils";
|
import { featureIconPath, getAssetPath } from "../../../../lib/assetUtils";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
import FeatureGridView from "./FeatureGrid.view";
|
import FeatureGridView from "./FeatureGrid.view";
|
||||||
import type { FeatureGridProps, Feature } from "./FeatureGrid.types";
|
import type { FeatureGridProps, Feature } from "./FeatureGrid.types";
|
||||||
@@ -17,52 +17,40 @@ const FeatureGridContainer = memo<FeatureGridProps>(
|
|||||||
const features: Feature[] = useMemo(
|
const features: Feature[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
backgroundColor: "bg-[var(--color-surface-invert-brand-royal)]",
|
backgroundColor: "bg-[var(--color-surface-invert-brand-lavender)]",
|
||||||
labelLine1: t(
|
title: t("pages.home.featureGrid.features.decisionMaking.title"),
|
||||||
"pages.home.featureGrid.features.decisionMaking.labelLine1",
|
description: t(
|
||||||
|
"pages.home.featureGrid.features.decisionMaking.description",
|
||||||
),
|
),
|
||||||
labelLine2: t(
|
descriptionClassName: "text-[#3a3273]",
|
||||||
"pages.home.featureGrid.features.decisionMaking.labelLine2",
|
iconSrc: getAssetPath(featureIconPath("git-branch")),
|
||||||
),
|
|
||||||
panelContent: getAssetPath(featurePanelPath("support")),
|
|
||||||
...featurePanelLayout("support"),
|
|
||||||
ariaLabel: t("featureGrid.features.decisionMaking.ariaLabel"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
backgroundColor: "bg-[var(--color-surface-invert-brand-lime)]",
|
backgroundColor: "bg-[var(--color-surface-invert-brand-lime)]",
|
||||||
labelLine1: t(
|
title: t("pages.home.featureGrid.features.valuesAlignment.title"),
|
||||||
"pages.home.featureGrid.features.valuesAlignment.labelLine1",
|
description: t(
|
||||||
|
"pages.home.featureGrid.features.valuesAlignment.description",
|
||||||
),
|
),
|
||||||
labelLine2: t(
|
descriptionClassName: "text-[#424f1a]",
|
||||||
"pages.home.featureGrid.features.valuesAlignment.labelLine2",
|
iconSrc: getAssetPath(featureIconPath("arrow-left-right")),
|
||||||
),
|
|
||||||
panelContent: getAssetPath(featurePanelPath("exercises")),
|
|
||||||
...featurePanelLayout("exercises"),
|
|
||||||
ariaLabel: t("featureGrid.features.valuesAlignment.ariaLabel"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
backgroundColor: "bg-[var(--color-surface-invert-brand-rust)]",
|
backgroundColor: "bg-[var(--color-surface-invert-brand-rust)]",
|
||||||
labelLine1: t(
|
title: t("pages.home.featureGrid.features.membershipGuidance.title"),
|
||||||
"pages.home.featureGrid.features.membershipGuidance.labelLine1",
|
description: t(
|
||||||
|
"pages.home.featureGrid.features.membershipGuidance.description",
|
||||||
),
|
),
|
||||||
labelLine2: t(
|
descriptionClassName: "text-[#5e2e23]",
|
||||||
"pages.home.featureGrid.features.membershipGuidance.labelLine2",
|
iconSrc: getAssetPath(featureIconPath("door-open")),
|
||||||
),
|
|
||||||
panelContent: getAssetPath(featurePanelPath("guidance")),
|
|
||||||
...featurePanelLayout("guidance"),
|
|
||||||
ariaLabel: t("featureGrid.features.membershipGuidance.ariaLabel"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
|
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
|
||||||
labelLine1: t(
|
title: t("pages.home.featureGrid.features.conflictResolution.title"),
|
||||||
"pages.home.featureGrid.features.conflictResolution.labelLine1",
|
description: t(
|
||||||
|
"pages.home.featureGrid.features.conflictResolution.description",
|
||||||
),
|
),
|
||||||
labelLine2: t(
|
descriptionClassName: "text-[#1f4d47]",
|
||||||
"pages.home.featureGrid.features.conflictResolution.labelLine2",
|
iconSrc: getAssetPath(featureIconPath("message-square-share")),
|
||||||
),
|
|
||||||
panelContent: getAssetPath(featurePanelPath("tools")),
|
|
||||||
...featurePanelLayout("tools"),
|
|
||||||
ariaLabel: t("featureGrid.features.conflictResolution.ariaLabel"),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t],
|
[t],
|
||||||
|
|||||||
@@ -6,13 +6,10 @@ export interface FeatureGridProps {
|
|||||||
|
|
||||||
export interface Feature {
|
export interface Feature {
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
labelLine1: string;
|
title: string;
|
||||||
labelLine2: string;
|
description: string;
|
||||||
panelContent: string;
|
descriptionClassName: string;
|
||||||
panelWidth: number;
|
iconSrc: string;
|
||||||
panelHeight: number;
|
|
||||||
panelImageClassName?: string;
|
|
||||||
ariaLabel: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeatureGridViewProps extends FeatureGridProps {
|
export interface FeatureGridViewProps extends FeatureGridProps {
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
import ContentLockup from "../../type/ContentLockup";
|
import ContentLockup from "../../type/ContentLockup";
|
||||||
import Mini from "../../cards/Mini";
|
|
||||||
import type { FeatureGridViewProps } from "./FeatureGrid.types";
|
import type { FeatureGridViewProps } from "./FeatureGrid.types";
|
||||||
|
|
||||||
/** Figma **Section / Feature-Grid** [18847:22410](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=18847-22410&m=dev). */
|
/** Figma **Section / Feature-Grid** [18632:10668](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=18632-10668&m=dev). */
|
||||||
function FeatureGridView({
|
function FeatureGridView({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
@@ -20,16 +19,16 @@ function FeatureGridView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
|
className={`p-0 md:px-[var(--spacing-scale-024)] lg:p-[var(--spacing-scale-064)] ${className}`}
|
||||||
aria-labelledby={labelledBy}
|
aria-labelledby={labelledBy}
|
||||||
aria-label={labelledBy ? undefined : ariaLabel}
|
aria-label={labelledBy ? undefined : ariaLabel}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-figma-node="18847-22410"
|
data-figma-node="18632-10668"
|
||||||
className="rounded-[var(--measures-radius-500,20px)] bg-[var(--color-surface-default-secondary)] px-[var(--spacing-scale-020)] py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-048)] md:pb-[var(--spacing-scale-048)] md:pt-[var(--spacing-scale-076)] lg:pb-[var(--spacing-scale-076)]"
|
className="rounded-none bg-[var(--color-surface-default-secondary)] px-[var(--spacing-scale-016)] py-[var(--spacing-scale-032)] md:rounded-[var(--radius-measures-radius-large)] md:p-[var(--spacing-scale-048)] lg:p-[var(--spacing-scale-064)]"
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full gap-[var(--spacing-scale-048)] [container-type:inline-size] lg:flex lg:items-start lg:gap-[var(--spacing-scale-048)]">
|
<div className="mx-auto flex w-full flex-col gap-[var(--spacing-scale-040)] md:gap-[var(--spacing-scale-048)] lg:flex-row lg:items-center lg:gap-[var(--spacing-scale-064)]">
|
||||||
<div className="lg:min-w-0 lg:shrink">
|
<div className="lg:min-w-0 lg:flex-1">
|
||||||
<ContentLockup
|
<ContentLockup
|
||||||
title={title}
|
title={title}
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
@@ -40,20 +39,34 @@ function FeatureGridView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-[var(--spacing-scale-048)] grid grid-cols-2 grid-rows-[repeat(2,minmax(0,1fr))] gap-x-[12px] gap-y-[12px] max-md:min-h-[384px] md:grid-cols-4 md:grid-rows-1 md:min-h-0 lg:mt-0 lg:shrink-0 lg:flex-grow">
|
<div className="grid w-full grid-cols-1 gap-[var(--spacing-scale-008)] md:grid-cols-2 md:gap-[var(--spacing-scale-016)] lg:flex-1">
|
||||||
{features.map((feature, index) => (
|
{features.map((feature) => (
|
||||||
<Mini
|
<div
|
||||||
key={index}
|
key={feature.title}
|
||||||
backgroundColor={feature.backgroundColor}
|
className={`flex flex-col gap-[var(--spacing-scale-016)] p-[var(--spacing-scale-024)] ${feature.backgroundColor} rounded-[var(--radius-200)] md:rounded-[var(--radius-400)] lg:rounded-[var(--radius-measures-radius-large)]`}
|
||||||
labelLine1={feature.labelLine1}
|
>
|
||||||
labelLine2={feature.labelLine2}
|
<div className="size-[40px] shrink-0 overflow-clip">
|
||||||
panelContent={feature.panelContent}
|
{/* eslint-disable-next-line @next/next/no-img-element -- decorative Figma glyph */}
|
||||||
panelWidth={feature.panelWidth}
|
<img
|
||||||
panelHeight={feature.panelHeight}
|
src={feature.iconSrc}
|
||||||
panelImageClassName={feature.panelImageClassName}
|
alt=""
|
||||||
ariaLabel={feature.ariaLabel}
|
width={40}
|
||||||
featureGridShell
|
height={40}
|
||||||
|
className="size-full object-contain"
|
||||||
|
role="presentation"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-[var(--spacing-scale-008)]">
|
||||||
|
<p className="font-semibold text-[17px] leading-[22px] text-[var(--color-content-inverse-primary)]">
|
||||||
|
{feature.title}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-small-paragraph ${feature.descriptionClassName}`}
|
||||||
|
>
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import type { GovernanceTemplateCatalogEntry } from "../../../../lib/templates/g
|
|||||||
|
|
||||||
export interface GovernanceTemplateGridProps {
|
export interface GovernanceTemplateGridProps {
|
||||||
entries: GovernanceTemplateCatalogEntry[];
|
entries: GovernanceTemplateCatalogEntry[];
|
||||||
onTemplateClick: (_slug: string) => void;
|
/**
|
||||||
|
* Real navigation target per card. Cards render as anchors so they are
|
||||||
|
* keyboard-focusable and work in a new tab.
|
||||||
|
*/
|
||||||
|
hrefForTemplate: (_slug: string) => string;
|
||||||
|
/** Optional side effects on activate (analytics, draft reset). */
|
||||||
|
onTemplateClick?: (_slug: string) => void;
|
||||||
/**
|
/**
|
||||||
* When true, use project **`md`** (640px) for a 2-column grid (e.g. `/use-cases`).
|
* When true, use project **`md`** (640px) for a 2-column grid (e.g. `/use-cases`).
|
||||||
* Default keeps the template shell break at **768px**.
|
* Default keeps the template shell break at **768px**.
|
||||||
@@ -19,6 +25,7 @@ export interface GovernanceTemplateGridProps {
|
|||||||
|
|
||||||
export function GovernanceTemplateGrid({
|
export function GovernanceTemplateGrid({
|
||||||
entries,
|
entries,
|
||||||
|
hrefForTemplate,
|
||||||
onTemplateClick,
|
onTemplateClick,
|
||||||
twoColumnsFromMd = false,
|
twoColumnsFromMd = false,
|
||||||
}: GovernanceTemplateGridProps) {
|
}: GovernanceTemplateGridProps) {
|
||||||
@@ -105,9 +112,14 @@ export function GovernanceTemplateGrid({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
backgroundColor={card.backgroundColor}
|
backgroundColor={card.backgroundColor}
|
||||||
onClick={() => {
|
href={hrefForTemplate(card.slug)}
|
||||||
|
onClick={
|
||||||
|
onTemplateClick
|
||||||
|
? () => {
|
||||||
onTemplateClick(card.slug);
|
onTemplateClick(card.slug);
|
||||||
}}
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { memo, useEffect, useState } from "react";
|
import { memo, useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
import { logger } from "../../../../lib/logger";
|
import { logger } from "../../../../lib/logger";
|
||||||
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||||
|
import { buildTemplateReviewHref } from "../../../(app)/create/utils/flowSteps";
|
||||||
import {
|
import {
|
||||||
fetchTemplates,
|
fetchTemplates,
|
||||||
isTemplatesFetchAborted,
|
isTemplatesFetchAborted,
|
||||||
@@ -34,7 +34,6 @@ declare global {
|
|||||||
|
|
||||||
const RuleStackContainer = memo<RuleStackProps>(
|
const RuleStackContainer = memo<RuleStackProps>(
|
||||||
({ className = "", initialGridEntries, translationNamespace, twoColumnsFromMd }) => {
|
({ className = "", initialGridEntries, translationNamespace, twoColumnsFromMd }) => {
|
||||||
const router = useRouter();
|
|
||||||
const namespace = translationNamespace ?? "pages.home.ruleStack";
|
const namespace = translationNamespace ?? "pages.home.ruleStack";
|
||||||
const t = useTranslation(namespace);
|
const t = useTranslation(namespace);
|
||||||
const [gridEntries, setGridEntries] = useState<TemplateGridCardEntry[] | null>(
|
const [gridEntries, setGridEntries] = useState<TemplateGridCardEntry[] | null>(
|
||||||
@@ -101,12 +100,12 @@ const RuleStackContainer = memo<RuleStackProps>(
|
|||||||
// `signedIn: true` because this surface has no session in hand; DELETE is
|
// `signedIn: true` because this surface has no session in hand; DELETE is
|
||||||
// best-effort and the sentinel blocks stale-draft hydration.
|
// best-effort and the sentinel blocks stale-draft hydration.
|
||||||
prepareFreshCreateFlowEntrySync({ signedIn: true });
|
prepareFreshCreateFlowEntrySync({ signedIn: true });
|
||||||
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RuleStackView
|
<RuleStackView
|
||||||
className={className}
|
className={className}
|
||||||
|
hrefForTemplate={(slug) => buildTemplateReviewHref(slug)}
|
||||||
onTemplateClick={handleTemplateClick}
|
onTemplateClick={handleTemplateClick}
|
||||||
gridEntries={gridEntries}
|
gridEntries={gridEntries}
|
||||||
sectionTitle={t("title")}
|
sectionTitle={t("title")}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface RuleStackProps {
|
|||||||
export interface RuleStackViewProps {
|
export interface RuleStackViewProps {
|
||||||
className: string;
|
className: string;
|
||||||
onTemplateClick: (_slug: string) => void;
|
onTemplateClick: (_slug: string) => void;
|
||||||
|
hrefForTemplate: (_slug: string) => string;
|
||||||
/** `null` while loading curated templates from the API. */
|
/** `null` while loading curated templates from the API. */
|
||||||
gridEntries: TemplateGridCardEntry[] | null;
|
gridEntries: TemplateGridCardEntry[] | null;
|
||||||
sectionTitle: string;
|
sectionTitle: string;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { RuleStackViewProps } from "./RuleStack.types";
|
|||||||
export function RuleStackView({
|
export function RuleStackView({
|
||||||
className,
|
className,
|
||||||
onTemplateClick,
|
onTemplateClick,
|
||||||
|
hrefForTemplate,
|
||||||
gridEntries,
|
gridEntries,
|
||||||
sectionTitle,
|
sectionTitle,
|
||||||
sectionSubtitle,
|
sectionSubtitle,
|
||||||
@@ -46,6 +47,7 @@ export function RuleStackView({
|
|||||||
) : (
|
) : (
|
||||||
<GovernanceTemplateGrid
|
<GovernanceTemplateGrid
|
||||||
entries={gridEntries}
|
entries={gridEntries}
|
||||||
|
hrefForTemplate={hrefForTemplate}
|
||||||
onTemplateClick={onTemplateClick}
|
onTemplateClick={onTemplateClick}
|
||||||
twoColumnsFromMd={twoColumnsFromMd}
|
twoColumnsFromMd={twoColumnsFromMd}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export interface StatItem {
|
|||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
asOf?: string;
|
asOf?: string;
|
||||||
|
asOfPrefix?: string;
|
||||||
|
sourceHref?: string;
|
||||||
|
sourceName?: string;
|
||||||
shapeVariant?: StatShapeVariant;
|
shapeVariant?: StatShapeVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,6 +14,7 @@ export interface StatsProps {
|
|||||||
titlePrefix?: string;
|
titlePrefix?: string;
|
||||||
titleEmphasis?: string;
|
titleEmphasis?: string;
|
||||||
titleSuffix?: string;
|
titleSuffix?: string;
|
||||||
|
asOfPrefix?: string;
|
||||||
items: StatItem[];
|
items: StatItem[];
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ function StatsView({
|
|||||||
titlePrefix,
|
titlePrefix,
|
||||||
titleEmphasis,
|
titleEmphasis,
|
||||||
titleSuffix,
|
titleSuffix,
|
||||||
|
asOfPrefix,
|
||||||
items,
|
items,
|
||||||
headingId,
|
headingId,
|
||||||
className = "",
|
className = "",
|
||||||
@@ -88,7 +89,11 @@ function StatsView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={`${item.value}-${index}`} className={staggerClass}>
|
<li key={`${item.value}-${index}`} className={staggerClass}>
|
||||||
<Stat {...item} className={heightClass} />
|
<Stat
|
||||||
|
{...item}
|
||||||
|
asOfPrefix={item.asOfPrefix ?? asOfPrefix}
|
||||||
|
className={heightClass}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -25,16 +25,27 @@ function AboutHeaderView({
|
|||||||
variant="about"
|
variant="about"
|
||||||
alignment="left"
|
alignment="left"
|
||||||
titleId={titleId}
|
titleId={titleId}
|
||||||
titleContent={segments.map((segment, index) => {
|
titleContent={segments.flatMap((segment, index) => {
|
||||||
if (segment.type === "word") {
|
const spaceBefore =
|
||||||
return (
|
segment.type === "word" && index > 0 ? (
|
||||||
<span key={`${segment.text}-${index}`} className="whitespace-nowrap">
|
<span key={`space-${index}`} className="sr-only">
|
||||||
{segment.text}
|
{" "}
|
||||||
</span>
|
</span>
|
||||||
);
|
) : null;
|
||||||
|
|
||||||
|
if (segment.type === "word") {
|
||||||
|
return [
|
||||||
|
spaceBefore,
|
||||||
|
<span
|
||||||
|
key={`${segment.text}-${index}`}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</span>,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return [
|
||||||
<span
|
<span
|
||||||
key={`${segment.icon}-${index}`}
|
key={`${segment.icon}-${index}`}
|
||||||
className={
|
className={
|
||||||
@@ -50,8 +61,8 @@ function AboutHeaderView({
|
|||||||
className="size-full object-contain"
|
className="size-full object-contain"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>,
|
||||||
);
|
];
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
import NextLink from "next/link";
|
||||||
import Button from "../../buttons/Button";
|
import Button from "../../buttons/Button";
|
||||||
import { contentLockupShapePath, getAssetPath } from "../../../../lib/assetUtils";
|
import { contentLockupShapePath, getAssetPath } from "../../../../lib/assetUtils";
|
||||||
import type { ContentLockupViewProps } from "./ContentLockup.types";
|
import type { ContentLockupViewProps } from "./ContentLockup.types";
|
||||||
@@ -84,8 +85,13 @@ function ContentLockupView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtitle */}
|
{subtitle ? (
|
||||||
{subtitle ? <h2 className={styles.subtitle}>{subtitle}</h2> : null}
|
variant === "feature" ? (
|
||||||
|
<p className={styles.subtitle}>{subtitle}</p>
|
||||||
|
) : (
|
||||||
|
<h2 className={styles.subtitle}>{subtitle}</h2>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
@@ -93,14 +99,13 @@ function ContentLockupView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Link for feature variant */}
|
|
||||||
{variant === "feature" && linkText && linkHref && (
|
{variant === "feature" && linkText && linkHref && (
|
||||||
<a
|
<NextLink
|
||||||
href={linkHref}
|
href={linkHref}
|
||||||
className="text-medium-underline underline text-[var(--color-content-default-primary)] hover:text-[var(--color-content-default-secondary)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-secondary)] rounded-sm px-1 py-0.5"
|
className="w-fit self-start cursor-pointer text-small-paragraph md:text-medium-paragraph text-[var(--color-content-default-primary)] underline decoration-solid [text-underline-position:from-font] hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
|
||||||
>
|
>
|
||||||
{linkText}
|
{linkText}
|
||||||
</a>
|
</NextLink>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CTA Button */}
|
{/* CTA Button */}
|
||||||
@@ -110,7 +115,7 @@ function ContentLockupView({
|
|||||||
<div className="block md:hidden">
|
<div className="block md:hidden">
|
||||||
<Button
|
<Button
|
||||||
buttonType="filled"
|
buttonType="filled"
|
||||||
palette={variant === "hero" ? "default" : "inverse"}
|
palette="inverse"
|
||||||
size="small"
|
size="small"
|
||||||
href={ctaHref}
|
href={ctaHref}
|
||||||
>
|
>
|
||||||
@@ -121,7 +126,7 @@ function ContentLockupView({
|
|||||||
<div className="hidden md:block xl:hidden">
|
<div className="hidden md:block xl:hidden">
|
||||||
<Button
|
<Button
|
||||||
buttonType="filled"
|
buttonType="filled"
|
||||||
palette={variant === "hero" ? "default" : "inverse"}
|
palette="inverse"
|
||||||
size="large"
|
size="large"
|
||||||
className={buttonClassName}
|
className={buttonClassName}
|
||||||
href={ctaHref}
|
href={ctaHref}
|
||||||
@@ -133,7 +138,7 @@ function ContentLockupView({
|
|||||||
<div className="hidden xl:block">
|
<div className="hidden xl:block">
|
||||||
<Button
|
<Button
|
||||||
buttonType="filled"
|
buttonType="filled"
|
||||||
palette={variant === "hero" ? "default" : "inverse"}
|
palette="inverse"
|
||||||
size="xlarge"
|
size="xlarge"
|
||||||
href={ctaHref}
|
href={ctaHref}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ const SectionHeader = memo<SectionHeaderProps>(
|
|||||||
</span>
|
</span>
|
||||||
{useStackedDesktop ? (
|
{useStackedDesktop ? (
|
||||||
<span className={splitFromMd ? "hidden md:block" : "hidden lg:block"}>
|
<span className={splitFromMd ? "hidden md:block" : "hidden lg:block"}>
|
||||||
<span className="block">{stackedDesktopLines[0]}</span>
|
<span className="block">{stackedDesktopLines[0]} </span>
|
||||||
<span className="block">{stackedDesktopLines[1]}</span>
|
<span className="block">{stackedDesktopLines[1]} </span>
|
||||||
<span className="block">{stackedDesktopLines[2]}</span>
|
<span className="block">{stackedDesktopLines[2]}</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+3
-4
@@ -6,10 +6,9 @@ import { ASSETS, getAssetPath } from "../lib/assetUtils";
|
|||||||
import { PRODUCTION_SITE_ORIGIN } from "../lib/siteMetadata";
|
import { PRODUCTION_SITE_ORIGIN } from "../lib/siteMetadata";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
// `force-dynamic` is now scoped to `(app)/layout.tsx` and `(admin)/layout.tsx`
|
// Session chrome (`ConditionalNavigation`) lives in `(marketing)`, `(app)`, and
|
||||||
// (the only groups that read the session via `ConditionalNavigation`). Marketing
|
// `(admin)` layouts, behind `<Suspense>`, so the static shell can prerender while
|
||||||
// renders a client-side `MarketingNavigation` so its HTML can be statically
|
// the header streams with the request cookie.
|
||||||
// optimized — TTFB drops to CDN speed for guests.
|
|
||||||
//
|
//
|
||||||
// MessagesProvider + AuthModalProvider are mounted per route group (Phase 4b):
|
// MessagesProvider + AuthModalProvider are mounted per route group (Phase 4b):
|
||||||
// `(marketing)` gets a trimmed slice without `create.*` (~41 KB gzipped saved
|
// `(marketing)` gets a trimmed slice without `create.*` (~41 KB gzipped saved
|
||||||
|
|||||||
@@ -45,6 +45,33 @@
|
|||||||
background: #292d32;
|
background: #292d32;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Must live in this file (same sheet as `@import "tailwindcss"`). Rules in
|
||||||
|
* `globals.css` after that import never reach the browser.
|
||||||
|
* Parked above the viewport until focused so it does not add header offset.
|
||||||
|
*/
|
||||||
|
.skip-to-content {
|
||||||
|
position: absolute;
|
||||||
|
left: var(--spacing-scale-016);
|
||||||
|
top: var(--spacing-scale-016);
|
||||||
|
z-index: 100;
|
||||||
|
padding: var(--spacing-scale-012) var(--spacing-scale-016);
|
||||||
|
border-radius: var(--radius-measures-radius-full);
|
||||||
|
background-color: var(--color-surface-inverse-primary);
|
||||||
|
color: var(--color-content-inverse-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px var(--color-border-default-primary),
|
||||||
|
0 0 0 4px var(--color-border-invert-primary);
|
||||||
|
transform: translateY(-200%);
|
||||||
|
}
|
||||||
|
.skip-to-content:focus,
|
||||||
|
.skip-to-content:focus-visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
/* Custom breakpoints */
|
/* Custom breakpoints */
|
||||||
--breakpoint-xsm: 429px;
|
--breakpoint-xsm: 429px;
|
||||||
|
|||||||
+4
-3
@@ -76,6 +76,7 @@ Call sites for **`prepareFreshCreateFlowEntry`**: [`Top.container.tsx`](../app/c
|
|||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/create/review-template/[slug]` | Template preview in the create shell; uses the same layout/footer chrome as other create pages but **is not** part of `FLOW_STEP_ORDER` **or** the three Figma stages above. |
|
| `/create/review-template/[slug]` | Template preview in the create shell; uses the same layout/footer chrome as other create pages but **is not** part of `FLOW_STEP_ORDER` **or** the three Figma stages above. |
|
||||||
|
| `/create/templates` | In-flow template catalog after review **Create from template**. Same create chrome (Back, Save & Exit); cards link to `/create/review-template/[slug]?fromFlow=1`. Marketing `/templates` stays the public catalog. |
|
||||||
|
|
||||||
From that page, **Customize** pre-fills the custom-rule selections on the current `CreateFlowState` (via [`buildTemplateCustomizePrefill`](../lib/create/applyTemplatePrefill.ts)) and routes to **`/create/core-values`** when the community name (`state.title`) is already set, otherwise to **`/create/informational`**. Name-only is the gate because other community-stage fields (e.g. `communityStructureChipSnapshots`) are sticky once the user lands on those screens; a non-empty title is also the minimum bar [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) enforces, so the two checks stay aligned. No query-param plumbing: state persists via the usual anonymous/server-draft mirrors.
|
From that page, **Customize** pre-fills the custom-rule selections on the current `CreateFlowState` (via [`buildTemplateCustomizePrefill`](../lib/create/applyTemplatePrefill.ts)) and routes to **`/create/core-values`** when the community name (`state.title`) is already set, otherwise to **`/create/informational`**. Name-only is the gate because other community-stage fields (e.g. `communityStructureChipSnapshots`) are sticky once the user lands on those screens; a non-empty title is also the minimum bar [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) enforces, so the two checks stay aligned. No query-param plumbing: state persists via the usual anonymous/server-draft mirrors.
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ From that page, **Customize** pre-fills the custom-rule selections on the curren
|
|||||||
|
|
||||||
- **Customize** routes to `/create/informational` and walks the full Create Community questionnaire. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s to `/create/core-values`. `replace` (not `push`) keeps `community-save` as the Back-button target from that destination.
|
- **Customize** routes to `/create/informational` and walks the full Create Community questionnaire. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s to `/create/core-values`. `replace` (not `push`) keeps `community-save` as the Back-button target from that destination.
|
||||||
- **Use without changes (direct from a template)** — home Popular templates, `/templates` without `fromFlow`, or a pasted review-template URL. Always routes to `/create/community-name` and walks **name → description → optional photo**, then `/create/confirm-stakeholders`. A leftover draft title is **not** “community already done”; those identity fields are cleared so cache cannot skip or pre-fill the path. Intro, structure, size, save, community review, and custom-rule authoring are skipped. Back from name returns to template review.
|
- **Use without changes (direct from a template)** — home Popular templates, `/templates` without `fromFlow`, or a pasted review-template URL. Always routes to `/create/community-name` and walks **name → description → optional photo**, then `/create/confirm-stakeholders`. A leftover draft title is **not** “community already done”; those identity fields are cleared so cache cannot skip or pre-fill the path. Intro, structure, size, save, community review, and custom-rule authoring are skipped. Back from name returns to template review.
|
||||||
- **Use without changes (in-flow)** — `/create/review` → `/templates?fromFlow=1` → template. Community identity is already collected, so this action seeds the template and routes to `/create/confirm-stakeholders`. Back returns to template review.
|
- **Use without changes (in-flow)** — `/create/review` → `/create/templates` → template. Community identity is already collected, so this action seeds the template and routes to `/create/confirm-stakeholders`. Back returns to template review.
|
||||||
|
|
||||||
The action for Customize is cleared on the first fire so later direct visits to `/create/review` render normally. The review screen is not part of the Use-without-changes identity path. If they land on `/create/review` with a leftover Use-without-changes pin, the screen `replace`s to `community-name`.
|
The action for Customize is cleared on the first fire so later direct visits to `/create/review` render normally. The review screen is not part of the Use-without-changes identity path. If they land on `/create/review` with a leftover Use-without-changes pin, the screen `replace`s to `community-name`.
|
||||||
|
|
||||||
@@ -97,9 +98,9 @@ The action for Customize is cleared on the first fire so later direct visits to
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Home marketing "Popular templates" ([`RuleStack.container.tsx`](../app/components/sections/RuleStack/RuleStack.container.tsx)) | always `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` |
|
| Home marketing "Popular templates" ([`RuleStack.container.tsx`](../app/components/sections/RuleStack/RuleStack.container.tsx)) | always `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` |
|
||||||
| `/templates` index ([`TemplatesPageClient.tsx`](../app/(marketing)/templates/TemplatesPageClient.tsx)) visited directly / via pasted URL | `fromFlow` absent → `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` |
|
| `/templates` index ([`TemplatesPageClient.tsx`](../app/(marketing)/templates/TemplatesPageClient.tsx)) visited directly / via pasted URL | `fromFlow` absent → `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` |
|
||||||
| In-flow: `/create/review` footer "Create from template" → `/templates?fromFlow=1` → template click | `fromFlow=1` → no fresh-entry prep | `/create/review-template/[slug]?fromFlow=1` |
|
| In-flow: `/create/review` footer "Create from template" → `/create/templates` → template click | no fresh-entry prep; cards are `?fromFlow=1` links | `/create/review-template/[slug]?fromFlow=1` |
|
||||||
|
|
||||||
Only one `?fromFlow=1` marker exists on `/templates`. Template clicks forward it onto `/create/review-template/[slug]?fromFlow=1` so **Use without changes** can tell in-flow (skip identity) from a direct catalog start (collect name / description / photo). **Customize** still branches on `state.title` (full questionnaire vs `core-values`).
|
Only one `?fromFlow=1` marker exists on template-review URLs. In-flow catalog clicks on `/create/templates` always include it so **Use without changes** can tell in-flow (skip identity) from a direct catalog start (collect name / description / photo). Profile still uses marketing `/templates?fromFlow=1`. **Customize** still branches on `state.title` (full questionnaire vs `core-values`).
|
||||||
|
|
||||||
**Resume from profile** remains explicit-only: **Continue** clears local mirrors then opens `/create/{step}` so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) can load `/api/drafts/me` when the client buffer is empty. There is no automatic “pick template from marketing → silently merge server draft” path.
|
**Resume from profile** remains explicit-only: **Continue** clears local mirrors then opens `/create/{step}` so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) can load `/api/drafts/me` when the client buffer is empty. There is no automatic “pick template from marketing → silently merge server draft” path.
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ Inventory aligns with [**CR-104**](https://linear.app/community-rule/issue/CR-10
|
|||||||
| **Link** matrix | **`Link/`** | Next.js **`Link`** wrapper + Figma “Link, CTA” styling; used in nav and content (e.g. **`Rule`**). |
|
| **Link** matrix | **`Link/`** | Next.js **`Link`** wrapper + Figma “Link, CTA” styling; used in nav and content (e.g. **`Rule`**). |
|
||||||
| Create-flow top chrome (often **Utility** in Figma) | **`CreateFlowTopNav/`** | Wizard header; **`CreateFlowLayoutClient`**. |
|
| Create-flow top chrome (often **Utility** in Figma) | **`CreateFlowTopNav/`** | Wizard header; **`CreateFlowLayoutClient`**. |
|
||||||
| Create-flow bottom chrome (often **Utility** in Figma) | **`CreateFlowFooter/`** | Wizard footer + **`ProportionBar`**; **`CreateFlowLayoutClient`**. |
|
| Create-flow bottom chrome (often **Utility** in Figma) | **`CreateFlowFooter/`** | Wizard footer + **`ProportionBar`**; **`CreateFlowLayoutClient`**. |
|
||||||
| App shell (not a DS atom) | **`ConditionalNavigation.tsx`**, **`ConditionalNavigationClient.tsx`** | Server: session for first paint. Client: hide global **`Top`** on **`/create/*`** and **`/login`**; else **`TopWithPathname`**. **Tolerated `usePathname()`** — no new pathname-conditional chrome (**`routes.mdc`**). |
|
| App shell (not a DS atom) | **`ConditionalNavigation.tsx`**, **`ConditionalNavigationClient.tsx`**, **`SkipToContent.tsx`** | Server: session for first paint. Client: hide global **`Top`** on **`/create/*`** and **`/login`**; else **`TopWithPathname`**. Skip link in group layouts targets **`#main-content`**. **Tolerated `usePathname()`** — no new pathname-conditional chrome (**`routes.mdc`**). |
|
||||||
|
|
||||||
**Also under Utility in Figma:** **`CreateFlowTopNav`** / **`CreateFlowFooter`** are filed under Utility but **canonical code** is here with **`Top`** / **`Footer`** (see **Utility conventions**).
|
**Also under Utility in Figma:** **`CreateFlowTopNav`** / **`CreateFlowFooter`** are filed under Utility but **canonical code** is here with **`Top`** / **`Footer`** (see **Utility conventions**).
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Convention for files served from `public/`. Path helpers live in
|
|||||||
```
|
```
|
||||||
public/
|
public/
|
||||||
assets/
|
assets/
|
||||||
icons/ # UI chrome (alert, close, help, pointer)
|
icons/ # UI chrome (close, help) + FeatureGrid card glyphs
|
||||||
logos/ # Brand + social lockups
|
logos/ # Brand + social lockups
|
||||||
partners/ # Logo wall partner SVGs (kebab org slug)
|
partners/ # Logo wall partner SVGs (kebab org slug)
|
||||||
marketing/ # Hero, feature panels, section numbers, avatars, banners, book cover
|
marketing/ # Hero, feature panels, section numbers, avatars, banners, book cover
|
||||||
@@ -25,7 +25,7 @@ public/
|
|||||||
|
|
||||||
| Location | Used for | Resolution |
|
| Location | Used for | Resolution |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `public/assets/icons/` | Static chrome served by URL (`icon-close.svg`, `icon-help.svg`) | `ASSETS.ICON_*` |
|
| `public/assets/icons/` | Static chrome served by URL (`icon-close.svg`, `icon-help.svg`) and FeatureGrid card glyphs (`icon-git-branch.svg`, …) | `ASSETS.ICON_*`, `featureIconPath()` |
|
||||||
| `app/components/asset/icon/` | Bundled create-flow / nav SVGs imported by `Icon.tsx` | Webpack import, not `public/` |
|
| `app/components/asset/icon/` | Bundled create-flow / nav SVGs imported by `Icon.tsx` | Webpack import, not `public/` |
|
||||||
|
|
||||||
Do not duplicate the same glyph in both places unless migrating between systems.
|
Do not duplicate the same glyph in both places unless migrating between systems.
|
||||||
@@ -61,7 +61,8 @@ stage. Raster → SVG conversion is tracked in
|
|||||||
| Path | Used by | Disposition |
|
| Path | Used by | Disposition |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `logos/partners/*.svg` (×6) | LogoWall | **Done** — SVG (kebab org slug, no `logo-` prefix) |
|
| `logos/partners/*.svg` (×6) | LogoWall | **Done** — SVG (kebab org slug, no `logo-` prefix) |
|
||||||
| `marketing/feature-*.svg` (×4) | FeatureGrid | Exported from Figma Section/Feature-Grid (18847:22410) |
|
| `marketing/feature-*.svg` (×4) | Mini cards | Panel art (`support`, `exercises`, `guidance`, `tools`) |
|
||||||
|
| `icons/icon-{git-branch,arrow-left-right,door-open,message-square-share}.svg` | FeatureGrid | Card glyphs from Figma Section/Feature-Grid (18632:10668) |
|
||||||
| `marketing/section-number-*.svg` (×3) | SectionNumber | **Done** — SVG |
|
| `marketing/section-number-*.svg` (×3) | SectionNumber | **Done** — SVG |
|
||||||
| `marketing/avatar-*.svg` (×3) | Avatar / ASSETS | **Done** — SVG |
|
| `marketing/avatar-*.svg` (×3) | Avatar / ASSETS | **Done** — SVG |
|
||||||
| `marketing/hero-image.png` | HeroBanner | **Design review** — likely keep raster |
|
| `marketing/hero-image.png` | HeroBanner | **Design review** — likely keep raster |
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ as follow-up work (see "Outcome" sections below).
|
|||||||
|
|
||||||
| Flag | Recommendation | Status |
|
| Flag | Recommendation | Status |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `cacheComponents` (PPR successor) | **Ship** | **Shipped.** `force-dynamic` removed from `(app)` and `(admin)` layouts; `<ConditionalNavigation />` (and `<MarketingNavigation />`) wrapped in `<Suspense fallback={null}>`. `(app)`/`(admin)` routes are now `◐ Partial Prerender` instead of `ƒ Dynamic`. `/` static shell dropped from 45 KB → 11.7 KB gzipped. |
|
| `cacheComponents` (PPR successor) | **Ship** | **Shipped.** `force-dynamic` removed from `(app)` and `(admin)` layouts; `<ConditionalNavigation />` wrapped in `<Suspense fallback={null}>` in `(marketing)`, `(app)`, and `(admin)`. `(app)`/`(admin)` routes are now `◐ Partial Prerender` instead of `ƒ Dynamic`. `/` static shell dropped from 45 KB → 11.7 KB gzipped. |
|
||||||
| React Compiler | **Ship (annotation mode)** | **Shipped (plumbing only).** `babel-plugin-react-compiler` + `eslint-plugin-react-compiler` installed. `reactCompiler: { compilationMode: "annotation" }` enabled in `next.config.mjs`. ESLint rule wired in at "warn" — found 31 latent warnings across 8 files (none introduced by this change). Migrating containers to `"use memo"` is a future task. |
|
| React Compiler | **Ship (annotation mode)** | **Shipped (plumbing only).** `babel-plugin-react-compiler` + `eslint-plugin-react-compiler` installed. `reactCompiler: { compilationMode: "annotation" }` enabled in `next.config.mjs`. ESLint rule wired in at "warn" — found 31 latent warnings across 8 files (none introduced by this change). Migrating containers to `"use memo"` is a future task. |
|
||||||
|
|
||||||
Both flags now ship in `main`. The findings below describe what changed in
|
Both flags now ship in `main`. The findings below describe what changed in
|
||||||
@@ -76,9 +76,9 @@ requires expressing that dynamism via `<Suspense>` boundaries plus
|
|||||||
2. Wrapped `<ConditionalNavigation />` (server component reading
|
2. Wrapped `<ConditionalNavigation />` (server component reading
|
||||||
`getNavAuthSignedIn()` → `cookies()`) in `<Suspense fallback={null}>` in
|
`getNavAuthSignedIn()` → `cookies()`) in `<Suspense fallback={null}>` in
|
||||||
both layouts.
|
both layouts.
|
||||||
3. Same change for `<MarketingNavigation />` in
|
3. Same Suspense wrap for `<ConditionalNavigation />` in
|
||||||
[app/(marketing)/layout.tsx](../../app/(marketing)/layout.tsx) — the
|
[app/(marketing)/layout.tsx](../../app/(marketing)/layout.tsx) — the
|
||||||
marketing nav reads `usePathname()` (uncached per request) and would
|
nav reads `usePathname()` (uncached per request) and would
|
||||||
otherwise block the static shell at routes like `/rules/[id]`.
|
otherwise block the static shell at routes like `/rules/[id]`.
|
||||||
4. Enabled `experimental.cacheComponents: true` in
|
4. Enabled `experimental.cacheComponents: true` in
|
||||||
[next.config.mjs](../../next.config.mjs).
|
[next.config.mjs](../../next.config.mjs).
|
||||||
|
|||||||
+15
-23
@@ -86,7 +86,13 @@ export function partnerLogoPath(slug: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Share modal glyphs in `public/assets/share/`. */
|
/** Share modal glyphs in `public/assets/share/`. */
|
||||||
export type ShareIconName = "discord" | "link" | "mail" | "signal" | "slack";
|
export type ShareIconName =
|
||||||
|
| "check"
|
||||||
|
| "discord"
|
||||||
|
| "link"
|
||||||
|
| "mail"
|
||||||
|
| "signal"
|
||||||
|
| "slack";
|
||||||
|
|
||||||
export function shareIconPath(name: ShareIconName): string {
|
export function shareIconPath(name: ShareIconName): string {
|
||||||
return `assets/share/${name}.svg`;
|
return `assets/share/${name}.svg`;
|
||||||
@@ -114,29 +120,15 @@ export function featurePanelPath(key: FeaturePanelKey): string {
|
|||||||
return `assets/marketing/feature-${key}.svg`;
|
return `assets/marketing/feature-${key}.svg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Intrinsic icon bounds from Figma Feature-Grid (18632:10911). */
|
/** Feature-grid card glyphs in `public/assets/icons/` (Figma **18632:10668**). */
|
||||||
export const FEATURE_PANEL_LAYOUT: Record<
|
export type FeatureIconKey =
|
||||||
FeaturePanelKey,
|
| "git-branch"
|
||||||
{ width: number; height: number; panelImageClassName?: string }
|
| "arrow-left-right"
|
||||||
> = {
|
| "door-open"
|
||||||
support: { width: 48, height: 48 },
|
| "message-square-share";
|
||||||
exercises: { width: 55, height: 48 },
|
|
||||||
guidance: { width: 56, height: 39 },
|
|
||||||
tools: {
|
|
||||||
width: 50,
|
|
||||||
height: 47,
|
|
||||||
/** Figma 18632:10947 — raw asset is inverted; frame applies rotate + flip. */
|
|
||||||
panelImageClassName: "rotate-180 -scale-x-100",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function featurePanelLayout(key: FeaturePanelKey): {
|
export function featureIconPath(key: FeatureIconKey): string {
|
||||||
panelWidth: number;
|
return `assets/icons/icon-${key}.svg`;
|
||||||
panelHeight: number;
|
|
||||||
panelImageClassName?: string;
|
|
||||||
} {
|
|
||||||
const { width, height, panelImageClassName } = FEATURE_PANEL_LAYOUT[key];
|
|
||||||
return { panelWidth: width, panelHeight: height, panelImageClassName };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Case study card artwork in `public/assets/case-study/`. */
|
/** Case study card artwork in `public/assets/case-study/`. */
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose";
|
||||||
|
|
||||||
|
/** Community avatar cap (bytes). */
|
||||||
|
const COMMUNITY_AVATAR_MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
/** Custom-method attachment cap (bytes). */
|
||||||
|
const CUSTOM_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
export const COMMUNITY_AVATAR_ACCEPT =
|
||||||
|
"image/jpeg,image/png,image/webp,image/gif";
|
||||||
|
export const CUSTOM_ATTACHMENT_ACCEPT = `${COMMUNITY_AVATAR_ACCEPT},application/pdf`;
|
||||||
|
|
||||||
|
export type CreateFlowUploadValidationReason =
|
||||||
|
| "empty"
|
||||||
|
| "tooLarge"
|
||||||
|
| "svg"
|
||||||
|
| "invalidType"
|
||||||
|
| "undecodable";
|
||||||
|
|
||||||
|
type SniffedUploadKind =
|
||||||
|
| "jpeg"
|
||||||
|
| "png"
|
||||||
|
| "gif"
|
||||||
|
| "webp"
|
||||||
|
| "pdf"
|
||||||
|
| "svg"
|
||||||
|
| "empty"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
type CreateFlowUploadRasterKind = "jpeg" | "png" | "gif" | "webp";
|
||||||
|
|
||||||
|
type CreateFlowUploadValidationOk = {
|
||||||
|
ok: true;
|
||||||
|
kind: CreateFlowUploadRasterKind | "pdf";
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateFlowUploadValidationFail = {
|
||||||
|
ok: false;
|
||||||
|
reason: CreateFlowUploadValidationReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateFlowUploadValidationResult =
|
||||||
|
| CreateFlowUploadValidationOk
|
||||||
|
| CreateFlowUploadValidationFail;
|
||||||
|
|
||||||
|
export class CreateFlowUploadValidationError extends Error {
|
||||||
|
readonly reason: CreateFlowUploadValidationReason;
|
||||||
|
|
||||||
|
constructor(reason: CreateFlowUploadValidationReason) {
|
||||||
|
super(reason);
|
||||||
|
this.name = "CreateFlowUploadValidationError";
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SVG_MIME = new Set(["image/svg+xml", "image/svg"]);
|
||||||
|
|
||||||
|
export function maxBytesForPurpose(purpose: CreateFlowUploadPurpose): number {
|
||||||
|
return purpose === "communityAvatar"
|
||||||
|
? COMMUNITY_AVATAR_MAX_BYTES
|
||||||
|
: CUSTOM_ATTACHMENT_MAX_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeTypeForSniffedKind(
|
||||||
|
kind: CreateFlowUploadRasterKind | "pdf",
|
||||||
|
): string {
|
||||||
|
switch (kind) {
|
||||||
|
case "jpeg":
|
||||||
|
return "image/jpeg";
|
||||||
|
case "png":
|
||||||
|
return "image/png";
|
||||||
|
case "gif":
|
||||||
|
return "image/gif";
|
||||||
|
case "webp":
|
||||||
|
return "image/webp";
|
||||||
|
case "pdf":
|
||||||
|
return "application/pdf";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messageKeyForCreateFlowUploadReason(
|
||||||
|
reason: CreateFlowUploadValidationReason,
|
||||||
|
): `errors.${CreateFlowUploadValidationReason}` {
|
||||||
|
return `errors.${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileLooksLikeSvg(file: File): boolean {
|
||||||
|
const declared = file.type.toLowerCase().split(";")[0]?.trim() ?? "";
|
||||||
|
if (SVG_MIME.has(declared)) return true;
|
||||||
|
return /\.svgz?$/i.test(file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readU16LE(bytes: Uint8Array, offset: number): number {
|
||||||
|
return bytes[offset]! | (bytes[offset + 1]! << 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readU16BE(bytes: Uint8Array, offset: number): number {
|
||||||
|
return (bytes[offset]! << 8) | bytes[offset + 1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readU24LE(bytes: Uint8Array, offset: number): number {
|
||||||
|
return (
|
||||||
|
bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readU32BE(bytes: Uint8Array, offset: number): number {
|
||||||
|
return (
|
||||||
|
((bytes[offset]! << 24) |
|
||||||
|
(bytes[offset + 1]! << 16) |
|
||||||
|
(bytes[offset + 2]! << 8) |
|
||||||
|
bytes[offset + 3]!) >>>
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWith(bytes: Uint8Array, offset: number, ascii: string): boolean {
|
||||||
|
if (offset + ascii.length > bytes.length) return false;
|
||||||
|
for (let i = 0; i < ascii.length; i++) {
|
||||||
|
if (bytes[offset + i] !== ascii.charCodeAt(i)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeSvgBytes(bytes: Uint8Array): boolean {
|
||||||
|
let i = 0;
|
||||||
|
if (
|
||||||
|
bytes.length >= 3 &&
|
||||||
|
bytes[0] === 0xef &&
|
||||||
|
bytes[1] === 0xbb &&
|
||||||
|
bytes[2] === 0xbf
|
||||||
|
) {
|
||||||
|
i = 3;
|
||||||
|
}
|
||||||
|
while (
|
||||||
|
i < bytes.length &&
|
||||||
|
(bytes[i] === 0x20 ||
|
||||||
|
bytes[i] === 0x09 ||
|
||||||
|
bytes[i] === 0x0d ||
|
||||||
|
bytes[i] === 0x0a)
|
||||||
|
) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
const head = new TextDecoder("utf-8", { fatal: false })
|
||||||
|
.decode(bytes.subarray(i, Math.min(i + 512, bytes.length)))
|
||||||
|
.toLowerCase();
|
||||||
|
return head.includes("<svg") || head.includes("<!doctype svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True-type sniff from magic bytes. Raster/PDF signatures win over a later
|
||||||
|
* `<svg` substring so binary comments cannot be classified as SVG.
|
||||||
|
*/
|
||||||
|
function sniffCreateFlowUploadBytes(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): SniffedUploadKind {
|
||||||
|
if (bytes.length === 0) return "empty";
|
||||||
|
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||||
|
return "jpeg";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
bytes.length >= 8 &&
|
||||||
|
bytes[0] === 0x89 &&
|
||||||
|
bytes[1] === 0x50 &&
|
||||||
|
bytes[2] === 0x4e &&
|
||||||
|
bytes[3] === 0x47 &&
|
||||||
|
bytes[4] === 0x0d &&
|
||||||
|
bytes[5] === 0x0a &&
|
||||||
|
bytes[6] === 0x1a &&
|
||||||
|
bytes[7] === 0x0a
|
||||||
|
) {
|
||||||
|
return "png";
|
||||||
|
}
|
||||||
|
if (startsWith(bytes, 0, "GIF87a") || startsWith(bytes, 0, "GIF89a")) {
|
||||||
|
return "gif";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
bytes.length >= 12 &&
|
||||||
|
startsWith(bytes, 0, "RIFF") &&
|
||||||
|
startsWith(bytes, 8, "WEBP")
|
||||||
|
) {
|
||||||
|
return "webp";
|
||||||
|
}
|
||||||
|
if (bytes.length >= 5 && startsWith(bytes, 0, "%PDF-")) {
|
||||||
|
return "pdf";
|
||||||
|
}
|
||||||
|
if (looksLikeSvgBytes(bytes)) return "svg";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pngDimensions(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): { width: number; height: number } | null {
|
||||||
|
if (bytes.length < 24 || !startsWith(bytes, 12, "IHDR")) return null;
|
||||||
|
const width = readU32BE(bytes, 16);
|
||||||
|
const height = readU32BE(bytes, 20);
|
||||||
|
if (width < 1 || height < 1) return null;
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function gifDimensions(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): { width: number; height: number } | null {
|
||||||
|
if (bytes.length < 10) return null;
|
||||||
|
const width = readU16LE(bytes, 6);
|
||||||
|
const height = readU16LE(bytes, 8);
|
||||||
|
if (width < 1 || height < 1) return null;
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function jpegDimensions(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): { width: number; height: number } | null {
|
||||||
|
if (bytes.length < 4) return null;
|
||||||
|
let offset = 2;
|
||||||
|
while (offset + 8 < bytes.length) {
|
||||||
|
if (bytes[offset] !== 0xff) {
|
||||||
|
offset += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const marker = bytes[offset + 1]!;
|
||||||
|
if (marker === 0xff) {
|
||||||
|
offset += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||||
|
offset += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (offset + 3 >= bytes.length) return null;
|
||||||
|
const size = readU16BE(bytes, offset + 2);
|
||||||
|
if (size < 2) return null;
|
||||||
|
const isSof =
|
||||||
|
(marker >= 0xc0 && marker <= 0xc3) ||
|
||||||
|
(marker >= 0xc5 && marker <= 0xc7) ||
|
||||||
|
(marker >= 0xc9 && marker <= 0xcb) ||
|
||||||
|
(marker >= 0xcd && marker <= 0xcf);
|
||||||
|
if (isSof) {
|
||||||
|
if (offset + 8 >= bytes.length) return null;
|
||||||
|
const height = readU16BE(bytes, offset + 5);
|
||||||
|
const width = readU16BE(bytes, offset + 7);
|
||||||
|
if (width < 1 || height < 1) return null;
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
offset += 2 + size;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webpDimensions(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): { width: number; height: number } | null {
|
||||||
|
if (bytes.length < 20) return null;
|
||||||
|
if (startsWith(bytes, 12, "VP8X")) {
|
||||||
|
if (bytes.length < 30) return null;
|
||||||
|
const width = readU24LE(bytes, 24) + 1;
|
||||||
|
const height = readU24LE(bytes, 27) + 1;
|
||||||
|
if (width < 1 || height < 1) return null;
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
if (startsWith(bytes, 12, "VP8L")) {
|
||||||
|
if (bytes.length < 25 || bytes[20] !== 0x2f) return null;
|
||||||
|
const bits =
|
||||||
|
bytes[21]! |
|
||||||
|
(bytes[22]! << 8) |
|
||||||
|
(bytes[23]! << 16) |
|
||||||
|
(bytes[24]! << 24);
|
||||||
|
const width = (bits & 0x3fff) + 1;
|
||||||
|
const height = ((bits >> 14) & 0x3fff) + 1;
|
||||||
|
if (width < 1 || height < 1) return null;
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
if (startsWith(bytes, 12, "VP8 ")) {
|
||||||
|
if (bytes.length < 30) return null;
|
||||||
|
if (bytes[23] !== 0x9d || bytes[24] !== 0x01 || bytes[25] !== 0x2a) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const width = readU16LE(bytes, 26) & 0x3fff;
|
||||||
|
const height = readU16LE(bytes, 28) & 0x3fff;
|
||||||
|
if (width < 1 || height < 1) return null;
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rasterDimensions(
|
||||||
|
kind: CreateFlowUploadRasterKind,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): { width: number; height: number } | null {
|
||||||
|
switch (kind) {
|
||||||
|
case "png":
|
||||||
|
return pngDimensions(bytes);
|
||||||
|
case "gif":
|
||||||
|
return gifDimensions(bytes);
|
||||||
|
case "jpeg":
|
||||||
|
return jpegDimensions(bytes);
|
||||||
|
case "webp":
|
||||||
|
return webpDimensions(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function purposeAllowsKind(
|
||||||
|
purpose: CreateFlowUploadPurpose,
|
||||||
|
kind: CreateFlowUploadRasterKind | "pdf",
|
||||||
|
): boolean {
|
||||||
|
if (kind === "pdf") return purpose === "customMethodAttachment";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size, true-type, SVG, and header-decode checks shared by the browser and
|
||||||
|
* `POST /api/uploads`. Call {@link validateCreateFlowUploadFile} on the client
|
||||||
|
* so oversized files are rejected before they are read into memory.
|
||||||
|
*/
|
||||||
|
export function validateCreateFlowUploadBytes(
|
||||||
|
purpose: CreateFlowUploadPurpose,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): CreateFlowUploadValidationResult {
|
||||||
|
if (bytes.length === 0) return { ok: false, reason: "empty" };
|
||||||
|
if (bytes.length > maxBytesForPurpose(purpose)) {
|
||||||
|
return { ok: false, reason: "tooLarge" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sniffed = sniffCreateFlowUploadBytes(bytes);
|
||||||
|
if (sniffed === "empty") return { ok: false, reason: "empty" };
|
||||||
|
if (sniffed === "svg") return { ok: false, reason: "svg" };
|
||||||
|
if (sniffed === "unknown") return { ok: false, reason: "invalidType" };
|
||||||
|
if (!purposeAllowsKind(purpose, sniffed)) {
|
||||||
|
return { ok: false, reason: "invalidType" };
|
||||||
|
}
|
||||||
|
if (sniffed === "pdf") {
|
||||||
|
return { ok: true, kind: "pdf", mimeType: mimeTypeForSniffedKind("pdf") };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rasterDimensions(sniffed, bytes) == null) {
|
||||||
|
return { ok: false, reason: "undecodable" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
kind: sniffed,
|
||||||
|
mimeType: mimeTypeForSniffedKind(sniffed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBlobBytes(blob: Blob): Promise<Uint8Array> {
|
||||||
|
if (typeof blob.arrayBuffer === "function") {
|
||||||
|
return new Uint8Array(await blob.arrayBuffer());
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
resolve(new Uint8Array(reader.result as ArrayBuffer));
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject(reader.error ?? new Error("FileReader failed"));
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decodeRasterInBrowser(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
mimeType: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (typeof createImageBitmap !== "function") return true;
|
||||||
|
try {
|
||||||
|
const copy = new Uint8Array(bytes);
|
||||||
|
const bitmap = await createImageBitmap(
|
||||||
|
new Blob([copy], { type: mimeType }),
|
||||||
|
);
|
||||||
|
const ok = bitmap.width > 0 && bitmap.height > 0;
|
||||||
|
bitmap.close();
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side validation: size and SVG name/type before reading bytes, then
|
||||||
|
* the same sniff/decode path as the server, plus `createImageBitmap` when
|
||||||
|
* available.
|
||||||
|
*/
|
||||||
|
export async function validateCreateFlowUploadFile(
|
||||||
|
file: File,
|
||||||
|
purpose: CreateFlowUploadPurpose,
|
||||||
|
): Promise<CreateFlowUploadValidationResult> {
|
||||||
|
if (file.size === 0) return { ok: false, reason: "empty" };
|
||||||
|
if (file.size > maxBytesForPurpose(purpose)) {
|
||||||
|
return { ok: false, reason: "tooLarge" };
|
||||||
|
}
|
||||||
|
if (fileLooksLikeSvg(file)) return { ok: false, reason: "svg" };
|
||||||
|
|
||||||
|
const bytes = await readBlobBytes(file);
|
||||||
|
const result = validateCreateFlowUploadBytes(purpose, bytes);
|
||||||
|
if (result.ok === false || result.kind === "pdf") return result;
|
||||||
|
|
||||||
|
const decoded = await decodeRasterInBrowser(bytes, result.mimeType);
|
||||||
|
if (!decoded) return { ok: false, reason: "undecodable" };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user