Improve page load times and rendering
This commit is contained in:
+11
-1
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
const Mark = USE_CASES_GROUP_MARKS[index] ?? USE_CASES_GROUP_MARKS[0];
|
||||||
|
return {
|
||||||
...item,
|
...item,
|
||||||
icon: (
|
icon: (
|
||||||
/* eslint-disable-next-line @next/next/no-img-element -- small vector marks from `public/assets/vector` */
|
<Mark
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="block size-9 shrink-0 object-contain"
|
className="block size-9 shrink-0"
|
||||||
height={36}
|
|
||||||
src={getAssetPath(
|
|
||||||
vectorMarkPath(
|
|
||||||
USE_CASES_GROUP_VECTOR_SLUGS[index] ?? USE_CASES_GROUP_VECTOR_SLUGS[0],
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
width={36}
|
width={36}
|
||||||
|
height={36}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const askOrganizerData = {
|
const askOrganizerData = {
|
||||||
title: page.askOrganizer.title,
|
title: page.askOrganizer.title,
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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 }) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user