Backend / staging cleanup, performance substrate, and create-flow polish #60
+11
-1
@@ -1,7 +1,17 @@
|
||||
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
|
||||
// public marketing footer. Auth/access is enforced upstream.
|
||||
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 { useTranslation } from "../../contexts/MessagesContext";
|
||||
|
||||
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 />,
|
||||
},
|
||||
);
|
||||
import CreateFlowLayoutClient from "./CreateFlowLayoutClient";
|
||||
|
||||
/**
|
||||
* 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({
|
||||
children,
|
||||
}: {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
isValidStep,
|
||||
parseCreateFlowScreenFromPathname,
|
||||
} from "./utils/flowSteps";
|
||||
import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry";
|
||||
|
||||
import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled";
|
||||
|
||||
@@ -52,6 +53,22 @@ export function SignedInDraftHydration({
|
||||
|
||||
const [loadingHydration, setLoadingHydration] = useState(false);
|
||||
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(() => {
|
||||
if (!isBackendSyncEnabled()) return;
|
||||
@@ -68,6 +85,10 @@ export function SignedInDraftHydration({
|
||||
return;
|
||||
}
|
||||
|
||||
if (freshEntryPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Local draft wins over server: no fetch, no replaceState. The provider
|
||||
// already hydrated from localStorage at mount, so the user sees their
|
||||
// unsaved keystrokes immediately.
|
||||
@@ -122,6 +143,7 @@ export function SignedInDraftHydration({
|
||||
replaceState,
|
||||
pathname,
|
||||
router,
|
||||
freshEntryPending,
|
||||
]);
|
||||
|
||||
if (!loadingHydration) return null;
|
||||
|
||||
@@ -56,29 +56,49 @@ export function CreateFlowProvider({
|
||||
initialStep = null,
|
||||
enableLocalDraftMirroring = false,
|
||||
}: CreateFlowProviderProps) {
|
||||
const [state, setState] = useState<CreateFlowState>(() => {
|
||||
const base = enableLocalDraftMirroring
|
||||
? readAnonymousCreateFlowState()
|
||||
: {};
|
||||
const storedDetails = readCoreValueDetailsFromLocalStorage();
|
||||
if (Object.keys(storedDetails).length === 0) return base;
|
||||
return {
|
||||
...base,
|
||||
coreValueDetailsByChipId: {
|
||||
...storedDetails,
|
||||
...(base.coreValueDetailsByChipId ?? {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
// Initializer must NOT touch `localStorage`: this provider runs through SSR
|
||||
// now (CreateFlowLayoutGate dropped `ssr: false`), and a server `{}` followed
|
||||
// by a client read of stored data would be a hydration mismatch. The
|
||||
// `mount-once` effect below replays the read on the client.
|
||||
const [state, setState] = useState<CreateFlowState>({});
|
||||
const [interactionTouched, setInteractionTouched] = useState(false);
|
||||
const [currentStep] = useState<CreateFlowStep | null>(initialStep);
|
||||
const prevPersistRef = useRef(enableLocalDraftMirroring);
|
||||
const persistWriteSkipRef = useRef(true);
|
||||
const initialHydrateDoneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
// with anything already in state. We can't bail on `prev` being non-empty:
|
||||
// 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}
|
||||
* thin; pair with {@link CREATE_FLOW_SCREEN_REGISTRY} metadata in tests/docs so
|
||||
* 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 { CreateFlowStep } from "../types";
|
||||
import { InformationalScreen } from "./informational/InformationalScreen";
|
||||
import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen";
|
||||
import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen";
|
||||
import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen";
|
||||
import { CoreValuesSelectScreen } from "./select/CoreValuesSelectScreen";
|
||||
import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
|
||||
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
|
||||
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
|
||||
import { FinalReviewScreen } from "./review/FinalReviewScreen";
|
||||
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen";
|
||||
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen";
|
||||
import { ConflictManagementScreen } from "./card/ConflictManagementScreen";
|
||||
import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen";
|
||||
import { CompletedScreen } from "./completed/CompletedScreen";
|
||||
|
||||
const CreateFlowTextFieldScreen = dynamic(
|
||||
() =>
|
||||
import("./text/CreateFlowTextFieldScreen").then((m) => ({
|
||||
default: m.CreateFlowTextFieldScreen,
|
||||
})),
|
||||
{ loading: () => null },
|
||||
);
|
||||
const CommunitySizeSelectScreen = dynamic(
|
||||
() =>
|
||||
import("./select/CommunitySizeSelectScreen").then((m) => ({
|
||||
default: m.CommunitySizeSelectScreen,
|
||||
})),
|
||||
{ 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 {
|
||||
switch (screenId) {
|
||||
|
||||
@@ -7,13 +7,14 @@ import type { CreateFlowStep } from "../types";
|
||||
import {
|
||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
|
||||
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||
FIRST_STEP,
|
||||
} from "./flowSteps";
|
||||
|
||||
export const CREATE_ROUTES = {
|
||||
root: "/",
|
||||
createRoot: "/create",
|
||||
/** First step resolves via redirect from `/create`. */
|
||||
createFirstStep: "/create",
|
||||
/** Direct path to the first wizard step so client navigations skip the redirect hop. */
|
||||
createFirstStep: `/create/${FIRST_STEP}`,
|
||||
review: "/create/review",
|
||||
finalReview: "/create/final-review",
|
||||
completed: "/create/completed",
|
||||
|
||||
@@ -4,19 +4,73 @@ import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorag
|
||||
|
||||
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”
|
||||
* entry points so signed-in + sync matches an anonymous fresh start: wipe
|
||||
* `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.
|
||||
*/
|
||||
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> {
|
||||
clearAnonymousCreateFlowStorage();
|
||||
clearCoreValueDetailsLocalStorage();
|
||||
if (isBackendSyncEnabled()) {
|
||||
if (!isBackendSyncEnabled()) return;
|
||||
setFreshEntryPending();
|
||||
try {
|
||||
await deleteServerDraft();
|
||||
} finally {
|
||||
clearFreshEntryPending();
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -1,8 +1,20 @@
|
||||
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
|
||||
// footer. `/profile` adds it via `profile/layout.tsx`. Per-route chrome (e.g.
|
||||
// CreateFlow) is composed in nested layouts.
|
||||
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 { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage";
|
||||
import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage";
|
||||
import { prepareFreshCreateFlowEntry } from "../create/utils/prepareFreshCreateFlowEntry";
|
||||
import { prepareFreshCreateFlowEntrySync } from "../create/utils/prepareFreshCreateFlowEntry";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import {
|
||||
ProfilePageSignedOutView,
|
||||
@@ -253,10 +253,8 @@ export default function ProfilePageClient() {
|
||||
}, [draft, router]);
|
||||
|
||||
const handleStartNewCustomRule = useCallback(() => {
|
||||
void (async () => {
|
||||
await prepareFreshCreateFlowEntry();
|
||||
router.push("/create");
|
||||
})();
|
||||
prepareFreshCreateFlowEntrySync();
|
||||
router.push("/create/informational");
|
||||
}, [router]);
|
||||
|
||||
const handleRequestDeleteDraft = useCallback(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ReactNode } from "react";
|
||||
import MarketingNavigation from "../components/navigation/MarketingNavigation";
|
||||
|
||||
// Site footer is part of the public marketing chrome only — not rendered for
|
||||
// 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 }) {
|
||||
return (
|
||||
<>
|
||||
<MarketingNavigation />
|
||||
<main className="flex-1">{children}</main>
|
||||
<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 { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
||||
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
||||
import { prepareFreshCreateFlowEntry } from "../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||
import { prepareFreshCreateFlowEntrySync } from "../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||
import {
|
||||
buildTemplateReviewHref,
|
||||
TEMPLATES_FACET_RECOMMEND_QUERY,
|
||||
@@ -102,13 +102,7 @@ function TemplatesGrid({
|
||||
entries={entries}
|
||||
onTemplateClick={(slug) => {
|
||||
if (!fromFlow) {
|
||||
void (async () => {
|
||||
await prepareFreshCreateFlowEntry();
|
||||
router.push(
|
||||
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
|
||||
);
|
||||
})();
|
||||
return;
|
||||
prepareFreshCreateFlowEntrySync();
|
||||
}
|
||||
router.push(
|
||||
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
|
||||
|
||||
@@ -15,7 +15,10 @@ import TripleTextBlock from "../../components/type/TripleTextBlock";
|
||||
import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock";
|
||||
import AskOrganizer from "../../components/sections/AskOrganizer";
|
||||
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(
|
||||
() => import("../../components/sections/RelatedArticles"),
|
||||
@@ -41,12 +44,12 @@ const CASE_STUDY_LINK_CLASS = [
|
||||
"active:scale-[0.98]",
|
||||
].join(" ");
|
||||
|
||||
/** Matches `pages.useCases.groups.items` order ↔ `public/assets/vector/*.svg`. */
|
||||
const USE_CASES_GROUP_VECTOR_SLUGS = [
|
||||
"worker-coop",
|
||||
"mutual-aid",
|
||||
"open-source",
|
||||
"dao",
|
||||
/** Matches `pages.useCases.groups.items` order ↔ inlined vector mark components. */
|
||||
const USE_CASES_GROUP_MARKS = [
|
||||
WorkerCoopMark,
|
||||
MutualAidMark,
|
||||
OpenSourceMark,
|
||||
DaoMark,
|
||||
] as const;
|
||||
|
||||
const USE_CASES_RELATED_SENTINEL_SLUG = "__use-cases-page__";
|
||||
@@ -77,24 +80,20 @@ export default function UseCasesPage() {
|
||||
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,
|
||||
icon: (
|
||||
/* eslint-disable-next-line @next/next/no-img-element -- small vector marks from `public/assets/vector` */
|
||||
<img
|
||||
alt=""
|
||||
<Mark
|
||||
aria-hidden
|
||||
className="block size-9 shrink-0 object-contain"
|
||||
height={36}
|
||||
src={getAssetPath(
|
||||
vectorMarkPath(
|
||||
USE_CASES_GROUP_VECTOR_SLUGS[index] ?? USE_CASES_GROUP_VECTOR_SLUGS[0],
|
||||
),
|
||||
)}
|
||||
className="block size-9 shrink-0"
|
||||
width={36}
|
||||
height={36}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
const askOrganizerData = {
|
||||
title: page.askOrganizer.title,
|
||||
|
||||
@@ -24,7 +24,15 @@ const Avatar = memo<AvatarProps>(
|
||||
|
||||
return (
|
||||
/* 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";
|
||||
|
||||
import Image from "next/image";
|
||||
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";
|
||||
|
||||
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)]",
|
||||
};
|
||||
|
||||
/** Default art per tile: Figma-exported SVG composites (305×305 incl. rounded bg). */
|
||||
const SURFACE_ART: Record<CaseStudyProps["surface"], string> = {
|
||||
lavender: getAssetPath(caseStudyVisualPath("lavender")),
|
||||
neutral: getAssetPath(caseStudyVisualPath("neutral")),
|
||||
rose: getAssetPath(caseStudyVisualPath("rose")),
|
||||
/**
|
||||
* Inline SVGR components avoid the network round-trip the prior `next/image`
|
||||
* version required, so the illustration paints with the colored tile shell.
|
||||
*/
|
||||
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). */
|
||||
@@ -27,6 +41,7 @@ function CaseStudyView({
|
||||
visual,
|
||||
className = "",
|
||||
}: CaseStudyProps) {
|
||||
const Art = SURFACE_ART[surface];
|
||||
return (
|
||||
<div
|
||||
data-figma-node="21993-32352"
|
||||
@@ -35,14 +50,13 @@ function CaseStudyView({
|
||||
{visual ? (
|
||||
<div className="flex size-full items-center justify-center p-2">{visual}</div>
|
||||
) : (
|
||||
<Image
|
||||
src={SURFACE_ART[surface]}
|
||||
alt={imageAlt}
|
||||
<Art
|
||||
role="img"
|
||||
aria-label={imageAlt}
|
||||
data-case-study-art={SURFACE_ART_DATA_KEY[surface]}
|
||||
width={305}
|
||||
height={305}
|
||||
unoptimized
|
||||
className="pointer-events-none size-full select-none object-contain object-center"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</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 Avatar from "../../asset/Avatar";
|
||||
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 type { TopProps, NavSize } from "./Top.types";
|
||||
|
||||
@@ -51,15 +51,14 @@ const TopContainer = memo<TopProps>(
|
||||
/**
|
||||
* `Top` is hidden on `/create` routes by ConditionalNavigationClient, so
|
||||
* this button is always clicked from outside the wizard. Clears anonymous
|
||||
* `localStorage` and, when backend sync is on, deletes the server draft
|
||||
* so signed-in users get the same fresh start as guests (see
|
||||
* {@link prepareFreshCreateFlowEntry}).
|
||||
* `localStorage` synchronously and, when backend sync is on, fires the
|
||||
* server `DELETE /api/drafts/me` in the background. `SignedInDraftHydration`
|
||||
* reads the `create:fresh-entry-pending` sentinel and waits before fetching
|
||||
* (see {@link prepareFreshCreateFlowEntrySync}).
|
||||
*/
|
||||
const handleCreateRuleClick = useCallback(() => {
|
||||
void (async () => {
|
||||
await prepareFreshCreateFlowEntry();
|
||||
router.push("/create");
|
||||
})();
|
||||
prepareFreshCreateFlowEntrySync();
|
||||
router.push("/create/informational");
|
||||
}, [router]);
|
||||
|
||||
// Schema markup for site navigation
|
||||
|
||||
@@ -21,7 +21,6 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
||||
onError,
|
||||
}) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
// Variant configurations
|
||||
const variants: Record<string, VariantConfig> = {
|
||||
@@ -97,16 +96,13 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
||||
const quoteId = `${baseId}-content`;
|
||||
const authorId = `${baseId}-author`;
|
||||
|
||||
// Error handling functions
|
||||
const handleImageError = (error: unknown) => {
|
||||
logger.warn(
|
||||
`QuoteBlock: Failed to load avatar image for ${author}:`,
|
||||
error,
|
||||
);
|
||||
setImageError(true);
|
||||
setImageLoading(false);
|
||||
|
||||
// Call error callback if provided
|
||||
if (onError) {
|
||||
onError({
|
||||
type: "image_load_error",
|
||||
@@ -118,11 +114,6 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setImageLoading(false);
|
||||
setImageError(false);
|
||||
};
|
||||
|
||||
// Validate required props
|
||||
if (variantProp === "statement") {
|
||||
if (!quote?.trim() || !quoteSecondary?.trim()) {
|
||||
@@ -166,9 +157,7 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
||||
authorId={authorId}
|
||||
config={config}
|
||||
imageError={imageError}
|
||||
imageLoading={imageLoading}
|
||||
currentAvatarSrc={currentAvatarSrc}
|
||||
onImageLoad={handleImageLoad}
|
||||
onImageError={handleImageError}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -52,8 +52,6 @@ export interface QuoteBlockViewProps {
|
||||
authorId: string;
|
||||
config: VariantConfig;
|
||||
imageError: boolean;
|
||||
imageLoading: boolean;
|
||||
currentAvatarSrc: string;
|
||||
onImageLoad: () => void;
|
||||
onImageError: (_error: unknown) => void;
|
||||
}
|
||||
|
||||
@@ -17,9 +17,7 @@ function QuoteBlockView({
|
||||
authorId,
|
||||
config,
|
||||
imageError,
|
||||
imageLoading,
|
||||
currentAvatarSrc,
|
||||
onImageLoad,
|
||||
onImageError,
|
||||
}: QuoteBlockViewProps) {
|
||||
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.avatarGap}`}>
|
||||
{/* Avatar with error handling */}
|
||||
<div className="relative">
|
||||
{!imageError ? (
|
||||
<Image
|
||||
@@ -97,26 +94,12 @@ function QuoteBlockView({
|
||||
alt={avatarAlt}
|
||||
width={64}
|
||||
height={64}
|
||||
className={`filter sepia ${
|
||||
config.avatar
|
||||
} transition-opacity duration-300 ${
|
||||
imageLoading ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
loading="lazy"
|
||||
className={`filter sepia ${config.avatar}`}
|
||||
loading="eager"
|
||||
priority
|
||||
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
|
||||
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 { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { logger } from "../../../../lib/logger";
|
||||
import { prepareFreshCreateFlowEntry } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||
import {
|
||||
fetchTemplates,
|
||||
isTemplatesFetchAborted,
|
||||
@@ -99,10 +99,8 @@ const RuleStackContainer = memo<RuleStackProps>(
|
||||
logger.debug(`${slug} template clicked`);
|
||||
// Marketing home “Popular templates”: same fresh start as Top “Create rule”
|
||||
// (local + server draft when sync) so stale state cannot break template apply.
|
||||
void (async () => {
|
||||
await prepareFreshCreateFlowEntry();
|
||||
prepareFreshCreateFlowEntrySync();
|
||||
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
|
||||
})();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+28
-8
@@ -6,10 +6,11 @@ import { MessagesProvider } from "./contexts/MessagesContext";
|
||||
import messages from "../messages/en/index";
|
||||
import { ASSETS, getAssetPath } from "../lib/assetUtils";
|
||||
import "./globals.css";
|
||||
import ConditionalNavigation from "./components/navigation/ConditionalNavigation";
|
||||
|
||||
/** Header reads `cr_session` via Server Components; must not use prerendered guest HTML. */
|
||||
export const dynamic = "force-dynamic";
|
||||
// `force-dynamic` is now scoped to `(app)/layout.tsx` and `(admin)/layout.tsx`
|
||||
// (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({
|
||||
subsets: ["latin"],
|
||||
@@ -34,7 +35,9 @@ const spaceGrotesk = Space_Grotesk({
|
||||
weight: ["400", "500", "700"],
|
||||
variable: "--font-space-grotesk",
|
||||
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"],
|
||||
});
|
||||
|
||||
@@ -116,15 +119,32 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<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
|
||||
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable}`}
|
||||
>
|
||||
<MessagesProvider messages={messages}>
|
||||
<AuthModalProvider>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<ConditionalNavigation />
|
||||
{children}
|
||||
</div>
|
||||
<div className="min-h-screen flex flex-col">{children}</div>
|
||||
</AuthModalProvider>
|
||||
</MessagesProvider>
|
||||
</body>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"tripleStep": {
|
||||
"heading": "Get recommendations that will make organizing easier",
|
||||
"ctaText": "Create Rule",
|
||||
"ctaHref": "/create",
|
||||
"ctaHref": "/create/informational",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Get your stakeholders together",
|
||||
@@ -68,7 +68,7 @@
|
||||
"tripleTextBlock": {
|
||||
"title": "Why Horizontal groups need CommunityRule",
|
||||
"ctaText": "Setup your community",
|
||||
"ctaHref": "/create",
|
||||
"ctaHref": "/create/informational",
|
||||
"columns": [
|
||||
{
|
||||
"title": "Share Leadership and Prevent Burnout",
|
||||
|
||||
+13
-1
@@ -29,7 +29,7 @@ const nextConfig = {
|
||||
// Image optimization
|
||||
images: {
|
||||
formats: ["image/webp", "image/avif"],
|
||||
minimumCacheTTL: 60,
|
||||
minimumCacheTTL: 31536000,
|
||||
dangerouslyAllowSVG: true,
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||
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 }) {
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('Top "Create rule" button', () => {
|
||||
* in-flight anonymous draft so the wizard always starts fresh. See
|
||||
* 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(
|
||||
CREATE_FLOW_ANONYMOUS_KEY,
|
||||
JSON.stringify({ title: "Stale community" }),
|
||||
@@ -93,7 +93,7 @@ describe('Top "Create rule" button', () => {
|
||||
await userEvent.click(btn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/create");
|
||||
expect(pushMock).toHaveBeenCalledWith("/create/informational");
|
||||
});
|
||||
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
|
||||
expect(
|
||||
|
||||
@@ -10,22 +10,24 @@ describe("CaseStudy", () => {
|
||||
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(
|
||||
<CaseStudy surface="neutral" imageAlt="Food Not Bombs logo" />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Food Not Bombs logo" }),
|
||||
).toHaveAttribute("src");
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses Mutual Aid vector on lavender surface", () => {
|
||||
const { container } = render(
|
||||
<CaseStudy surface="lavender" imageAlt="Mutual Aid Colorado logo" />,
|
||||
);
|
||||
expect(container.querySelector("img")?.getAttribute("src")).toContain(
|
||||
"case-study-mutual-aid.svg",
|
||||
);
|
||||
expect(
|
||||
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", () => {
|
||||
const tree = AppLayout({ children: <div>app-child</div> });
|
||||
expect(tree.type).toBe("main");
|
||||
expect(tree.props.className).toContain("flex-1");
|
||||
const main = findDescendant(
|
||||
tree,
|
||||
(n) => n?.type === "main" && n.props?.className?.includes("flex-1"),
|
||||
);
|
||||
expect(main).toBeTruthy();
|
||||
expect(
|
||||
findDescendant(tree, (n) => typeof n === "string" && n.includes("app-child")),
|
||||
findDescendant(main, (n) => typeof n === "string" && n.includes("app-child")),
|
||||
).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: {
|
||||
target: "node18",
|
||||
|
||||
Reference in New Issue
Block a user