Backend / staging cleanup, performance substrate, and create-flow polish #60

Merged
an.di merged 16 commits from adilallo/Backend/StagingCleanup into main 2026-05-26 15:11:47 +00:00
29 changed files with 467 additions and 176 deletions
Showing only changes of commit 3be188a3cc - Show all commits
+11 -1
View File
@@ -1,7 +1,17 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
// Reads the session for admin chrome (matches the HttpOnly cookie on first
// HTML response). Scoped here so `(marketing)` can render statically.
export const dynamic = "force-dynamic";
// Operator/admin dashboards (e.g. `/monitor`) intentionally render without the // Operator/admin dashboards (e.g. `/monitor`) intentionally render without the
// public marketing footer. Auth/access is enforced upstream. // public marketing footer. Auth/access is enforced upstream.
export default function AdminLayout({ children }: { children: ReactNode }) { export default function AdminLayout({ children }: { children: ReactNode }) {
return <main className="flex-1">{children}</main>; return (
<>
<ConditionalNavigation />
<main className="flex-1">{children}</main>
</>
);
} }
+7 -23
View File
@@ -1,28 +1,12 @@
"use client";
import dynamic from "next/dynamic";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useTranslation } from "../../contexts/MessagesContext"; import CreateFlowLayoutClient from "./CreateFlowLayoutClient";
function CreateFlowLayoutLoading() {
const t = useTranslation("controlsChrome");
return (
<div
className="flex h-screen min-h-0 flex-col overflow-hidden bg-black"
aria-busy="true"
aria-label={t("loadingCreateFlow")}
/>
);
}
const CreateFlowLayoutClient = dynamic(
() => import("./CreateFlowLayoutClient"),
{
ssr: false,
loading: () => <CreateFlowLayoutLoading />,
},
);
/**
* Server-renders the create-flow chrome shell so users see real layout instead
* of a black `aria-busy` div while the client bundle hydrates. The provider
* inside `CreateFlowLayoutClient` defers `localStorage` reads to a mount-once
* effect so SSR + first client render align.
*/
export default function CreateFlowLayoutGate({ export default function CreateFlowLayoutGate({
children, children,
}: { }: {
@@ -16,6 +16,7 @@ import {
isValidStep, isValidStep,
parseCreateFlowScreenFromPathname, parseCreateFlowScreenFromPathname,
} from "./utils/flowSteps"; } from "./utils/flowSteps";
import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry";
import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled"; import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled";
@@ -52,6 +53,22 @@ export function SignedInDraftHydration({
const [loadingHydration, setLoadingHydration] = useState(false); const [loadingHydration, setLoadingHydration] = useState(false);
const finishedUserIdRef = useRef<string | null>(null); const finishedUserIdRef = useRef<string | null>(null);
const [freshEntryPending, setFreshEntryPending] = useState(false);
// Poll the sessionStorage sentinel set by `prepareFreshCreateFlowEntrySync`.
// Cheap because the gate is open within a few hundred ms in practice; the
// poll stops as soon as the in-flight DELETE clears the flag.
useEffect(() => {
if (!hasFreshEntryPending()) return;
setFreshEntryPending(true);
const id = window.setInterval(() => {
if (!hasFreshEntryPending()) {
setFreshEntryPending(false);
window.clearInterval(id);
}
}, 50);
return () => window.clearInterval(id);
}, []);
useEffect(() => { useEffect(() => {
if (!isBackendSyncEnabled()) return; if (!isBackendSyncEnabled()) return;
@@ -68,6 +85,10 @@ export function SignedInDraftHydration({
return; return;
} }
if (freshEntryPending) {
return;
}
// Local draft wins over server: no fetch, no replaceState. The provider // Local draft wins over server: no fetch, no replaceState. The provider
// already hydrated from localStorage at mount, so the user sees their // already hydrated from localStorage at mount, so the user sees their
// unsaved keystrokes immediately. // unsaved keystrokes immediately.
@@ -122,6 +143,7 @@ export function SignedInDraftHydration({
replaceState, replaceState,
pathname, pathname,
router, router,
freshEntryPending,
]); ]);
if (!loadingHydration) return null; if (!loadingHydration) return null;
+34 -14
View File
@@ -56,29 +56,49 @@ export function CreateFlowProvider({
initialStep = null, initialStep = null,
enableLocalDraftMirroring = false, enableLocalDraftMirroring = false,
}: CreateFlowProviderProps) { }: CreateFlowProviderProps) {
const [state, setState] = useState<CreateFlowState>(() => { // Initializer must NOT touch `localStorage`: this provider runs through SSR
const base = enableLocalDraftMirroring // now (CreateFlowLayoutGate dropped `ssr: false`), and a server `{}` followed
? readAnonymousCreateFlowState() // by a client read of stored data would be a hydration mismatch. The
: {}; // `mount-once` effect below replays the read on the client.
const storedDetails = readCoreValueDetailsFromLocalStorage(); const [state, setState] = useState<CreateFlowState>({});
if (Object.keys(storedDetails).length === 0) return base;
return {
...base,
coreValueDetailsByChipId: {
...storedDetails,
...(base.coreValueDetailsByChipId ?? {}),
},
};
});
const [interactionTouched, setInteractionTouched] = useState(false); const [interactionTouched, setInteractionTouched] = useState(false);
const [currentStep] = useState<CreateFlowStep | null>(initialStep); const [currentStep] = useState<CreateFlowStep | null>(initialStep);
const prevPersistRef = useRef(enableLocalDraftMirroring); const prevPersistRef = useRef(enableLocalDraftMirroring);
const persistWriteSkipRef = useRef(true); const persistWriteSkipRef = useRef(true);
const initialHydrateDoneRef = useRef(false);
useEffect(() => { useEffect(() => {
clearLegacyCreateFlowKeysOnce(); clearLegacyCreateFlowKeysOnce();
}, []); }, []);
// Replay the previous `useState` initializer on mount (client-only). Keeps
// SSR + first client render aligned with the empty default while still
// hydrating any persisted draft / core-value details that existed before
// the user landed back on a wizard step.
useEffect(() => {
if (initialHydrateDoneRef.current) return;
initialHydrateDoneRef.current = true;
const base = enableLocalDraftMirroring
? readAnonymousCreateFlowState()
: {};
const storedDetails = readCoreValueDetailsFromLocalStorage();
const baseEmpty = Object.keys(base).length === 0;
const detailsEmpty = Object.keys(storedDetails).length === 0;
if (baseEmpty && detailsEmpty) return;
setState((prev) => {
const merged: CreateFlowState = { ...base, ...prev };
if (!detailsEmpty) {
merged.coreValueDetailsByChipId = {
...storedDetails,
...(base.coreValueDetailsByChipId ?? {}),
...(prev.coreValueDetailsByChipId ?? {}),
};
}
return merged;
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional mount-once
}, []);
// Session resolved after initial paint: hydrate from localStorage, merging // Session resolved after initial paint: hydrate from localStorage, merging
// with anything already in state. We can't bail on `prev` being non-empty: // with anything already in state. We can't bail on `prev` being non-empty:
// the initializer pre-populates `coreValueDetailsByChipId` from a separate // the initializer pre-populates `coreValueDetailsByChipId` from a separate
+17
View File
@@ -0,0 +1,17 @@
/**
* Route-level fallback shown while `/create/...` RSC streams in. Mirrors the
* create-flow chrome surface (top bar height + dark canvas) so the user sees
* structural feedback instead of a flash of the previous page.
*/
export default function CreateFlowRouteLoading() {
return (
<div
className="flex h-screen min-h-0 flex-col overflow-hidden bg-[var(--color-surface-default-primary)]"
aria-busy="true"
aria-live="polite"
>
<div className="h-14 w-full border-b border-[var(--color-border-default-primary)] md:h-16" />
<div className="flex-1" />
</div>
);
}
@@ -2,24 +2,108 @@
* Step → screen component map (Linear CR-92 §3). Keeps {@link CreateFlowScreenView} * Step → screen component map (Linear CR-92 §3). Keeps {@link CreateFlowScreenView}
* thin; pair with {@link CREATE_FLOW_SCREEN_REGISTRY} metadata in tests/docs so * thin; pair with {@link CREATE_FLOW_SCREEN_REGISTRY} metadata in tests/docs so
* new steps do not drift. * new steps do not drift.
*
* `InformationalScreen` is statically imported because it is the entry step;
* every other screen is lazy-loaded so visiting `/create/informational` does
* not pull the rest of the wizard into the initial bundle.
*/ */
import dynamic from "next/dynamic";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { CreateFlowStep } from "../types"; import type { CreateFlowStep } from "../types";
import { InformationalScreen } from "./informational/InformationalScreen"; import { InformationalScreen } from "./informational/InformationalScreen";
import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen";
import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen"; const CreateFlowTextFieldScreen = dynamic(
import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen"; () =>
import { CoreValuesSelectScreen } from "./select/CoreValuesSelectScreen"; import("./text/CreateFlowTextFieldScreen").then((m) => ({
import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen"; default: m.CreateFlowTextFieldScreen,
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen"; })),
import { CommunityReviewScreen } from "./review/CommunityReviewScreen"; { loading: () => null },
import { FinalReviewScreen } from "./review/FinalReviewScreen"; );
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen"; const CommunitySizeSelectScreen = dynamic(
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen"; () =>
import { ConflictManagementScreen } from "./card/ConflictManagementScreen"; import("./select/CommunitySizeSelectScreen").then((m) => ({
import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen"; default: m.CommunitySizeSelectScreen,
import { CompletedScreen } from "./completed/CompletedScreen"; })),
{ loading: () => null },
);
const CommunityStructureSelectScreen = dynamic(
() =>
import("./select/CommunityStructureSelectScreen").then((m) => ({
default: m.CommunityStructureSelectScreen,
})),
{ loading: () => null },
);
const CoreValuesSelectScreen = dynamic(
() =>
import("./select/CoreValuesSelectScreen").then((m) => ({
default: m.CoreValuesSelectScreen,
})),
{ loading: () => null },
);
const ConfirmStakeholdersScreen = dynamic(
() =>
import("./select/ConfirmStakeholdersScreen").then((m) => ({
default: m.ConfirmStakeholdersScreen,
})),
{ loading: () => null },
);
const CommunityUploadScreen = dynamic(
() =>
import("./upload/CommunityUploadScreen").then((m) => ({
default: m.CommunityUploadScreen,
})),
{ loading: () => null },
);
const CommunityReviewScreen = dynamic(
() =>
import("./review/CommunityReviewScreen").then((m) => ({
default: m.CommunityReviewScreen,
})),
{ loading: () => null },
);
const FinalReviewScreen = dynamic(
() =>
import("./review/FinalReviewScreen").then((m) => ({
default: m.FinalReviewScreen,
})),
{ loading: () => null },
);
const CommunicationMethodsScreen = dynamic(
() =>
import("./card/CommunicationMethodsScreen").then((m) => ({
default: m.CommunicationMethodsScreen,
})),
{ loading: () => null },
);
const MembershipMethodsScreen = dynamic(
() =>
import("./card/MembershipMethodsScreen").then((m) => ({
default: m.MembershipMethodsScreen,
})),
{ loading: () => null },
);
const ConflictManagementScreen = dynamic(
() =>
import("./card/ConflictManagementScreen").then((m) => ({
default: m.ConflictManagementScreen,
})),
{ loading: () => null },
);
const DecisionApproachesScreen = dynamic(
() =>
import("./right-rail/DecisionApproachesScreen").then((m) => ({
default: m.DecisionApproachesScreen,
})),
{ loading: () => null },
);
const CompletedScreen = dynamic(
() =>
import("./completed/CompletedScreen").then((m) => ({
default: m.CompletedScreen,
})),
{ loading: () => null },
);
export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode { export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
switch (screenId) { switch (screenId) {
+3 -2
View File
@@ -7,13 +7,14 @@ import type { CreateFlowStep } from "../types";
import { import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY, CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
FIRST_STEP,
} from "./flowSteps"; } from "./flowSteps";
export const CREATE_ROUTES = { export const CREATE_ROUTES = {
root: "/", root: "/",
createRoot: "/create", createRoot: "/create",
/** First step resolves via redirect from `/create`. */ /** Direct path to the first wizard step so client navigations skip the redirect hop. */
createFirstStep: "/create", createFirstStep: `/create/${FIRST_STEP}`,
review: "/create/review", review: "/create/review",
finalReview: "/create/final-review", finalReview: "/create/final-review",
completed: "/create/completed", completed: "/create/completed",
@@ -4,19 +4,73 @@ import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorag
import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled"; import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled";
/**
* Sentinel set on click and cleared once the in-flight DELETE settles. Read by
* {@link SignedInDraftHydration} so it skips the server draft fetch while the
* fresh-entry cleanup is racing the user's first paint of `/create`.
*/
export const FRESH_ENTRY_PENDING_KEY = "create:fresh-entry-pending";
export function hasFreshEntryPending(): boolean {
if (typeof window === "undefined") return false;
try {
return window.sessionStorage.getItem(FRESH_ENTRY_PENDING_KEY) === "1";
} catch {
return false;
}
}
function setFreshEntryPending(): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(FRESH_ENTRY_PENDING_KEY, "1");
} catch {
/* ignore — sessionStorage may be unavailable */
}
}
function clearFreshEntryPending(): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.removeItem(FRESH_ENTRY_PENDING_KEY);
} catch {
/* ignore */
}
}
/** /**
* Call **before** navigating into `/create` from marketing or profile “new rule” * Call **before** navigating into `/create` from marketing or profile “new rule”
* entry points so signed-in + sync matches an anonymous fresh start: wipe * entry points so signed-in + sync matches an anonymous fresh start: wipe
* `localStorage` draft keys and, when sync is on, `DELETE /api/drafts/me`. * `localStorage` draft keys and, when sync is on, `DELETE /api/drafts/me`.
* Anonymous `DELETE` is harmless (401). Await ensures the server draft is gone *
* before mount so {@link SignedInDraftHydration} does not rehydrate stale work. * Synchronous variant: returns immediately after clearing local state and
* scheduling the server draft delete in the background. Sets a sessionStorage
* sentinel that {@link SignedInDraftHydration} checks before fetching, so the
* brief race window does not hydrate from a not-yet-deleted server draft.
* *
* Do **not** use for “Continue draft” — that path should load the server draft. * Do **not** use for “Continue draft” — that path should load the server draft.
*/ */
export function prepareFreshCreateFlowEntrySync(): void {
clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage();
if (!isBackendSyncEnabled()) return;
setFreshEntryPending();
void deleteServerDraft().finally(clearFreshEntryPending);
}
/**
* Awaitable variant kept for callers that genuinely need the DELETE to settle
* before continuing (e.g. tests, programmatic reset flows). Most click handlers
* should use {@link prepareFreshCreateFlowEntrySync} for instant navigation.
*/
export async function prepareFreshCreateFlowEntry(): Promise<void> { export async function prepareFreshCreateFlowEntry(): Promise<void> {
clearAnonymousCreateFlowStorage(); clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage(); clearCoreValueDetailsLocalStorage();
if (isBackendSyncEnabled()) { if (!isBackendSyncEnabled()) return;
setFreshEntryPending();
try {
await deleteServerDraft(); await deleteServerDraft();
} finally {
clearFreshEntryPending();
} }
} }
+13 -1
View File
@@ -1,8 +1,20 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
// Reads `cr_session` via Server Components on every navigation so the header
// matches the HttpOnly cookie on the first HTML response (no "Log in" flash
// before `/api/auth/session`). Scoped here instead of the root layout so
// `(marketing)` can render statically.
export const dynamic = "force-dynamic";
// Signed-in product surfaces (`/create/*`, `/login`) run without the marketing // Signed-in product surfaces (`/create/*`, `/login`) run without the marketing
// footer. `/profile` adds it via `profile/layout.tsx`. Per-route chrome (e.g. // footer. `/profile` adds it via `profile/layout.tsx`. Per-route chrome (e.g.
// CreateFlow) is composed in nested layouts. // CreateFlow) is composed in nested layouts.
export default function AppLayout({ children }: { children: ReactNode }) { export default function AppLayout({ children }: { children: ReactNode }) {
return <main className="flex-1">{children}</main>; return (
<>
<ConditionalNavigation />
<main className="flex-1">{children}</main>
</>
);
} }
+3 -5
View File
@@ -23,7 +23,7 @@ import {
import type { CreateFlowStep } from "../create/types"; import type { CreateFlowStep } from "../create/types";
import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage"; import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage";
import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage"; import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage";
import { prepareFreshCreateFlowEntry } from "../create/utils/prepareFreshCreateFlowEntry"; import { prepareFreshCreateFlowEntrySync } from "../create/utils/prepareFreshCreateFlowEntry";
import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useMediaQuery } from "../../hooks/useMediaQuery";
import { import {
ProfilePageSignedOutView, ProfilePageSignedOutView,
@@ -253,10 +253,8 @@ export default function ProfilePageClient() {
}, [draft, router]); }, [draft, router]);
const handleStartNewCustomRule = useCallback(() => { const handleStartNewCustomRule = useCallback(() => {
void (async () => { prepareFreshCreateFlowEntrySync();
await prepareFreshCreateFlowEntry(); router.push("/create/informational");
router.push("/create");
})();
}, [router]); }, [router]);
const handleRequestDeleteDraft = useCallback(() => { const handleRequestDeleteDraft = useCallback(() => {
+2
View File
@@ -1,5 +1,6 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import MarketingNavigation from "../components/navigation/MarketingNavigation";
// 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
// signed-in product surfaces, admin dashboards, or dev previews. See // signed-in product surfaces, admin dashboards, or dev previews. See
@@ -14,6 +15,7 @@ const Footer = dynamic(() => import("../components/navigation/Footer"), {
export default function MarketingLayout({ children }: { children: ReactNode }) { export default function MarketingLayout({ children }: { children: ReactNode }) {
return ( return (
<> <>
<MarketingNavigation />
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>
<Footer /> <Footer />
</> </>
+14
View File
@@ -0,0 +1,14 @@
/**
* Lightweight skeleton shown while the next marketing route streams. Matches
* the page background so navigations feel instant on the user's phone instead
* of stalling on the previous page until RSC payload arrives.
*/
export default function MarketingRouteLoading() {
return (
<div
className="min-h-screen w-full bg-[var(--color-surface-default-primary)]"
aria-busy="true"
aria-live="polite"
/>
);
}
@@ -5,7 +5,7 @@ import { useRouter, 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";
import { prepareFreshCreateFlowEntry } from "../../(app)/create/utils/prepareFreshCreateFlowEntry"; import { prepareFreshCreateFlowEntrySync } from "../../(app)/create/utils/prepareFreshCreateFlowEntry";
import { import {
buildTemplateReviewHref, buildTemplateReviewHref,
TEMPLATES_FACET_RECOMMEND_QUERY, TEMPLATES_FACET_RECOMMEND_QUERY,
@@ -102,13 +102,7 @@ function TemplatesGrid({
entries={entries} entries={entries}
onTemplateClick={(slug) => { onTemplateClick={(slug) => {
if (!fromFlow) { if (!fromFlow) {
void (async () => { prepareFreshCreateFlowEntrySync();
await prepareFreshCreateFlowEntry();
router.push(
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
);
})();
return;
} }
router.push( router.push(
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }), buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
+24 -25
View File
@@ -15,7 +15,10 @@ import TripleTextBlock from "../../components/type/TripleTextBlock";
import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock"; import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock";
import AskOrganizer from "../../components/sections/AskOrganizer"; import AskOrganizer from "../../components/sections/AskOrganizer";
import { MarketingRuleStackSection } from "../_components/MarketingRuleStackSection"; import { MarketingRuleStackSection } from "../_components/MarketingRuleStackSection";
import { getAssetPath, vectorMarkPath } from "../../../lib/assetUtils"; import WorkerCoopMark from "../../../public/assets/vector/worker-coop.svg";
import MutualAidMark from "../../../public/assets/vector/mutual-aid.svg";
import OpenSourceMark from "../../../public/assets/vector/open-source.svg";
import DaoMark from "../../../public/assets/vector/dao.svg";
const RelatedArticles = dynamic( const RelatedArticles = dynamic(
() => import("../../components/sections/RelatedArticles"), () => import("../../components/sections/RelatedArticles"),
@@ -41,12 +44,12 @@ const CASE_STUDY_LINK_CLASS = [
"active:scale-[0.98]", "active:scale-[0.98]",
].join(" "); ].join(" ");
/** Matches `pages.useCases.groups.items` order ↔ `public/assets/vector/*.svg`. */ /** Matches `pages.useCases.groups.items` order ↔ inlined vector mark components. */
const USE_CASES_GROUP_VECTOR_SLUGS = [ const USE_CASES_GROUP_MARKS = [
"worker-coop", WorkerCoopMark,
"mutual-aid", MutualAidMark,
"open-source", OpenSourceMark,
"dao", DaoMark,
] as const; ] as const;
const USE_CASES_RELATED_SENTINEL_SLUG = "__use-cases-page__"; const USE_CASES_RELATED_SENTINEL_SLUG = "__use-cases-page__";
@@ -77,24 +80,20 @@ export default function UseCasesPage() {
page.groups.items, page.groups.items,
); );
const groupItems: GroupsItem[] = groupItemsRaw.map((item, index) => ({ const groupItems: GroupsItem[] = groupItemsRaw.map((item, index) => {
...item, const Mark = USE_CASES_GROUP_MARKS[index] ?? USE_CASES_GROUP_MARKS[0];
icon: ( return {
/* eslint-disable-next-line @next/next/no-img-element -- small vector marks from `public/assets/vector` */ ...item,
<img icon: (
alt="" <Mark
aria-hidden aria-hidden
className="block size-9 shrink-0 object-contain" className="block size-9 shrink-0"
height={36} width={36}
src={getAssetPath( height={36}
vectorMarkPath( />
USE_CASES_GROUP_VECTOR_SLUGS[index] ?? USE_CASES_GROUP_VECTOR_SLUGS[0], ),
), };
)} });
width={36}
/>
),
}));
const askOrganizerData = { const askOrganizerData = {
title: page.askOrganizer.title, title: page.askOrganizer.title,
+9 -1
View File
@@ -24,7 +24,15 @@ const Avatar = memo<AvatarProps>(
return ( return (
/* eslint-disable-next-line @next/next/no-img-element -- avatar image from URL */ /* eslint-disable-next-line @next/next/no-img-element -- avatar image from URL */
<img src={src} alt={alt} className={baseStyles} {...props} /> <img
src={src}
alt={alt}
className={baseStyles}
loading="eager"
decoding="async"
fetchPriority="high"
{...props}
/>
); );
}, },
); );
@@ -1,8 +1,10 @@
"use client"; "use client";
import Image from "next/image";
import { memo } from "react"; import { memo } from "react";
import { caseStudyVisualPath, getAssetPath } from "../../../../lib/assetUtils"; import type { ComponentType, SVGProps } from "react";
import MutualAidArt from "../../../../public/assets/case-study/case-study-mutual-aid.svg";
import FoodNotBombsArt from "../../../../public/assets/case-study/case-study-food-not-bombs.svg";
import BoulderCountyStreetMedicsArt from "../../../../public/assets/case-study/case-study-boulder-county-street-medics.svg";
import type { CaseStudyProps } from "./CaseStudy.types"; import type { CaseStudyProps } from "./CaseStudy.types";
const SURFACE_CLASS: Record<CaseStudyProps["surface"], string> = { const SURFACE_CLASS: Record<CaseStudyProps["surface"], string> = {
@@ -11,11 +13,23 @@ const SURFACE_CLASS: Record<CaseStudyProps["surface"], string> = {
rose: "bg-[var(--color-surface-invert-brand-red)]", rose: "bg-[var(--color-surface-invert-brand-red)]",
}; };
/** Default art per tile: Figma-exported SVG composites (305×305 incl. rounded bg). */ /**
const SURFACE_ART: Record<CaseStudyProps["surface"], string> = { * Inline SVGR components avoid the network round-trip the prior `next/image`
lavender: getAssetPath(caseStudyVisualPath("lavender")), * version required, so the illustration paints with the colored tile shell.
neutral: getAssetPath(caseStudyVisualPath("neutral")), */
rose: getAssetPath(caseStudyVisualPath("rose")), const SURFACE_ART: Record<
CaseStudyProps["surface"],
ComponentType<SVGProps<SVGSVGElement>>
> = {
lavender: MutualAidArt,
neutral: FoodNotBombsArt,
rose: BoulderCountyStreetMedicsArt,
};
const SURFACE_ART_DATA_KEY: Record<CaseStudyProps["surface"], string> = {
lavender: "case-study-mutual-aid",
neutral: "case-study-food-not-bombs",
rose: "case-study-boulder-county-street-medics",
}; };
/** Figma: ~23px corner (“Card / CaseStudy” shells). */ /** Figma: ~23px corner (“Card / CaseStudy” shells). */
@@ -27,6 +41,7 @@ function CaseStudyView({
visual, visual,
className = "", className = "",
}: CaseStudyProps) { }: CaseStudyProps) {
const Art = SURFACE_ART[surface];
return ( return (
<div <div
data-figma-node="21993-32352" data-figma-node="21993-32352"
@@ -35,14 +50,13 @@ function CaseStudyView({
{visual ? ( {visual ? (
<div className="flex size-full items-center justify-center p-2">{visual}</div> <div className="flex size-full items-center justify-center p-2">{visual}</div>
) : ( ) : (
<Image <Art
src={SURFACE_ART[surface]} role="img"
alt={imageAlt} aria-label={imageAlt}
data-case-study-art={SURFACE_ART_DATA_KEY[surface]}
width={305} width={305}
height={305} height={305}
unoptimized
className="pointer-events-none size-full select-none object-contain object-center" className="pointer-events-none size-full select-none object-contain object-center"
draggable={false}
/> />
)} )}
</div> </div>
@@ -0,0 +1,27 @@
"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;
@@ -13,7 +13,7 @@ import Button from "../../buttons/Button";
import AvatarContainer from "../../asset/AvatarContainer"; import AvatarContainer from "../../asset/AvatarContainer";
import Avatar from "../../asset/Avatar"; import Avatar from "../../asset/Avatar";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils"; import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import { prepareFreshCreateFlowEntry } 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, NavSize } from "./Top.types";
@@ -51,15 +51,14 @@ const TopContainer = memo<TopProps>(
/** /**
* `Top` is hidden on `/create` routes by ConditionalNavigationClient, so * `Top` is hidden on `/create` routes by ConditionalNavigationClient, so
* this button is always clicked from outside the wizard. Clears anonymous * this button is always clicked from outside the wizard. Clears anonymous
* `localStorage` and, when backend sync is on, deletes the server draft * `localStorage` synchronously and, when backend sync is on, fires the
* so signed-in users get the same fresh start as guests (see * server `DELETE /api/drafts/me` in the background. `SignedInDraftHydration`
* {@link prepareFreshCreateFlowEntry}). * reads the `create:fresh-entry-pending` sentinel and waits before fetching
* (see {@link prepareFreshCreateFlowEntrySync}).
*/ */
const handleCreateRuleClick = useCallback(() => { const handleCreateRuleClick = useCallback(() => {
void (async () => { prepareFreshCreateFlowEntrySync();
await prepareFreshCreateFlowEntry(); router.push("/create/informational");
router.push("/create");
})();
}, [router]); }, [router]);
// Schema markup for site navigation // Schema markup for site navigation
@@ -21,7 +21,6 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
onError, onError,
}) => { }) => {
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
// Variant configurations // Variant configurations
const variants: Record<string, VariantConfig> = { const variants: Record<string, VariantConfig> = {
@@ -97,16 +96,13 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
const quoteId = `${baseId}-content`; const quoteId = `${baseId}-content`;
const authorId = `${baseId}-author`; const authorId = `${baseId}-author`;
// Error handling functions
const handleImageError = (error: unknown) => { const handleImageError = (error: unknown) => {
logger.warn( logger.warn(
`QuoteBlock: Failed to load avatar image for ${author}:`, `QuoteBlock: Failed to load avatar image for ${author}:`,
error, error,
); );
setImageError(true); setImageError(true);
setImageLoading(false);
// Call error callback if provided
if (onError) { if (onError) {
onError({ onError({
type: "image_load_error", type: "image_load_error",
@@ -118,11 +114,6 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
} }
}; };
const handleImageLoad = () => {
setImageLoading(false);
setImageError(false);
};
// Validate required props // Validate required props
if (variantProp === "statement") { if (variantProp === "statement") {
if (!quote?.trim() || !quoteSecondary?.trim()) { if (!quote?.trim() || !quoteSecondary?.trim()) {
@@ -166,9 +157,7 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
authorId={authorId} authorId={authorId}
config={config} config={config}
imageError={imageError} imageError={imageError}
imageLoading={imageLoading}
currentAvatarSrc={currentAvatarSrc} currentAvatarSrc={currentAvatarSrc}
onImageLoad={handleImageLoad}
onImageError={handleImageError} onImageError={handleImageError}
/> />
); );
@@ -52,8 +52,6 @@ export interface QuoteBlockViewProps {
authorId: string; authorId: string;
config: VariantConfig; config: VariantConfig;
imageError: boolean; imageError: boolean;
imageLoading: boolean;
currentAvatarSrc: string; currentAvatarSrc: string;
onImageLoad: () => void;
onImageError: (_error: unknown) => void; onImageError: (_error: unknown) => void;
} }
@@ -17,9 +17,7 @@ function QuoteBlockView({
authorId, authorId,
config, config,
imageError, imageError,
imageLoading,
currentAvatarSrc, currentAvatarSrc,
onImageLoad,
onImageError, onImageError,
}: QuoteBlockViewProps) { }: QuoteBlockViewProps) {
const t = useTranslation("quoteBlock"); const t = useTranslation("quoteBlock");
@@ -89,7 +87,6 @@ function QuoteBlockView({
<div className={`flex flex-col ${config.gap} relative z-10`}> <div className={`flex flex-col ${config.gap} relative z-10`}>
<div className={`flex flex-col ${config.avatarGap}`}> <div className={`flex flex-col ${config.avatarGap}`}>
{/* Avatar with error handling */}
<div className="relative"> <div className="relative">
{!imageError ? ( {!imageError ? (
<Image <Image
@@ -97,26 +94,12 @@ function QuoteBlockView({
alt={avatarAlt} alt={avatarAlt}
width={64} width={64}
height={64} height={64}
className={`filter sepia ${ className={`filter sepia ${config.avatar}`}
config.avatar loading="eager"
} transition-opacity duration-300 ${ priority
imageLoading ? "opacity-0" : "opacity-100"
}`}
loading="lazy"
onError={onImageError} onError={onImageError}
onLoad={onImageLoad}
/> />
) : null} ) : (
{/* Loading state */}
{imageLoading && !imageError && (
<div
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
/>
)}
{/* Error state - show initials */}
{imageError && (
<div <div
className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`} className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`}
> >
@@ -8,7 +8,7 @@ import { memo, useEffect, useState } from "react";
import { useRouter } from "next/navigation"; 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 { prepareFreshCreateFlowEntry } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry"; import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
import { import {
fetchTemplates, fetchTemplates,
isTemplatesFetchAborted, isTemplatesFetchAborted,
@@ -99,10 +99,8 @@ const RuleStackContainer = memo<RuleStackProps>(
logger.debug(`${slug} template clicked`); logger.debug(`${slug} template clicked`);
// Marketing home “Popular templates”: same fresh start as Top “Create rule” // Marketing home “Popular templates”: same fresh start as Top “Create rule”
// (local + server draft when sync) so stale state cannot break template apply. // (local + server draft when sync) so stale state cannot break template apply.
void (async () => { prepareFreshCreateFlowEntrySync();
await prepareFreshCreateFlowEntry(); router.push(`/create/review-template/${encodeURIComponent(slug)}`);
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
})();
}; };
return ( return (
+28 -8
View File
@@ -6,10 +6,11 @@ import { MessagesProvider } from "./contexts/MessagesContext";
import messages from "../messages/en/index"; import messages from "../messages/en/index";
import { ASSETS, getAssetPath } from "../lib/assetUtils"; import { ASSETS, getAssetPath } from "../lib/assetUtils";
import "./globals.css"; import "./globals.css";
import ConditionalNavigation from "./components/navigation/ConditionalNavigation";
/** Header reads `cr_session` via Server Components; must not use prerendered guest HTML. */ // `force-dynamic` is now scoped to `(app)/layout.tsx` and `(admin)/layout.tsx`
export const dynamic = "force-dynamic"; // (the only groups that read the session via `ConditionalNavigation`). Marketing
// renders a client-side `MarketingNavigation` so its HTML can be statically
// optimized — TTFB drops to CDN speed for guests.
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
@@ -34,7 +35,9 @@ const spaceGrotesk = Space_Grotesk({
weight: ["400", "500", "700"], weight: ["400", "500", "700"],
variable: "--font-space-grotesk", variable: "--font-space-grotesk",
display: "swap", display: "swap",
preload: true, // Below-the-fold (subtitle in `ContentLockup` only). Skipping preload keeps
// the marketing critical-path bytes for Inter + Bricolage.
preload: false,
fallback: ["system-ui", "arial"], fallback: ["system-ui", "arial"],
}); });
@@ -116,15 +119,32 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en" className="font-sans"> <html lang="en" className="font-sans">
<head>
<link
rel="preload"
as="image"
href={getAssetPath(ASSETS.AVATAR_1)}
type="image/svg+xml"
/>
<link
rel="preload"
as="image"
href={getAssetPath(ASSETS.AVATAR_2)}
type="image/svg+xml"
/>
<link
rel="preload"
as="image"
href={getAssetPath(ASSETS.AVATAR_3)}
type="image/svg+xml"
/>
</head>
<body <body
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable}`} className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable}`}
> >
<MessagesProvider messages={messages}> <MessagesProvider messages={messages}>
<AuthModalProvider> <AuthModalProvider>
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">{children}</div>
<ConditionalNavigation />
{children}
</div>
</AuthModalProvider> </AuthModalProvider>
</MessagesProvider> </MessagesProvider>
</body> </body>
+2 -2
View File
@@ -49,7 +49,7 @@
"tripleStep": { "tripleStep": {
"heading": "Get recommendations that will make organizing easier", "heading": "Get recommendations that will make organizing easier",
"ctaText": "Create Rule", "ctaText": "Create Rule",
"ctaHref": "/create", "ctaHref": "/create/informational",
"steps": [ "steps": [
{ {
"title": "Get your stakeholders together", "title": "Get your stakeholders together",
@@ -68,7 +68,7 @@
"tripleTextBlock": { "tripleTextBlock": {
"title": "Why Horizontal groups need CommunityRule", "title": "Why Horizontal groups need CommunityRule",
"ctaText": "Setup your community", "ctaText": "Setup your community",
"ctaHref": "/create", "ctaHref": "/create/informational",
"columns": [ "columns": [
{ {
"title": "Share Leadership and Prevent Burnout", "title": "Share Leadership and Prevent Burnout",
+13 -1
View File
@@ -29,7 +29,7 @@ const nextConfig = {
// Image optimization // Image optimization
images: { images: {
formats: ["image/webp", "image/avif"], formats: ["image/webp", "image/avif"],
minimumCacheTTL: 60, minimumCacheTTL: 31536000,
dangerouslyAllowSVG: true, dangerouslyAllowSVG: true,
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
remotePatterns: [ remotePatterns: [
@@ -70,6 +70,18 @@ const nextConfig = {
}, },
], ],
}, },
{
// Long-cache static marketing art (avatars, vectors, case-study, etc.)
// since the file content is hashed into the URL by Next at request time
// through the image optimizer for raster, and changes require a deploy.
source: "/assets/:path*\\.(svg|png|webp|avif|jpg|jpeg)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
]; ];
}, },
webpack(config, { dev, isServer }) { webpack(config, { dev, isServer }) {
+2 -2
View File
@@ -73,7 +73,7 @@ describe('Top "Create rule" button', () => {
* in-flight anonymous draft so the wizard always starts fresh. See * in-flight anonymous draft so the wizard always starts fresh. See
* handleCreateRuleClick in Top.container.tsx for the contract. * handleCreateRuleClick in Top.container.tsx for the contract.
*/ */
it("clears anonymous draft + core-value-details localStorage before routing to /create", async () => { it("clears anonymous draft + core-value-details localStorage before routing to /create/informational", async () => {
window.localStorage.setItem( window.localStorage.setItem(
CREATE_FLOW_ANONYMOUS_KEY, CREATE_FLOW_ANONYMOUS_KEY,
JSON.stringify({ title: "Stale community" }), JSON.stringify({ title: "Stale community" }),
@@ -93,7 +93,7 @@ describe('Top "Create rule" button', () => {
await userEvent.click(btn); await userEvent.click(btn);
await waitFor(() => { await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/create"); expect(pushMock).toHaveBeenCalledWith("/create/informational");
}); });
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull(); expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
expect( expect(
+7 -5
View File
@@ -10,22 +10,24 @@ describe("CaseStudy", () => {
expect(container.querySelector('[data-figma-node="21993-32352"]')).toBeTruthy(); expect(container.querySelector('[data-figma-node="21993-32352"]')).toBeTruthy();
}); });
it("renders built-in raster when visual is omitted (neutral)", () => { it("renders built-in art when visual is omitted (neutral)", () => {
render( render(
<CaseStudy surface="neutral" imageAlt="Food Not Bombs logo" />, <CaseStudy surface="neutral" imageAlt="Food Not Bombs logo" />,
); );
expect( expect(
screen.getByRole("img", { name: "Food Not Bombs logo" }), screen.getByRole("img", { name: "Food Not Bombs logo" }),
).toHaveAttribute("src"); ).toBeTruthy();
}); });
it("uses Mutual Aid vector on lavender surface", () => { it("uses Mutual Aid vector on lavender surface", () => {
const { container } = render( const { container } = render(
<CaseStudy surface="lavender" imageAlt="Mutual Aid Colorado logo" />, <CaseStudy surface="lavender" imageAlt="Mutual Aid Colorado logo" />,
); );
expect(container.querySelector("img")?.getAttribute("src")).toContain( expect(
"case-study-mutual-aid.svg", container.querySelector("[data-case-study-art]")?.getAttribute(
); "data-case-study-art",
),
).toBe("case-study-mutual-aid");
}); });
}); });
+6 -3
View File
@@ -138,10 +138,13 @@ describe("Group layouts (chrome composition)", () => {
test("AppLayout wraps children in <main flex-1> with no footer", () => { test("AppLayout wraps children in <main flex-1> with no footer", () => {
const tree = AppLayout({ children: <div>app-child</div> }); const tree = AppLayout({ children: <div>app-child</div> });
expect(tree.type).toBe("main"); const main = findDescendant(
expect(tree.props.className).toContain("flex-1"); tree,
(n) => n?.type === "main" && n.props?.className?.includes("flex-1"),
);
expect(main).toBeTruthy();
expect( expect(
findDescendant(tree, (n) => typeof n === "string" && n.includes("app-child")), findDescendant(main, (n) => typeof n === "string" && n.includes("app-child")),
).toBeTruthy(); ).toBeTruthy();
}); });
}); });
+27
View File
@@ -13,6 +13,33 @@ export default defineConfig({
} }
}, },
}, },
// Stub .svg imports as inert React components so SVGR-style imports
// (`import Foo from "./foo.svg"; <Foo />`) work without webpack/turbopack.
// `enforce: "pre"` so we run before Vite's default asset handler that would
// otherwise return the URL string.
{
name: "svg-mock",
enforce: "pre",
async resolveId(source, importer) {
if (!source.endsWith(".svg")) return null;
const resolved = await this.resolve(source, importer, {
skipSelf: true,
});
if (!resolved) return null;
return { ...resolved, id: `${resolved.id}?svg-mock` };
},
load(id) {
if (id.endsWith(".svg?svg-mock")) {
return `import * as React from "react";
const SvgMock = React.forwardRef(function SvgMock(props, ref) {
return React.createElement("svg", { ref, ...props });
});
export default SvgMock;
export const ReactComponent = SvgMock;
`;
}
},
},
], ],
esbuild: { esbuild: {
target: "node18", target: "node18",