diff --git a/.gitignore b/.gitignore index 5c074bb..fc752b1 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ npm-cache/ /build # misc +/tmp/ *.pem # IDE and editor files diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 729d37f..587556f 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -44,6 +44,7 @@ import { } from "./utils/createFlowScreenRegistry"; import Button from "../../components/buttons/Button"; import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail"; +import { buildCreateFlowDraftPayload } from "../../../lib/create/buildCreateFlowDraftPayload"; import { fetchAuthSession, requestMagicLink, @@ -52,6 +53,7 @@ import { safeInternalPath } from "../../../lib/safeInternalPath"; import { clearAnonymousCreateFlowStorage, setTransferPendingFlag, + writeAnonymousCreateFlowState, } from "./utils/anonymousDraftStorage"; import { createFlowStateFromPublishedRule, @@ -396,7 +398,15 @@ function CreateFlowLayoutContent({ const segment = stepAfterSave ?? "review"; const rawNext = `/create/${segment}?syncDraft=1`; const nextPath = safeInternalPath(rawNext); - const result = await requestMagicLink(trimmed, nextPath); + const draftPayload = buildCreateFlowDraftPayload(state, currentStep); + writeAnonymousCreateFlowState({ + ...draftPayload, + communitySaveEmail: trimmed, + }); + const result = await requestMagicLink(trimmed, nextPath, { + ...draftPayload, + communitySaveEmail: trimmed, + }); if (result.ok === false) { if (result.retryAfterMs != null && result.retryAfterMs > 0) { const seconds = Math.ceil(result.retryAfterMs / 1000); @@ -418,7 +428,7 @@ function CreateFlowLayoutContent({ } finally { setCommunitySaveMagicLinkSubmitting(false); } - }, [state.communitySaveEmail, tLogin, updateState]); + }, [state, currentStep, tLogin, updateState]); const isCompletedStep = currentStep === "completed"; const isRightRailStep = currentStep === "decision-approaches"; diff --git a/app/(app)/create/PostLoginDraftTransfer.tsx b/app/(app)/create/PostLoginDraftTransfer.tsx index 178469e..6c43f7a 100644 --- a/app/(app)/create/PostLoginDraftTransfer.tsx +++ b/app/(app)/create/PostLoginDraftTransfer.tsx @@ -9,16 +9,55 @@ import { } from "./utils/anonymousDraftStorage"; import { useCreateFlow } from "./context/CreateFlowContext"; import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps"; -import { saveDraftToServer } from "../../../lib/create/api"; +import { fetchDraftFromServer, saveDraftToServer } from "../../../lib/create/api"; +import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils"; +import type { CreateFlowState } from "./types"; import messages from "../../../messages/en/index"; import Alert from "../../components/modals/Alert"; const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true"; +function buildPayloadWithStep( + base: CreateFlowState, + pathname: string | null, +): CreateFlowState { + const step = + parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined; + return { + ...base, + ...(step ? { currentStep: step } : {}), + }; +} + +/** + * Prefer the on-device anonymous mirror when present; otherwise use the draft + * stored on the magic-link token at request time (written during verify). + */ +async function resolvePostLoginDraftPayload( + local: CreateFlowState, + pathname: string | null, +): Promise { + const localPayload = createFlowStateHasKeys(local) + ? buildPayloadWithStep(local, pathname) + : null; + + const serverDraft = await fetchDraftFromServer(); + const serverPayload = + serverDraft != null && createFlowStateHasKeys(serverDraft) + ? buildPayloadWithStep(serverDraft, pathname) + : null; + + if (localPayload && serverPayload) { + return { ...serverPayload, ...localPayload }; + } + return localPayload ?? serverPayload; +} + /** * After magic-link verify, redirects to `/create/...?syncDraft=1` with session cookie. - * With backend sync: PUT draft once then hydrates context. Without sync: hydrates from - * `create-flow-anonymous` localStorage only (no server write). + * With backend sync: PUT draft once when the device mirror is non-empty, then hydrates + * context. Without sync: hydrates from localStorage and/or the server draft saved at + * verify. Never writes an empty payload over an existing server draft. */ export function PostLoginDraftTransfer({ sessionUser, @@ -39,49 +78,6 @@ export function PostLoginDraftTransfer({ if (!wantsTransfer) return; if (attemptedRef.current) return; - if (!SYNC_ENABLED) { - attemptedRef.current = true; - let cancelled = false; - void (async () => { - const local = readAnonymousCreateFlowState(); - const pending = hasTransferPendingFlag(); - - if (Object.keys(local).length === 0 && !pending) { - const params = new URLSearchParams(searchParams.toString()); - params.delete("syncDraft"); - const q = params.toString(); - if (pathname) { - router.replace(q ? `${pathname}?${q}` : pathname); - } - attemptedRef.current = false; - return; - } - - const step = - parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined; - const payload = { - ...local, - ...(step ? { currentStep: step } : {}), - }; - - if (cancelled) return; - clearAnonymousCreateFlowStorage(); - replaceState(payload); - - if (cancelled) return; - if (pathname) { - const params = new URLSearchParams(searchParams.toString()); - params.delete("syncDraft"); - const q = params.toString(); - router.replace(q ? `${pathname}?${q}` : pathname); - } - })(); - - return () => { - cancelled = true; - }; - } - attemptedRef.current = true; let cancelled = false; @@ -90,7 +86,7 @@ export function PostLoginDraftTransfer({ const local = readAnonymousCreateFlowState(); const pending = hasTransferPendingFlag(); - if (Object.keys(local).length === 0 && !pending) { + if (!createFlowStateHasKeys(local) && !pending) { const params = new URLSearchParams(searchParams.toString()); params.delete("syncDraft"); const q = params.toString(); @@ -101,27 +97,36 @@ export function PostLoginDraftTransfer({ return; } - const step = - parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined; - const payload = { - ...local, - ...(step ? { currentStep: step } : {}), - }; - - const saveResult = await saveDraftToServer(payload); + const payload = await resolvePostLoginDraftPayload(local, pathname); if (cancelled) return; - if (saveResult.ok === false) { - setTransferError( - messages.create.topNav.postLoginSaveFailedWithReason.replace( - "{reason}", - saveResult.message, - ), - ); + if (payload == null || !createFlowStateHasKeys(payload)) { + const params = new URLSearchParams(searchParams.toString()); + params.delete("syncDraft"); + const q = params.toString(); + if (pathname) { + router.replace(q ? `${pathname}?${q}` : pathname); + } attemptedRef.current = false; return; } + if (SYNC_ENABLED && createFlowStateHasKeys(local)) { + const saveResult = await saveDraftToServer(payload); + if (cancelled) return; + + if (saveResult.ok === false) { + setTransferError( + messages.create.topNav.postLoginSaveFailedWithReason.replace( + "{reason}", + saveResult.message, + ), + ); + attemptedRef.current = false; + return; + } + } + clearAnonymousCreateFlowStorage(); replaceState(payload); diff --git a/app/(app)/create/SignedInDraftHydration.tsx b/app/(app)/create/SignedInDraftHydration.tsx index 69e34f0..bed4ce3 100644 --- a/app/(app)/create/SignedInDraftHydration.tsx +++ b/app/(app)/create/SignedInDraftHydration.tsx @@ -65,7 +65,6 @@ export function SignedInDraftHydration({ if (finishedUserIdRef.current === userId) return; if (syncDraftParam === "1" || hasTransferPendingFlag()) { - finishedUserIdRef.current = userId; return; } diff --git a/app/(app)/create/components/FinalReviewTitleEditModal.tsx b/app/(app)/create/components/FinalReviewTitleEditModal.tsx new file mode 100644 index 0000000..d12c830 --- /dev/null +++ b/app/(app)/create/components/FinalReviewTitleEditModal.tsx @@ -0,0 +1,109 @@ +"use client"; + +/** + * Edit published rule: community name with the same 48-char limit as + * {@link CreateFlowTextFieldScreen} `community-name` step. + */ + +import { useEffect, useMemo, useRef, useState } from "react"; +import Create from "../../../components/modals/Create"; +import TextInput from "../../../components/controls/TextInput"; +import ContentLockup from "../../../components/type/ContentLockup"; +import { useTranslation } from "../../../contexts/MessagesContext"; + +/** Matches `community-name` step (`CreateFlowTextFieldScreen` `maxLength={48}`). */ +export const COMMUNITY_TITLE_FIELD_MAX_LENGTH = 48; + +export interface FinalReviewTitleEditModalProps { + isOpen: boolean; + onClose: () => void; + initialValue: string; + onSave: (_value: string) => void; +} + +export function FinalReviewTitleEditModal({ + isOpen, + onClose, + initialValue, + onSave, +}: FinalReviewTitleEditModalProps) { + const tModal = useTranslation( + "create.reviewAndComplete.finalReview.titleEditModal", + ); + const tField = useTranslation("create.community.communityName"); + const tSave = useTranslation( + "create.reviewAndComplete.finalReview.chipEditModal", + ); + + const [draft, setDraft] = useState(""); + const initialRef = useRef(""); + const seededOpenRef = useRef(false); + + useEffect(() => { + if (!isOpen) { + seededOpenRef.current = false; + return; + } + if (seededOpenRef.current) return; + seededOpenRef.current = true; + const seed = initialValue; + setDraft(seed); + initialRef.current = seed; + }, [isOpen, initialValue]); + + const isDirty = useMemo(() => draft !== initialRef.current, [draft]); + + const trimmedDraft = draft.trim(); + const canSave = isDirty && trimmedDraft.length > 0; + + const characterHint = tField("characterCountTemplate") + .replace("{current}", String(draft.length)) + .replace("{max}", String(COMMUNITY_TITLE_FIELD_MAX_LENGTH)); + + const handleSave = () => { + if (!canSave) return; + const capped = trimmedDraft.slice(0, COMMUNITY_TITLE_FIELD_MAX_LENGTH); + onSave(capped); + onClose(); + }; + + return ( + + + + } + showBackButton={false} + showNextButton + nextButtonText={tSave("saveButton")} + nextButtonDisabled={!canSave} + onNext={handleSave} + ariaLabel={tModal("title")} + > +
+ { + setDraft(e.target.value); + }} + inputSize="medium" + formHeader={false} + textHint={characterHint} + maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH} + /> +
+
+ ); +} diff --git a/app/(app)/create/context/CreateFlowContext.tsx b/app/(app)/create/context/CreateFlowContext.tsx index 37169fe..d6cb111 100644 --- a/app/(app)/create/context/CreateFlowContext.tsx +++ b/app/(app)/create/context/CreateFlowContext.tsx @@ -18,6 +18,7 @@ import type { import { clearAnonymousCreateFlowStorage, clearLegacyCreateFlowKeysOnce, + hasTransferPendingFlag, readAnonymousCreateFlowState, writeAnonymousCreateFlowState, } from "../utils/anonymousDraftStorage"; @@ -94,6 +95,13 @@ export function CreateFlowProvider({ const wasOff = !prevPersistRef.current; prevPersistRef.current = true; if (!wasOff) return; + if (hasTransferPendingFlag()) return; + if ( + typeof window !== "undefined" && + new URLSearchParams(window.location.search).get("syncDraft") === "1" + ) { + return; + } const from = readAnonymousCreateFlowState(); if (Object.keys(from).length === 0) return; // eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate local draft when mirroring turns on diff --git a/app/(app)/create/hooks/useCreateFlowSm2Up.ts b/app/(app)/create/hooks/useCreateFlowSm2Up.ts new file mode 100644 index 0000000..ce5da76 --- /dev/null +++ b/app/(app)/create/hooks/useCreateFlowSm2Up.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useMediaQuery } from "../../../hooks/useMediaQuery"; + +/** `--breakpoint-sm2` (440px); pairs with Tailwind `sm2:` on create-flow chrome. */ +const CREATE_FLOW_MIN_WIDTH_SM2 = "(min-width: 440px)"; + +/** True at viewport ≥440px. */ +export function useCreateFlowSm2Up(): boolean { + const [isMounted, setIsMounted] = useState(false); + const isSm2OrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_SM2); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- defer until mount for SSR/first-paint alignment + setIsMounted(true); + }, []); + + return !isMounted || isSm2OrLarger; +} diff --git a/app/(app)/create/screens/completed/CompletedScreen.tsx b/app/(app)/create/screens/completed/CompletedScreen.tsx index 74bc05f..cb96331 100644 --- a/app/(app)/create/screens/completed/CompletedScreen.tsx +++ b/app/(app)/create/screens/completed/CompletedScreen.tsx @@ -163,10 +163,10 @@ export function CompletedScreen() { <>
(null); const [communityContextModalOpen, setCommunityContextModalOpen] = useState(false); + const [titleModalOpen, setTitleModalOpen] = useState(false); const handleSave = useCallback( (patch: FinalReviewChipEditPatch) => { @@ -225,6 +227,9 @@ export function FinalReviewScreen({ const rawCommunityContextForModal = typeof state.communityContext === "string" ? state.communityContext : ""; + const rawTitleForModal = + typeof state.title === "string" ? state.title : ""; + const descriptionEmptyHint = variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined; @@ -242,6 +247,16 @@ export function FinalReviewScreen({ setTitleModalOpen(true) + : undefined + } + titleEditAriaLabel={ + variant === "editPublished" + ? t("titleEditModal.ariaEditTitle") + : undefined + } onDescriptionClick={ variant === "editPublished" ? () => setCommunityContextModalOpen(true) @@ -278,15 +293,26 @@ export function FinalReviewScreen({ detail={activeReadOnlyDetail} /> {variant === "editPublished" ? ( - setCommunityContextModalOpen(false)} - initialValue={rawCommunityContextForModal} - onSave={(value) => { - markCreateFlowInteraction(); - updateState({ communityContext: value, summary: value }); - }} - /> + <> + setTitleModalOpen(false)} + initialValue={rawTitleForModal} + onSave={(value) => { + markCreateFlowInteraction(); + updateState({ title: value }); + }} + /> + setCommunityContextModalOpen(false)} + initialValue={rawCommunityContextForModal} + onSave={(value) => { + markCreateFlowInteraction(); + updateState({ communityContext: value, summary: value }); + }} + /> + ) : null} ); diff --git a/app/(marketing)/_components/MarketingRuleStackSection.tsx b/app/(marketing)/_components/MarketingRuleStackSection.tsx index fb372bb..14f55a7 100644 --- a/app/(marketing)/_components/MarketingRuleStackSection.tsx +++ b/app/(marketing)/_components/MarketingRuleStackSection.tsx @@ -10,14 +10,28 @@ const RuleStack = dynamic(() => import("../../components/sections/RuleStack"), { ssr: true, }); +type MarketingRuleStackSectionProps = { + translationNamespace?: string; + twoColumnsFromMd?: boolean; +}; + /** * Server-loaded “Popular templates” row so the first paint has card data without a client fetch. */ -export async function MarketingRuleStackSection() { +export async function MarketingRuleStackSection({ + translationNamespace, + twoColumnsFromMd, +}: MarketingRuleStackSectionProps = {}) { const rows = await listRuleTemplatesFromDb(); const initialGridEntries = gridEntriesForSlugOrderWithCatalogFallback( rows, GOVERNANCE_TEMPLATE_HOME_SLUGS, ); - return ; + return ( + + ); } diff --git a/app/(marketing)/about/page.tsx b/app/(marketing)/about/page.tsx new file mode 100644 index 0000000..57ab556 --- /dev/null +++ b/app/(marketing)/about/page.tsx @@ -0,0 +1,73 @@ +import messages from "../../../messages/en/index"; +import { getTranslation } from "../../../lib/i18n/getTranslation"; +import AboutHeader from "../../components/type/AboutHeader"; +import type { AboutHeaderSegment } from "../../components/type/AboutHeader"; +import Stats from "../../components/sections/Stats"; +import type { StatItem } from "../../components/sections/Stats"; +import TripleTextBlock from "../../components/type/TripleTextBlock"; +import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock"; +import Book from "../../components/sections/Book"; +import FaqAccordion from "../../components/sections/Accordion"; +import type { FaqAccordionItem } from "../../components/sections/Accordion"; +import QuoteBlock from "../../components/sections/QuoteBlock"; +import AskOrganizer from "../../components/sections/AskOrganizer"; + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? value : []; +} + +export default function AboutPage() { + const t = (key: string) => getTranslation(messages, key); + + const page = messages.pages.about; + + const headerSegments = asArray(page.aboutHeader.segments); + const statsItems = asArray(page.stats.items); + + const statsAsOf = + typeof page.stats.asOf === "string" + ? page.stats.asOf + : String(page.stats.asOf ?? ""); + + const faqItems = asArray(page.faq.items); + const tripleColumns = asArray(page.tripleTextBlock.columns); + + const askOrganizerData = { + title: t("pages.home.askOrganizer.title"), + subtitle: t("pages.home.askOrganizer.subtitle"), + buttonText: t("pages.home.askOrganizer.buttonText"), + }; + + return ( +
+ + ({ + ...item, + asOf: statsAsOf, + }))} + /> + + + + +
+ +
+
+ ); +} diff --git a/app/(marketing)/blog/[slug]/page.tsx b/app/(marketing)/blog/[slug]/page.tsx index b1339c3..2b1ca9f 100644 --- a/app/(marketing)/blog/[slug]/page.tsx +++ b/app/(marketing)/blog/[slug]/page.tsx @@ -1,10 +1,11 @@ import { notFound } from "next/navigation"; import type { Metadata } from "next"; import dynamic from "next/dynamic"; +import type { BlogPost } from "../../../../lib/content"; import { getBlogPostBySlug, getAllBlogPosts as getAllPosts, - type BlogPost, + getRelatedBlogPosts, } from "../../../../lib/content"; import { logger } from "../../../../lib/logger"; import ContentBanner from "../../../components/sections/ContentBanner"; @@ -111,66 +112,12 @@ export default async function BlogPostPage({ params }: PageProps) { // Get related articles with improved algorithm const allPosts = getAllPosts(); - - // Create slug order for consistent background cycling const slugOrder = allPosts.map((post) => post.slug); - - // Simple related articles algorithm based on content similarity - const getRelatedArticles = ( - currentPost: BlogPost, - allPosts: BlogPost[], - limit = 3, - ): BlogPost[] => { - const otherPosts = allPosts.filter((p) => p.slug !== currentPost.slug); - - // Score posts based on content similarity - const scoredPosts = otherPosts.map((post) => { - let score = 0; - - // Check for similar keywords in title and description - const currentTitle = currentPost.frontmatter.title.toLowerCase(); - const currentDesc = currentPost.frontmatter.description.toLowerCase(); - const postTitle = post.frontmatter.title.toLowerCase(); - const postDesc = post.frontmatter.description.toLowerCase(); - - // Common keywords that indicate similarity - const keywords = [ - "community", - "conflict", - "decision", - "governance", - "security", - "trust", - "collaboration", - "organization", - ]; - - keywords.forEach((keyword) => { - if (currentTitle.includes(keyword) && postTitle.includes(keyword)) - score += 3; - if (currentDesc.includes(keyword) && postDesc.includes(keyword)) - score += 2; - if (currentTitle.includes(keyword) && postDesc.includes(keyword)) - score += 1; - if (currentDesc.includes(keyword) && postTitle.includes(keyword)) - score += 1; - }); - - return { ...post, score }; - }); - - // Sort by score and return top posts - return scoredPosts - .sort((a, b) => b.score - a.score) - .slice(0, limit) - .map(({ score, ...post }) => { - // Score used for sorting, removed from final result - void score; - return post; - }); - }; - - const relatedArticles = getRelatedArticles(post, allPosts); + const relatedArticles = getRelatedBlogPosts( + post.slug, + post.frontmatter.related, + 3, + ); // Generate structured data for search engines const structuredData = { @@ -255,7 +202,7 @@ export default async function BlogPostPage({ params }: PageProps) { />
{/* Content Banner */} @@ -296,10 +243,16 @@ export default async function BlogPostPage({ params }: PageProps) { />
- {/* Main Content */} -
- {/* Article Content */} -
+ {/* Main Content — Figma Content page Template (19003:23305) article body instances */} +
+
diff --git a/app/(marketing)/blog/blog.css b/app/(marketing)/blog/blog.css index 60e5fad..e5c7fd3 100644 --- a/app/(marketing)/blog/blog.css +++ b/app/(marketing)/blog/blog.css @@ -1,4 +1,8 @@ /* Blog post body styling with semantic spacing */ +.post-body > :first-child { + margin-block-start: 0; +} + .post-body p { /* Scales with font size - uses logical properties for better writing mode support */ margin-block: 1em; diff --git a/app/(marketing)/how-it-works/_components/HowItWorksDecorativeShapes.tsx b/app/(marketing)/how-it-works/_components/HowItWorksDecorativeShapes.tsx new file mode 100644 index 0000000..be24187 --- /dev/null +++ b/app/(marketing)/how-it-works/_components/HowItWorksDecorativeShapes.tsx @@ -0,0 +1,43 @@ +/** + * Figma: "A Guide to CommunityRule" body ornaments (22078:791901) + * https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22078-791901 + * + * - 19003:23575 — concentric circles, right (`how-shape-2.svg`) + * - 19003:23576 — loop mark, left (`how-shape-1.svg`) + */ +import { + getAssetPath, + howItWorksOrnamentLeftPath, + howItWorksOrnamentRightPath, +} from "../../../../lib/assetUtils"; + +export default function HowItWorksDecorativeShapes() { + return ( + <> +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + ); +} diff --git a/app/(marketing)/how-it-works/page.tsx b/app/(marketing)/how-it-works/page.tsx new file mode 100644 index 0000000..b565907 --- /dev/null +++ b/app/(marketing)/how-it-works/page.tsx @@ -0,0 +1,132 @@ +/** + * Figma: "How Community Rule works" (22078:806964) + * https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22078-806964 + */ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import messages from "../../../messages/en/index"; +import { getAllBlogPosts } from "../../../lib/content"; +import { + buildHowItWorksSyntheticPost, + HOW_IT_WORKS_SENTINEL_SLUG, +} from "../../../lib/howItWorksSyntheticPost"; +import ContentBanner from "../../components/sections/ContentBanner"; +import HowItWorksDecorativeShapes from "./_components/HowItWorksDecorativeShapes"; +import AskOrganizer from "../../components/sections/AskOrganizer"; +import "../blog/blog.css"; + +const RelatedArticles = dynamic( + () => import("../../components/sections/RelatedArticles"), + { + loading: () => ( +
+ ), + ssr: true, + }, +); + +export async function generateMetadata(): Promise { + const meta = messages.metadata.howItWorks; + const page = messages.pages.howItWorks; + + return { + title: meta.title, + description: meta.description, + keywords: meta.keywords, + openGraph: { + title: page.banner.title, + description: page.banner.description, + type: "website", + siteName: "CommunityRule", + }, + }; +} + +export default function HowItWorksPage() { + const page = messages.pages.howItWorks; + const syntheticPost = buildHowItWorksSyntheticPost(page); + + const allPosts = getAllBlogPosts(); + const relatedPosts = allPosts.slice(0, 8); + const slugOrder = allPosts.map((post) => post.slug); + + const askOrganizerData = { + title: messages.pages.home.askOrganizer.title, + subtitle: messages.pages.home.askOrganizer.subtitle, + buttonText: messages.pages.home.askOrganizer.buttonText, + }; + + const structuredData = { + "@context": "https://schema.org", + "@type": "WebPage", + name: page.banner.title, + description: page.banner.description, + url: "https://communityrule.com/how-it-works", + publisher: { + "@type": "Organization", + name: "CommunityRule", + url: "https://communityrule.com", + }, + }; + + const breadcrumbData = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: "Home", + item: "https://communityrule.com", + }, + { + "@type": "ListItem", + position: 2, + name: page.banner.title, + item: "https://communityrule.com/how-it-works", + }, + ], + }; + + return ( + <> +