diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..dfe17d7 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,9 @@ +# Gitea issue template config — see https://docs.gitea.com/usage/issue-pull-request-templates +blank_issues_enabled: false +contact_links: + - name: Open the staging site + url: https://staging.communityrule.info + about: Preview version of Community Rule, test here before reporting + - name: How to sign in (read first) + url: https://staging.communityrule.info/login + about: Use your email to recieve a link to click diff --git a/.gitea/ISSUE_TEMPLATE/feedback_or_suggestion.md b/.gitea/ISSUE_TEMPLATE/feedback_or_suggestion.md new file mode 100644 index 0000000..63da858 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feedback_or_suggestion.md @@ -0,0 +1,36 @@ +--- +name: "Feedback or suggestion" +about: "Something worked but was confusing, or you have an idea to improve it" +title: "[Staging feedback] " +--- + +Thank you for sharing your thoughts on the staging preview. **Clear, everyday language is exactly what we need.** + +### Before you submit + +- This form is for **ideas and usability feedback**, not broken pages — if something failed or errored, use **"Something isn't working"** instead. +- **One topic per report** keeps feedback easier to act on. + +--- + +## Where on the site? + +_Copy the page address from your browser, or describe where you were — e.g. "Create rule — choose methods step"_ + +## What is your feedback? + +_Tell us what felt confusing, missing, or could work better. What would make this easier for you or your community?_ + +## Why would this help? (optional) + +_Who benefits — e.g. first-time organizers, small groups, non-English speakers?_ + +## Screenshot or example (optional) + +_Drag an image here, or describe what you were looking at._ + +## Your browser and device (optional) + +_Examples: "Safari on iPad," "Chrome on Android phone"_ + +## Anything else? diff --git a/.gitea/ISSUE_TEMPLATE/something_isnt_working.md b/.gitea/ISSUE_TEMPLATE/something_isnt_working.md new file mode 100644 index 0000000..df3ae8c --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/something_isnt_working.md @@ -0,0 +1,67 @@ +--- +name: "Something isn't working" +about: "A page broke, a button didn't work, or you got stuck" +title: "[Staging] " +--- + +Thank you for helping us test Community Rule. **You do not need to be technical** — plain language is perfect. Answer what you can and skip anything you are not sure about. + +### Before you submit + +- **One report = one problem.** If you found several separate issues, please open a new report for each. +- **Check [existing reports](https://git.medlab.host/CommunityRule/community-rule/issues)** — someone may have already reported the same thing. +- **Never share your password** or paste the **sign-in link from your email** here (those are private to you). + +--- + +## Where were you on the site? + +_Copy the web address from your browser's address bar, or check the box that fits:_ + +- [ ] https://staging.communityrule.info (staging / preview site) +- [ ] https://communityrule.info (live site) +- [ ] Other: + +## What were you trying to do? + +_Examples: "Sign in with my email," "Create a new rule," "Upload a community photo," "Publish my rule"_ + +## What happened instead? + +_Describe what you saw. Did an error message appear? Did a button do nothing? Did the page look wrong?_ + +## What did you expect to happen? + +## Steps to get there (if you remember) + +_Numbered steps help us reproduce the problem — e.g. "1. Clicked Log in → 2. Entered my email → 3. …"_ + +1. +2. +3. + +## Does it happen every time? + +- [ ] Yes — it happens every time I try +- [ ] Sometimes — it only happened once or occasionally +- [ ] Not sure — I have not tried again + +## Your browser and device + +_Examples: "Safari on iPhone," "Chrome on a Windows laptop," "Firefox on Mac"_ + +## Screenshot (optional, very helpful) + +You can **drag an image into this text box** or use the **attachment** button below it. + +## Sign-in email (only if this is about logging in) + +_The email address you used. We will not ask for your password._ + +**Did you receive the sign-in email?** + +- [ ] Yes, but the link did not work +- [ ] No, nothing arrived (I checked spam/junk) +- [ ] Not applicable — this is not about signing in + +## Anything else we should know? diff --git a/.gitignore b/.gitignore index fc752b1..bf17aa0 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ npm-cache/ /lhci-results/ /.lighthouseci/ -# Ignore other image files (but not visual regression snapshots) +# Ignore other image files (but not visual regression snapshots or favicons) *.png *.jpg *.jpeg @@ -39,6 +39,11 @@ npm-cache/ *.avi *.mkv +# Root favicons (generated via `npm run generate:favicons`) +!public/favicon-16x16.png +!public/favicon-32x32.png +!public/apple-touch-icon.png + # Visual regression snapshots (allow these) !tests/e2e/visual-regression.spec.ts-snapshots/ !tests/e2e/visual-regression.spec.ts-snapshots/*.png diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx index 52df07c..62d58c3 100644 --- a/app/(admin)/layout.tsx +++ b/app/(admin)/layout.tsx @@ -1,7 +1,24 @@ -import type { ReactNode } from "react"; +import { Suspense, type ReactNode } from "react"; +import ConditionalNavigation from "../components/navigation/ConditionalNavigation"; +import { MessagesProvider } from "../contexts/MessagesContext"; +import { AuthModalProvider } from "../contexts/AuthModalContext"; +import messages from "../../messages/en/index"; +// `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16). +// See `(app)/layout.tsx` for the matching `` rationale +// — the fallback can't access `usePathname()` since it sits in the static shell. +// // 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
{children}
; + return ( + + + + + +
{children}
+
+
+ ); } diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 627d4ac..2af41dd 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -12,6 +12,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext"; import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation"; import { useCreateFlowExit } from "./hooks/useCreateFlowExit"; +import { usePrefetchMethodFacetRecommendations } from "./hooks/usePrefetchMethodFacetRecommendations"; import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize"; import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions"; import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport"; @@ -167,6 +168,7 @@ function CreateFlowLayoutContent({ replaceState, markCreateFlowInteraction, } = useCreateFlow(); + usePrefetchMethodFacetRecommendations(); const manageStakeholdersIntent = searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) === CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE; @@ -692,7 +694,7 @@ function CreateFlowLayoutContent({ }`.trim()} />
{children}
diff --git a/app/(app)/create/CreateFlowLayoutGate.tsx b/app/(app)/create/CreateFlowLayoutGate.tsx index 1cd755e..eb0e999 100644 --- a/app/(app)/create/CreateFlowLayoutGate.tsx +++ b/app/(app)/create/CreateFlowLayoutGate.tsx @@ -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 ( -
- ); -} - -const CreateFlowLayoutClient = dynamic( - () => import("./CreateFlowLayoutClient"), - { - ssr: false, - loading: () => , - }, -); +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, }: { diff --git a/app/(app)/create/SignedInDraftHydration.tsx b/app/(app)/create/SignedInDraftHydration.tsx index d1554a0..e5b1588 100644 --- a/app/(app)/create/SignedInDraftHydration.tsx +++ b/app/(app)/create/SignedInDraftHydration.tsx @@ -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(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; diff --git a/app/(app)/create/context/CreateFlowContext.tsx b/app/(app)/create/context/CreateFlowContext.tsx index d6cb111..8aba4a0 100644 --- a/app/(app)/create/context/CreateFlowContext.tsx +++ b/app/(app)/create/context/CreateFlowContext.tsx @@ -56,29 +56,49 @@ export function CreateFlowProvider({ initialStep = null, enableLocalDraftMirroring = false, }: CreateFlowProviderProps) { - const [state, setState] = useState(() => { - 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({}); const [interactionTouched, setInteractionTouched] = useState(false); const [currentStep] = useState(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 diff --git a/app/(app)/create/hooks/useFacetRecommendations.ts b/app/(app)/create/hooks/useFacetRecommendations.ts index 42b4efd..f476a14 100644 --- a/app/(app)/create/hooks/useFacetRecommendations.ts +++ b/app/(app)/create/hooks/useFacetRecommendations.ts @@ -1,8 +1,13 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString"; import type { MethodFacetApiSectionId } from "../../../../lib/create/customRuleFacets"; +import { + buildFacetRecommendationRequestKey, + getCachedFacetScores, + loadFacetScores, +} from "../../../../lib/create/facetRecommendationsLoad"; import { useCreateFlow } from "../context/CreateFlowContext"; /** @@ -25,6 +30,34 @@ export type FacetRecommendationsResult = { const EMPTY_SCORES: Record = {}; +function initialFacetRecommendationsResult( + section: RecommendationSection, + queryString: string, +): FacetRecommendationsResult { + const hasAnyFacets = queryString.length > 0; + if (!hasAnyFacets) { + return { + isReady: true, + scoresBySlug: EMPTY_SCORES, + hasAnyFacets: false, + }; + } + const requestKey = buildFacetRecommendationRequestKey(section, queryString); + const cached = getCachedFacetScores(requestKey); + if (cached) { + return { + isReady: true, + scoresBySlug: cached, + hasAnyFacets: true, + }; + } + return { + isReady: false, + scoresBySlug: EMPTY_SCORES, + hasAnyFacets: true, + }; +} + /** * Calls `GET /api/create-flow/methods?section=
&facet.*=...` for the * card-deck step `section` and returns a `slug → score` map for re-ranking @@ -46,14 +79,9 @@ export function useFacetRecommendations( ); const hasAnyFacets = queryString.length > 0; - const [result, setResult] = useState({ - isReady: !hasAnyFacets, - scoresBySlug: EMPTY_SCORES, - hasAnyFacets, - }); - - // Track the last successful request input so we don't re-fetch on every state poke. - const lastQueryRef = useRef(null); + const [result, setResult] = useState(() => + initialFacetRecommendationsResult(section, queryString), + ); useEffect(() => { if (!hasAnyFacets) { @@ -62,51 +90,34 @@ export function useFacetRecommendations( scoresBySlug: EMPTY_SCORES, hasAnyFacets: false, }); - lastQueryRef.current = null; return; } - const requestKey = `${section}?${queryString}`; - if (lastQueryRef.current === requestKey) return; - lastQueryRef.current = requestKey; - const ctrl = new AbortController(); - setResult((prev) => ({ ...prev, isReady: false, hasAnyFacets: true })); - fetch(`/api/create-flow/methods?section=${section}&${queryString}`, { - credentials: "include", - signal: ctrl.signal, - }) - .then(async (res) => { - if (!res.ok) throw new Error(`status ${res.status}`); - return (await res.json()) as { - methods?: { slug: string; matches?: { score?: number } }[]; - }; - }) - .then((json) => { - const scoresBySlug: Record = {}; - for (const m of json.methods ?? []) { - if (typeof m.slug === "string") { - scoresBySlug[m.slug] = m.matches?.score ?? 0; - } - } - setResult({ isReady: true, scoresBySlug, hasAnyFacets: true }); - }) - .catch((e) => { - if ((e as { name?: string }).name === "AbortError") return; - setResult({ - isReady: true, - scoresBySlug: EMPTY_SCORES, - hasAnyFacets: true, - }); + const requestKey = buildFacetRecommendationRequestKey(section, queryString); + const cached = getCachedFacetScores(requestKey); + if (cached) { + setResult({ + isReady: true, + scoresBySlug: cached, + hasAnyFacets: true, }); + return; + } + + let cancelled = false; + setResult((prev) => + prev.isReady && prev.hasAnyFacets + ? { ...prev, isReady: false } + : { isReady: false, scoresBySlug: EMPTY_SCORES, hasAnyFacets: true }, + ); + + void loadFacetScores(section, queryString).then((scoresBySlug) => { + if (cancelled) return; + setResult({ isReady: true, scoresBySlug, hasAnyFacets: true }); + }); return () => { - ctrl.abort(); - // Clear the dedup key so React 19 Strict Mode's mount → unmount → mount - // cycle (and any future remount) re-issues the request instead of - // returning early on the same key. - if (lastQueryRef.current === requestKey) { - lastQueryRef.current = null; - } + cancelled = true; }; }, [section, queryString, hasAnyFacets]); diff --git a/app/(app)/create/hooks/useMethodCardDeckOrdering.ts b/app/(app)/create/hooks/useMethodCardDeckOrdering.ts index 050616b..6b0010e 100644 --- a/app/(app)/create/hooks/useMethodCardDeckOrdering.ts +++ b/app/(app)/create/hooks/useMethodCardDeckOrdering.ts @@ -25,7 +25,9 @@ export function useMethodCardDeckOrdering( methods: readonly MethodEntry[], selectedIds: readonly string[], ) { - const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section); + const { scoresBySlug, hasAnyFacets, isReady } = + useFacetRecommendations(section); + const recommendationsReady = !hasAnyFacets || isReady; const rankedMethods = useMemo( () => rankMethodsByScore(methods, scoresBySlug), @@ -90,5 +92,6 @@ export function useMethodCardDeckOrdering( recommendedIds, sampleCards, methodById, + recommendationsReady, }; } diff --git a/app/(app)/create/hooks/usePrefetchMethodFacetRecommendations.ts b/app/(app)/create/hooks/usePrefetchMethodFacetRecommendations.ts new file mode 100644 index 0000000..a535260 --- /dev/null +++ b/app/(app)/create/hooks/usePrefetchMethodFacetRecommendations.ts @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect, useMemo } from "react"; +import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString"; +import { METHOD_FACET_API_SECTION_IDS } from "../../../../lib/create/customRuleFacets"; +import { loadFacetScores } from "../../../../lib/create/facetRecommendationsLoad"; +import { useCreateFlow } from "../context/CreateFlowContext"; + +/** + * Warms the facet recommendation cache for all method-deck sections once the + * user has community facet selections, so method screens can render ranked + * cards on first paint instead of flashing authoring order. + */ +export function usePrefetchMethodFacetRecommendations(): void { + const { state } = useCreateFlow(); + const queryString = useMemo( + () => buildFacetQueryString(state), + [state], + ); + + useEffect(() => { + if (queryString.length === 0) return; + for (const section of METHOD_FACET_API_SECTION_IDS) { + void loadFacetScores(section, queryString); + } + }, [queryString]); +} diff --git a/app/(app)/create/loading.tsx b/app/(app)/create/loading.tsx new file mode 100644 index 0000000..0c0a982 --- /dev/null +++ b/app/(app)/create/loading.tsx @@ -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 ( +
+
+
+
+ ); +} diff --git a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx index 1cd86a1..bb925d0 100644 --- a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx +++ b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx @@ -97,7 +97,8 @@ export function CommunicationMethodsScreen() { [comm.methods, selectedIds, state.customMethodCardMetaById], ); - const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( + const { sampleCards, compactCardIds, methodById, recommendationsReady } = + useMethodCardDeckOrdering( "communication", mergedMethods, selectedIds, @@ -735,23 +736,28 @@ export function CommunicationMethodsScreen() { justification="center" />
-
- { - markCreateFlowInteraction(); - setExpanded((prev) => !prev); - }} - hasMore={true} - toggleLabel={comm.page.seeAllLink} - compactRecommendedLimit={5} - compactCardIds={compactCardIds} - compactDesktopLayout="flexWrap" - headerLockupSize={mdUp ? "L" : "M"} - /> +
+ {recommendationsReady && ( + { + markCreateFlowInteraction(); + setExpanded((prev) => !prev); + }} + hasMore={true} + toggleLabel={comm.page.seeAllLink} + compactRecommendedLimit={5} + compactCardIds={compactCardIds} + compactDesktopLayout="flexWrap" + headerLockupSize={mdUp ? "L" : "M"} + /> + )}
diff --git a/app/(app)/create/screens/card/ConflictManagementScreen.tsx b/app/(app)/create/screens/card/ConflictManagementScreen.tsx index 30d2ab4..e352cd1 100644 --- a/app/(app)/create/screens/card/ConflictManagementScreen.tsx +++ b/app/(app)/create/screens/card/ConflictManagementScreen.tsx @@ -94,7 +94,8 @@ export function ConflictManagementScreen() { [cm.methods, selectedIds, state.customMethodCardMetaById], ); - const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( + const { sampleCards, compactCardIds, methodById, recommendationsReady } = + useMethodCardDeckOrdering( "conflictManagement", mergedMethods, selectedIds, @@ -734,23 +735,28 @@ export function ConflictManagementScreen() { justification="center" />
-
- { - markCreateFlowInteraction(); - setExpanded((prev) => !prev); - }} - hasMore={true} - toggleLabel={cm.page.seeAllLink} - compactRecommendedLimit={5} - compactCardIds={compactCardIds} - compactDesktopLayout="pyramidFive" - headerLockupSize={mdUp ? "L" : "M"} - /> +
+ {recommendationsReady && ( + { + markCreateFlowInteraction(); + setExpanded((prev) => !prev); + }} + hasMore={true} + toggleLabel={cm.page.seeAllLink} + compactRecommendedLimit={5} + compactCardIds={compactCardIds} + compactDesktopLayout="pyramidFive" + headerLockupSize={mdUp ? "L" : "M"} + /> + )}
diff --git a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx index 1afe6e3..4c603af 100644 --- a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx +++ b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx @@ -95,7 +95,8 @@ export function MembershipMethodsScreen() { [mem.methods, selectedIds, state.customMethodCardMetaById], ); - const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( + const { sampleCards, compactCardIds, methodById, recommendationsReady } = + useMethodCardDeckOrdering( "membership", mergedMethods, selectedIds, @@ -727,23 +728,28 @@ export function MembershipMethodsScreen() { justification="center" />
-
- { - markCreateFlowInteraction(); - setExpanded((prev) => !prev); - }} - hasMore={true} - toggleLabel={mem.page.seeAllLink} - compactRecommendedLimit={5} - compactCardIds={compactCardIds} - compactDesktopLayout="pyramidFive" - headerLockupSize={mdUp ? "L" : "M"} - /> +
+ {recommendationsReady && ( + { + markCreateFlowInteraction(); + setExpanded((prev) => !prev); + }} + hasMore={true} + toggleLabel={mem.page.seeAllLink} + compactRecommendedLimit={5} + compactCardIds={compactCardIds} + compactDesktopLayout="pyramidFive" + headerLockupSize={mdUp ? "L" : "M"} + /> + )}
diff --git a/app/(app)/create/screens/createFlowScreenComponents.tsx b/app/(app)/create/screens/createFlowScreenComponents.tsx index 581ca0f..40a58f9 100644 --- a/app/(app)/create/screens/createFlowScreenComponents.tsx +++ b/app/(app)/create/screens/createFlowScreenComponents.tsx @@ -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) { diff --git a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx index 86db7b7..db629be 100644 --- a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx +++ b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx @@ -108,7 +108,8 @@ export function DecisionApproachesScreen() { [da.methods, selectedIds, state.customMethodCardMetaById], ); - const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( + const { sampleCards, compactCardIds, methodById, recommendationsReady } = + useMethodCardDeckOrdering( "decisionApproaches", mergedMethods, selectedIds, @@ -761,36 +762,41 @@ export function DecisionApproachesScreen() { } > -
- - {da.cardStack.expandedStackDescriptionBefore} - - {da.sidebar.descriptionLinkLabel} - - {da.cardStack.expandedStackDescriptionAfter} - - ) : ( - "" - ) - } - layout="singleStack" - compactRecommendedLimit={5} - compactCardIds={compactCardIds} - className="w-full" - headerLockupSize={mdUp ? "L" : "M"} - /> +
+ {recommendationsReady && ( + + {da.cardStack.expandedStackDescriptionBefore} + + {da.sidebar.descriptionLinkLabel} + + {da.cardStack.expandedStackDescriptionAfter} + + ) : ( + "" + ) + } + layout="singleStack" + compactRecommendedLimit={5} + compactCardIds={compactCardIds} + className="w-full" + headerLockupSize={mdUp ? "L" : "M"} + /> + )}
{ +export function prepareFreshCreateFlowEntrySync( + options: PrepareFreshCreateFlowEntryOptions = {}, +): void { + const signedIn = options.signedIn === true; clearAnonymousCreateFlowStorage(); clearCoreValueDetailsLocalStorage(); - if (isBackendSyncEnabled()) { + clearServerDraftWhenSignedIn(signedIn); +} + +/** + * 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( + options: PrepareFreshCreateFlowEntryOptions = {}, +): Promise { + const signedIn = options.signedIn === true; + clearAnonymousCreateFlowStorage(); + clearCoreValueDetailsLocalStorage(); + if (!signedIn || !isBackendSyncEnabled()) return; + setFreshEntryPending(); + try { await deleteServerDraft(); + } finally { + clearFreshEntryPending(); } } diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 0ccbb16..8797597 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,8 +1,31 @@ -import type { ReactNode } from "react"; +import { Suspense, type ReactNode } from "react"; +import ConditionalNavigation from "../components/navigation/ConditionalNavigation"; +import { MessagesProvider } from "../contexts/MessagesContext"; +import { AuthModalProvider } from "../contexts/AuthModalContext"; +import messages from "../../messages/en/index"; +// `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16). +// `ConditionalNavigation` reads `cr_session` server-side (and `usePathname()` +// transitively in `ConditionalNavigationClient`) — both are uncached, so it +// lives behind a `` boundary so the rest of the layout stays in the +// static shell while the session/pathname-aware nav streams in. The fallback +// is `null` because any non-null fallback would also need to live in the +// static shell, and the nav's chromeless decision depends on the pathname +// (e.g. `/create/*` and `/login` render no top-nav). Brief blank-nav while +// the dynamic island resolves is acceptable on signed-in product surfaces. +// // 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
{children}
; + return ( + + + + + +
{children}
+
+
+ ); } diff --git a/app/(app)/profile/ProfilePageClient.tsx b/app/(app)/profile/ProfilePageClient.tsx index 1631a69..765eb58 100644 --- a/app/(app)/profile/ProfilePageClient.tsx +++ b/app/(app)/profile/ProfilePageClient.tsx @@ -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({ signedIn: true }); + router.push("/create/informational"); }, [router]); const handleRequestDeleteDraft = useCallback(() => { diff --git a/app/(dev)/layout.tsx b/app/(dev)/layout.tsx index e67fa09..699928e 100644 --- a/app/(dev)/layout.tsx +++ b/app/(dev)/layout.tsx @@ -1,10 +1,19 @@ import type { ReactNode } from "react"; import { notFound } from "next/navigation"; +import { MessagesProvider } from "../contexts/MessagesContext"; +import { AuthModalProvider } from "../contexts/AuthModalContext"; +import messages from "../../messages/en/index"; // Development-only previews (e.g. `/components-preview`) — no public chrome. export default function DevLayout({ children }: { children: ReactNode }) { if (process.env.NODE_ENV === "production") { notFound(); } - return
{children}
; + return ( + + +
{children}
+
+
+ ); } diff --git a/app/(marketing)/blog/[slug]/page.tsx b/app/(marketing)/blog/[slug]/page.tsx index 00584fb..1ce8706 100644 --- a/app/(marketing)/blog/[slug]/page.tsx +++ b/app/(marketing)/blog/[slug]/page.tsx @@ -126,7 +126,7 @@ export default async function BlogPostPage({ params }: PageProps) { headline: post.frontmatter.title, description: post.frontmatter.description, author: { - "@type": "Person", + "@type": "Organization", name: post.frontmatter.author, }, publisher: { diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx index bd29e83..48b387c 100644 --- a/app/(marketing)/layout.tsx +++ b/app/(marketing)/layout.tsx @@ -1,5 +1,9 @@ import dynamic from "next/dynamic"; -import type { ReactNode } from "react"; +import { Suspense, type ReactNode } from "react"; +import MarketingNavigation from "../components/navigation/MarketingNavigation"; +import { MessagesProvider } from "../contexts/MessagesContext"; +import { AuthModalProvider } from "../contexts/AuthModalContext"; +import marketingMessages from "../../messages/en/marketing"; // Site footer is part of the public marketing chrome only — not rendered for // signed-in product surfaces, admin dashboards, or dev previews. See @@ -13,9 +17,19 @@ const Footer = dynamic(() => import("../components/navigation/Footer"), { export default function MarketingLayout({ children }: { children: ReactNode }) { return ( - <> -
{children}
-