Implement how it works page
This commit is contained in:
@@ -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 (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute z-0 hidden aspect-square md:block left-[84.86%] right-[-9.28%] top-[clamp(200px,20vw,255px)]"
|
||||
data-node-id="19003:23575"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getAssetPath(howItWorksOrnamentRightPath())}
|
||||
alt=""
|
||||
className="pointer-events-none size-full max-w-none object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute z-0 hidden aspect-square md:block left-[-1.66%] right-[88.38%] top-[clamp(520px,55vw,811px)]"
|
||||
data-node-id="19003:23576"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getAssetPath(howItWorksOrnamentLeftPath())}
|
||||
alt=""
|
||||
className="pointer-events-none size-full max-w-none object-cover"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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: () => (
|
||||
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
|
||||
),
|
||||
ssr: true,
|
||||
},
|
||||
);
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(structuredData),
|
||||
}}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(breadcrumbData),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative min-h-screen overflow-x-hidden bg-transparent">
|
||||
<ContentBanner post={syntheticPost} variant="guide" />
|
||||
|
||||
<div className="relative w-full">
|
||||
<HowItWorksDecorativeShapes />
|
||||
|
||||
<article className="relative z-10 p-[var(--spacing-scale-024)] sm:py-[var(--spacing-scale-032)]">
|
||||
<div
|
||||
className="post-body -mt-[var(--spacing-scale-048)] text-[var(--color-content-default-primary)] text-[16px] leading-[24px] sm:text-[18px] sm:leading-[130%] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] sm:mx-auto sm:max-w-[390px] md:max-w-[472px] lg:max-w-[700px] xl:max-w-[904px]"
|
||||
dangerouslySetInnerHTML={{ __html: syntheticPost.htmlContent }}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<RelatedArticles
|
||||
relatedPosts={relatedPosts}
|
||||
currentPostSlug={HOW_IT_WORKS_SENTINEL_SLUG}
|
||||
slugOrder={slugOrder}
|
||||
headingSurface="onLight"
|
||||
heading={page.relatedArticles.title}
|
||||
/>
|
||||
|
||||
<AskOrganizer {...askOrganizerData} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -76,6 +76,7 @@ export default function Page() {
|
||||
iconColor: "orange",
|
||||
},
|
||||
],
|
||||
seeHowItWorksHref: t("cardSteps.buttons.seeHowItWorksHref"),
|
||||
};
|
||||
|
||||
const featureGridData = {
|
||||
|
||||
@@ -6,7 +6,13 @@ import ContentContainerView from "./ContentContainer.view";
|
||||
import type { ContentContainerProps } from "./ContentContainer.types";
|
||||
|
||||
const ContentContainerContainer = memo<ContentContainerProps>(
|
||||
({ post, width = "200px", size: sizeProp = "responsive" }) => {
|
||||
({
|
||||
post,
|
||||
width = "200px",
|
||||
size: sizeProp = "responsive",
|
||||
leadingImageSrc,
|
||||
leadingImageAlt,
|
||||
}) => {
|
||||
const size = sizeProp;
|
||||
// Get the corresponding icon based on the same logic as background images
|
||||
const getIconImage = (slug: string): string => {
|
||||
@@ -30,7 +36,9 @@ const ContentContainerContainer = memo<ContentContainerProps>(
|
||||
return icons[finalIndex];
|
||||
};
|
||||
|
||||
const iconImage = getIconImage(post.slug);
|
||||
const iconImage = leadingImageSrc ?? getIconImage(post.slug);
|
||||
const iconAlt =
|
||||
leadingImageAlt ?? `Icon for ${post.frontmatter.title}`;
|
||||
|
||||
// Format date
|
||||
const formattedDate = new Date(post.frontmatter.date).toLocaleDateString(
|
||||
@@ -83,6 +91,7 @@ const ContentContainerContainer = memo<ContentContainerProps>(
|
||||
width={width}
|
||||
size={size}
|
||||
iconImage={iconImage}
|
||||
iconAlt={iconAlt}
|
||||
containerClasses={containerClasses}
|
||||
contentGapClasses={contentGapClasses}
|
||||
textGapClasses={textGapClasses}
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface ContentContainerProps {
|
||||
* Content container size.
|
||||
*/
|
||||
size?: ContentContainerSizeValue;
|
||||
/** When set, replaces the default slug-based thumbnail icon. */
|
||||
leadingImageSrc?: string;
|
||||
/** Alt text for `leadingImageSrc`; defaults to post title. */
|
||||
leadingImageAlt?: string;
|
||||
}
|
||||
|
||||
export interface ContentContainerViewProps {
|
||||
@@ -16,6 +20,7 @@ export interface ContentContainerViewProps {
|
||||
width: string;
|
||||
size: "xs" | "responsive";
|
||||
iconImage: string;
|
||||
iconAlt: string;
|
||||
containerClasses: string;
|
||||
contentGapClasses: string;
|
||||
textGapClasses: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ function ContentContainerView({
|
||||
width,
|
||||
size,
|
||||
iconImage,
|
||||
iconAlt,
|
||||
containerClasses,
|
||||
contentGapClasses,
|
||||
textGapClasses,
|
||||
@@ -27,7 +28,7 @@ function ContentContainerView({
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={iconImage}
|
||||
alt={`Icon for ${post.frontmatter.title}`}
|
||||
alt={iconAlt}
|
||||
className="w-[60px] h-[30px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { CardStepsProps } from "./CardSteps.types";
|
||||
* Composes **`cards/Step`** (Figma Card / Step), not **`progress/Stepper`**.
|
||||
*/
|
||||
const CardStepsContainer = memo<CardStepsProps>(
|
||||
({ title, subtitle, steps, headingDesktopLines }) => {
|
||||
({ title, subtitle, steps, headingDesktopLines, seeHowItWorksHref }) => {
|
||||
const schemaData = useSchemaData({
|
||||
type: "HowTo",
|
||||
name: title,
|
||||
@@ -29,6 +29,7 @@ const CardStepsContainer = memo<CardStepsProps>(
|
||||
subtitle={subtitle}
|
||||
steps={steps}
|
||||
headingDesktopLines={headingDesktopLines}
|
||||
seeHowItWorksHref={seeHowItWorksHref}
|
||||
schemaJson={schemaJson}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface CardStepsProps {
|
||||
steps: CardStepsItem[];
|
||||
/** Large-screen heading split: line 1–3 (e.g. How / CommunityRule / helps). */
|
||||
headingDesktopLines?: readonly [string, string, string];
|
||||
/** When set, the section CTA renders as a link. */
|
||||
seeHowItWorksHref?: string;
|
||||
}
|
||||
|
||||
export interface CardStepsViewProps extends CardStepsProps {
|
||||
|
||||
@@ -11,6 +11,7 @@ function CardStepsView({
|
||||
subtitle,
|
||||
steps,
|
||||
headingDesktopLines,
|
||||
seeHowItWorksHref,
|
||||
schemaJson,
|
||||
}: CardStepsViewProps) {
|
||||
const t = useTranslation();
|
||||
@@ -47,7 +48,12 @@ function CardStepsView({
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Button buttonType="outline" palette="default" size="large">
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
size="large"
|
||||
href={seeHowItWorksHref}
|
||||
>
|
||||
{t("cardSteps.buttons.seeHowItWorks")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { getAssetPath } from "../../../lib/assetUtils";
|
||||
import ContentContainer from "../content/ContentContainer";
|
||||
import type { BlogPost } from "../../../lib/content";
|
||||
|
||||
interface ContentBannerProps {
|
||||
post: BlogPost;
|
||||
}
|
||||
|
||||
const ContentBanner = memo<ContentBannerProps>(({ post }) => {
|
||||
// Get article-specific horizontal thumbnail (small) and banner (md+)
|
||||
const getBackgroundImage = (post: BlogPost): string => {
|
||||
if (post.frontmatter?.thumbnail?.horizontal) {
|
||||
return `/content/blog/${post.frontmatter.thumbnail.horizontal}`;
|
||||
}
|
||||
// Fallback to default image
|
||||
return getAssetPath("assets/Content_Banner.svg");
|
||||
};
|
||||
|
||||
const getBannerImageMd = (post: BlogPost): string => {
|
||||
// Use banner.horizontal when provided; fallback to horizontal thumbnail
|
||||
if (post.frontmatter?.banner?.horizontal) {
|
||||
return `/content/blog/${post.frontmatter.banner.horizontal}`;
|
||||
}
|
||||
// Fallback to horizontal thumbnail, then default banner
|
||||
if (post.frontmatter?.thumbnail?.horizontal) {
|
||||
return `/content/blog/${post.frontmatter.thumbnail.horizontal}`;
|
||||
}
|
||||
return getAssetPath("assets/Content_Banner_2.svg");
|
||||
};
|
||||
|
||||
const backgroundImage = getBackgroundImage(post);
|
||||
const bannerImageMd = getBannerImageMd(post);
|
||||
|
||||
return (
|
||||
<div className="pt-[var(--measures-spacing-016)] md:pt-[var(--measures-spacing-008)] lg:pt-[50px] xl:pt-[112px] h-[275px] sm:h-[326px] md:h-[224px] lg:h-[358.4px] xl:h-[504px] relative w-full sm:overflow-hidden">
|
||||
{/* Background SVG - Default to sm breakpoint */}
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full bg-cover bg-no-repeat aspect-[320/225.5]"
|
||||
style={{
|
||||
backgroundImage: `url(${backgroundImage})`,
|
||||
backgroundPosition: "center bottom",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Background SVG - md breakpoint and above (article banner image) */}
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full bg-cover bg-no-repeat aspect-[640/224] md:block hidden"
|
||||
style={{
|
||||
backgroundImage: `url(${bannerImageMd})`,
|
||||
backgroundPosition: "center bottom",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content Container */}
|
||||
<div
|
||||
className="
|
||||
relative z-10 h-full
|
||||
flex flex-col
|
||||
pl-[var(--measures-spacing-016)] md:pl-[var(--measures-spacing-024)] lg:pl-[var(--measures-spacing-064)]
|
||||
pr-[96px] md:pr-[350px]
|
||||
|
||||
/* default: normal flow, top-aligned */
|
||||
justify-start
|
||||
|
||||
/* only at md: take out of flow and center vertically */
|
||||
md:absolute md:inset-x-0 md:top-1/2 md:-translate-y-1/2 md:w-full md:h-auto
|
||||
|
||||
/* after md (lg+): snap back to normal flow/top align */
|
||||
lg:static lg:translate-y-0 lg:top-auto lg:h-full lg:justify-start
|
||||
"
|
||||
>
|
||||
{/* ContentContainer with post data */}
|
||||
<ContentContainer post={post} size="responsive" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ContentBanner.displayName = "ContentBanner";
|
||||
|
||||
export default ContentBanner;
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||
import type { BlogPost } from "../../../../lib/content";
|
||||
import ContentBannerView from "./ContentBanner.view";
|
||||
import type { ContentBannerProps } from "./ContentBanner.types";
|
||||
|
||||
/** Figma: Section / ContentBanner — article (blog) and guide (content page template 22078:791901). */
|
||||
const ContentBannerContainer = memo<ContentBannerProps>(
|
||||
({
|
||||
post,
|
||||
variant: variantProp = "article",
|
||||
leadingImageSrc,
|
||||
leadingImageAlt,
|
||||
}) => {
|
||||
const variant = variantProp;
|
||||
|
||||
const getBackgroundImage = (blogPost: BlogPost): string => {
|
||||
if (blogPost.frontmatter?.thumbnail?.horizontal) {
|
||||
return `/content/blog/${blogPost.frontmatter.thumbnail.horizontal}`;
|
||||
}
|
||||
return getAssetPath("assets/Content_Banner.svg");
|
||||
};
|
||||
|
||||
const getBannerImageMd = (blogPost: BlogPost): string => {
|
||||
if (blogPost.frontmatter?.banner?.horizontal) {
|
||||
return `/content/blog/${blogPost.frontmatter.banner.horizontal}`;
|
||||
}
|
||||
if (blogPost.frontmatter?.thumbnail?.horizontal) {
|
||||
return `/content/blog/${blogPost.frontmatter.thumbnail.horizontal}`;
|
||||
}
|
||||
return getAssetPath("assets/Content_Banner_2.svg");
|
||||
};
|
||||
|
||||
const backgroundImageSm =
|
||||
variant === "article" ? getBackgroundImage(post) : undefined;
|
||||
const backgroundImageMd =
|
||||
variant === "article" ? getBannerImageMd(post) : undefined;
|
||||
|
||||
return (
|
||||
<ContentBannerView
|
||||
variant={variant}
|
||||
post={post}
|
||||
leadingImageSrc={leadingImageSrc}
|
||||
leadingImageAlt={leadingImageAlt}
|
||||
backgroundImageSm={backgroundImageSm}
|
||||
backgroundImageMd={backgroundImageMd}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ContentBannerContainer.displayName = "ContentBanner";
|
||||
|
||||
export default ContentBannerContainer;
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { BlogPost } from "../../../../lib/content";
|
||||
|
||||
export type ContentBannerVariant = "article" | "guide";
|
||||
|
||||
export interface ContentBannerProps {
|
||||
post: BlogPost;
|
||||
/**
|
||||
* `article` — blog post hero with thumbnail/banner imagery and metadata.
|
||||
* `guide` — static guide pages (Figma ContentBanner on content page template).
|
||||
*/
|
||||
variant?: ContentBannerVariant;
|
||||
/** Article variant only: replaces slug-based thumbnail icon in ContentContainer. */
|
||||
leadingImageSrc?: string;
|
||||
leadingImageAlt?: string;
|
||||
}
|
||||
|
||||
export interface ContentBannerViewProps {
|
||||
variant: ContentBannerVariant;
|
||||
post: BlogPost;
|
||||
leadingImageSrc?: string;
|
||||
leadingImageAlt?: string;
|
||||
backgroundImageSm?: string;
|
||||
backgroundImageMd?: string;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import ContentContainer from "../../content/ContentContainer";
|
||||
import {
|
||||
getAssetPath,
|
||||
guideBannerLogoArrowPath,
|
||||
} from "../../../../lib/assetUtils";
|
||||
import type { ContentBannerViewProps } from "./ContentBanner.types";
|
||||
|
||||
/**
|
||||
* Figma: ContentBanner on content page template (22078:791901) — left column
|
||||
* title + description; logo mark (22078:806960) in right column.
|
||||
*/
|
||||
function ContentBannerGuideView({
|
||||
post,
|
||||
}: Pick<ContentBannerViewProps, "post">) {
|
||||
const { title, description } = post.frontmatter;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative w-full overflow-clip px-[var(--spacing-scale-020)] py-[var(--spacing-scale-024)] sm:px-[var(--spacing-scale-032)] sm:py-[var(--spacing-scale-032)] lg:px-[var(--spacing-scale-048)] lg:py-[var(--spacing-scale-040)]"
|
||||
aria-labelledby="content-banner-title"
|
||||
>
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-[1024px] flex-col items-start gap-[var(--spacing-scale-024)] md:flex-row md:items-center md:gap-[var(--spacing-scale-032)]"
|
||||
data-node-id="19189:9358"
|
||||
>
|
||||
<div
|
||||
className="flex w-full max-w-[365px] shrink-0 flex-col items-start justify-center gap-[var(--spacing-scale-024)]"
|
||||
data-node-id="19189:9171"
|
||||
>
|
||||
<div className="flex w-full flex-col items-start gap-[var(--measures-spacing-016)]">
|
||||
<div className="flex w-full flex-col items-start gap-[var(--measures-spacing-004)] text-left text-[var(--color-content-default-primary)]">
|
||||
<h1
|
||||
id="content-banner-title"
|
||||
className="w-full font-bricolage font-medium text-[32px] leading-[110%] sm:text-[40px] lg:text-[44px]"
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p className="w-full font-inter font-normal text-[16px] leading-[130%] sm:text-[18px]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex w-full shrink-0 items-center justify-center md:flex-1"
|
||||
data-node-id="22078:806960"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getAssetPath(guideBannerLogoArrowPath())}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="h-[clamp(120px,20vw,171px)] w-[clamp(120px,20vw,172px)] max-w-none shrink-0 object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentBannerArticleView({
|
||||
post,
|
||||
leadingImageSrc,
|
||||
leadingImageAlt,
|
||||
backgroundImageSm,
|
||||
backgroundImageMd,
|
||||
}: ContentBannerViewProps) {
|
||||
if (!backgroundImageSm || !backgroundImageMd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-[275px] w-full pt-[var(--measures-spacing-016)] sm:h-[326px] sm:overflow-hidden md:h-[224px] md:pt-[var(--measures-spacing-008)] lg:h-[358.4px] lg:pt-[50px] xl:h-[504px] xl:pt-[112px]">
|
||||
<div
|
||||
className="absolute inset-0 aspect-[320/225.5] h-full w-full bg-cover bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${backgroundImageSm})`,
|
||||
backgroundPosition: "center bottom",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute inset-0 hidden aspect-[640/224] h-full w-full bg-cover bg-no-repeat md:block"
|
||||
style={{
|
||||
backgroundImage: `url(${backgroundImageMd})`,
|
||||
backgroundPosition: "center bottom",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="
|
||||
relative z-10 flex h-full flex-col
|
||||
pl-[var(--measures-spacing-016)] pr-[96px]
|
||||
justify-start
|
||||
md:absolute md:inset-x-0 md:top-1/2 md:h-auto md:w-full md:-translate-y-1/2
|
||||
md:pl-[var(--measures-spacing-024)] md:pr-[350px]
|
||||
lg:static lg:top-auto lg:h-full lg:translate-y-0 lg:justify-start
|
||||
lg:pl-[var(--measures-spacing-064)]
|
||||
"
|
||||
>
|
||||
<ContentContainer
|
||||
post={post}
|
||||
size="responsive"
|
||||
leadingImageSrc={leadingImageSrc}
|
||||
leadingImageAlt={leadingImageAlt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentBannerView(props: ContentBannerViewProps) {
|
||||
if (props.variant === "guide") {
|
||||
return <ContentBannerGuideView post={props.post} />;
|
||||
}
|
||||
|
||||
return <ContentBannerArticleView {...props} />;
|
||||
}
|
||||
|
||||
ContentBannerView.displayName = "ContentBannerView";
|
||||
|
||||
export default memo(ContentBannerView);
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default } from "./ContentBanner.container";
|
||||
export type {
|
||||
ContentBannerProps,
|
||||
ContentBannerVariant,
|
||||
} from "./ContentBanner.types";
|
||||
@@ -12,6 +12,8 @@ const RelatedArticlesContainer = memo<RelatedArticlesProps>(
|
||||
currentPostSlug,
|
||||
slugOrder = [],
|
||||
variant = "default",
|
||||
headingSurface = "onDark",
|
||||
heading,
|
||||
}) => {
|
||||
const messages = useMessages();
|
||||
// Memoize filtered posts to prevent unnecessary re-computations
|
||||
@@ -116,6 +118,8 @@ const RelatedArticlesContainer = memo<RelatedArticlesProps>(
|
||||
getProgressStyle={getProgressStyle}
|
||||
onMouseDown={handleMouseDown}
|
||||
variant={variant}
|
||||
headingSurface={headingSurface}
|
||||
heading={heading}
|
||||
useCasesHeadingLines={useCasesHeadingLines}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { BlogPost } from "../../../../lib/content";
|
||||
|
||||
export type RelatedArticlesVariant = "default" | "useCases";
|
||||
|
||||
/** Heading contrast when the section sits on a dark vs light page background. */
|
||||
export type RelatedArticlesHeadingSurface = "onDark" | "onLight";
|
||||
|
||||
export interface RelatedArticlesProps {
|
||||
relatedPosts: BlogPost[];
|
||||
currentPostSlug: string;
|
||||
@@ -12,6 +15,10 @@ export interface RelatedArticlesProps {
|
||||
* **`lg`** [**20711-14231**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=20711-14231&m=dev) (shell + card row gutter / padding).
|
||||
*/
|
||||
variant?: RelatedArticlesVariant;
|
||||
/** Default `onDark` (blog). Use `onLight` on transparent / light marketing pages. */
|
||||
headingSurface?: RelatedArticlesHeadingSurface;
|
||||
/** Overrides the default “Related Articles” heading. */
|
||||
heading?: string;
|
||||
}
|
||||
|
||||
export interface RelatedArticlesViewProps {
|
||||
@@ -22,6 +29,8 @@ export interface RelatedArticlesViewProps {
|
||||
getProgressStyle: (_index: number) => React.CSSProperties;
|
||||
onMouseDown?: (_e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
variant?: RelatedArticlesVariant;
|
||||
headingSurface?: RelatedArticlesHeadingSurface;
|
||||
heading?: string;
|
||||
/** Stacked title lines (`pages.useCases.relatedArticles.title`) when `variant="useCases"`. */
|
||||
useCasesHeadingLines?: readonly string[];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export function RelatedArticlesView({
|
||||
getProgressStyle,
|
||||
onMouseDown,
|
||||
variant = "default",
|
||||
headingSurface = "onDark",
|
||||
heading = "Related Articles",
|
||||
useCasesHeadingLines,
|
||||
}: RelatedArticlesViewProps) {
|
||||
if (filteredPosts.length === 0) {
|
||||
@@ -49,8 +51,14 @@ export function RelatedArticlesView({
|
||||
</span>
|
||||
</h2>
|
||||
) : (
|
||||
<h2 className="text-center text-[32px] font-medium leading-[110%] text-[var(--color-content-inverse-primary)] lg:text-[44px]">
|
||||
Related Articles
|
||||
<h2
|
||||
className={`text-center text-[32px] font-medium leading-[110%] lg:text-[44px] ${
|
||||
headingSurface === "onLight"
|
||||
? "text-[var(--color-content-default-primary)]"
|
||||
: "text-[var(--color-content-inverse-primary)]"
|
||||
}`}
|
||||
>
|
||||
{heading}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const ContentLockupContainer = memo<ContentLockupProps>(
|
||||
subtitle,
|
||||
description,
|
||||
ctaText,
|
||||
ctaHref,
|
||||
buttonClassName = "",
|
||||
variant: variantProp = "hero",
|
||||
linkText,
|
||||
@@ -166,6 +167,7 @@ const ContentLockupContainer = memo<ContentLockupProps>(
|
||||
subtitle={subtitle}
|
||||
description={description}
|
||||
ctaText={ctaText}
|
||||
ctaHref={ctaHref}
|
||||
buttonClassName={buttonClassName}
|
||||
variant={variant}
|
||||
linkText={linkText}
|
||||
|
||||
@@ -10,6 +10,7 @@ function ContentLockupView({
|
||||
subtitle,
|
||||
description,
|
||||
ctaText,
|
||||
ctaHref,
|
||||
buttonClassName,
|
||||
variant,
|
||||
linkText,
|
||||
@@ -111,6 +112,7 @@ function ContentLockupView({
|
||||
buttonType="filled"
|
||||
palette={variant === "hero" ? "default" : "inverse"}
|
||||
size="small"
|
||||
href={ctaHref}
|
||||
>
|
||||
{ctaText}
|
||||
</Button>
|
||||
@@ -122,6 +124,7 @@ function ContentLockupView({
|
||||
palette={variant === "hero" ? "default" : "inverse"}
|
||||
size="large"
|
||||
className={buttonClassName}
|
||||
href={ctaHref}
|
||||
>
|
||||
{ctaText}
|
||||
</Button>
|
||||
@@ -132,6 +135,7 @@ function ContentLockupView({
|
||||
buttonType="filled"
|
||||
palette={variant === "hero" ? "default" : "inverse"}
|
||||
size="xlarge"
|
||||
href={ctaHref}
|
||||
>
|
||||
{ctaText}
|
||||
</Button>
|
||||
|
||||
@@ -49,6 +49,25 @@ export function quoteStatementShapePath(): string {
|
||||
return "assets/shapes/shape-qoute.svg";
|
||||
}
|
||||
|
||||
/**
|
||||
* How-it-works body ornaments (`public/assets/shapes/how-shape-*.svg`,
|
||||
* Figma **22078:791901**).
|
||||
*/
|
||||
export function howItWorksOrnamentRightPath(): string {
|
||||
return "assets/shapes/how-shape-2.svg";
|
||||
}
|
||||
|
||||
export function howItWorksOrnamentLeftPath(): string {
|
||||
return "assets/shapes/how-shape-1.svg";
|
||||
}
|
||||
|
||||
/**
|
||||
* Guide ContentBanner logo mark (Figma **22078:806960**).
|
||||
*/
|
||||
export function guideBannerLogoArrowPath(): string {
|
||||
return "assets/shapes/guide-banner-logo-arrow.svg";
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset paths for common components
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { BlogPost } from "./content";
|
||||
import { markdownToHtml } from "./content";
|
||||
import type howItWorksPage from "../messages/en/pages/howItWorks.json";
|
||||
|
||||
export const HOW_IT_WORKS_SENTINEL_SLUG = "__how-it-works__";
|
||||
|
||||
type HowItWorksMessages = typeof howItWorksPage;
|
||||
|
||||
/**
|
||||
* Builds a {@link BlogPost}-shaped object for static marketing pages that reuse
|
||||
* blog article chrome (`ContentBanner`, `.post-body`) without a markdown file.
|
||||
*/
|
||||
export function buildHowItWorksSyntheticPost(
|
||||
page: HowItWorksMessages,
|
||||
): BlogPost {
|
||||
const { banner, bodyMarkdown } = page;
|
||||
|
||||
return {
|
||||
slug: HOW_IT_WORKS_SENTINEL_SLUG,
|
||||
frontmatter: {
|
||||
title: banner.title,
|
||||
description: banner.description,
|
||||
author: banner.author,
|
||||
date: banner.date,
|
||||
},
|
||||
content: bodyMarkdown,
|
||||
htmlContent: markdownToHtml(bodyMarkdown),
|
||||
filePath: "messages/en/pages/howItWorks.json",
|
||||
lastModified: new Date(banner.date),
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"_comment": "CardSteps section defaults (shared across pages)",
|
||||
"titleLg": "How CommunityRule helps",
|
||||
"buttons": {
|
||||
"seeHowItWorks": "See how it works"
|
||||
"seeHowItWorks": "See how it works",
|
||||
"seeHowItWorksHref": "/how-it-works"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import templates from "./pages/templates.json";
|
||||
import learn from "./pages/learn.json";
|
||||
import about from "./pages/about.json";
|
||||
import useCases from "./pages/useCases.json";
|
||||
import howItWorks from "./pages/howItWorks.json";
|
||||
import monitor from "./pages/monitor.json";
|
||||
import login from "./pages/login.json";
|
||||
import profile from "./pages/profile.json";
|
||||
@@ -80,6 +81,7 @@ export default {
|
||||
learn,
|
||||
about,
|
||||
useCases,
|
||||
howItWorks,
|
||||
monitor,
|
||||
login,
|
||||
profile,
|
||||
|
||||
@@ -19,5 +19,15 @@
|
||||
"community governance",
|
||||
"operating manual"
|
||||
]
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "A Guide to CommunityRule — CommunityRule",
|
||||
"description": "CommunityRule is a modular governance toolkit designed to help democratic groups build, customize, and publish their own Operating Manual.",
|
||||
"keywords": [
|
||||
"how it works",
|
||||
"community governance",
|
||||
"operating manual",
|
||||
"decision-making"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"subtitle": "with clarity",
|
||||
"description": "Help your community make important decisions in a way that reflects its unique values.",
|
||||
"ctaText": "Learn how CommunityRule works",
|
||||
"ctaHref": "#"
|
||||
"ctaHref": "/how-it-works"
|
||||
},
|
||||
"cardSteps": {
|
||||
"title": "How CommunityRule works",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 172 171" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M172 85.5L0 0L43.043 85.5L0 171L172 85.5ZM86.0861 67.7374C89.5979 67.7585 93.025 68.811 95.9358 70.7624C98.8467 72.7139 101.111 75.4769 102.444 78.7039C103.777 81.9308 104.12 85.4773 103.428 88.8969C102.736 92.3165 101.041 95.4564 98.556 97.9213C96.0712 100.386 92.9078 102.066 89.4639 102.749C86.02 103.432 82.4496 103.088 79.2022 101.76C75.9547 100.432 73.1754 98.1797 71.2141 95.2863C69.2528 92.3929 68.1972 88.988 68.1802 85.5C68.1688 83.1624 68.6243 80.8458 69.5203 78.6842C70.4164 76.5226 71.7353 74.559 73.4005 72.907C75.0658 71.2551 77.0445 69.9476 79.2219 69.0602C81.3994 68.1728 83.7325 67.7232 86.0861 67.7374Z" fill="var(--fill-0, #FEFCC9)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 855 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 15 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 69 KiB |
@@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import ContentBanner from "../../app/components/sections/ContentBanner";
|
||||
|
||||
const mockBlogPost = {
|
||||
@@ -20,6 +21,21 @@ const mockBlogPost = {
|
||||
"<p>This is the main content of the sample article.</p><p>It has multiple paragraphs.</p>",
|
||||
};
|
||||
|
||||
const guidePost = {
|
||||
slug: "__how-it-works__",
|
||||
frontmatter: {
|
||||
title: "A Guide to CommunityRule",
|
||||
description:
|
||||
"CommunityRule is a modular governance toolkit designed to help democratic groups build, customize, and publish their own Operating Manual.",
|
||||
author: "CommunityRule",
|
||||
date: "2026-01-15",
|
||||
},
|
||||
content: "",
|
||||
htmlContent: "",
|
||||
filePath: "messages/en/pages/howItWorks.json",
|
||||
lastModified: new Date("2026-01-15"),
|
||||
};
|
||||
|
||||
export default {
|
||||
title: "Components/Sections/ContentBanner",
|
||||
component: ContentBanner,
|
||||
@@ -27,24 +43,44 @@ export default {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The ContentBanner component displays the header information for blog articles, including title, description, author, and date.\n\nImages: sm uses thumbnail.horizontal; md+ uses banner.horizontal when provided, otherwise falls back to thumbnail.horizontal; final fallback is assets/Content_Banner_2.svg.\n\nNote: page background colors are applied at the blog page level using a hex color from frontmatter (background.color), not inside this component. Thumbnail and banner images should be uploaded via the content pipeline to public/content/blog/ and referenced in frontmatter.",
|
||||
"Section / ContentBanner — `article` variant for blog posts (thumbnail/banner imagery, icon, author, date); `guide` variant for static content pages (left: title + description, right: logo mark — Figma 22078:791901 + 22078:806960).",
|
||||
},
|
||||
},
|
||||
layout: "fullscreen",
|
||||
},
|
||||
argTypes: {
|
||||
post: {
|
||||
control: "object",
|
||||
description: "Blog post object with frontmatter and content",
|
||||
},
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["article", "guide"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
export const Article = {
|
||||
args: {
|
||||
post: mockBlogPost,
|
||||
variant: "article",
|
||||
},
|
||||
};
|
||||
|
||||
export const Guide = {
|
||||
args: {
|
||||
post: guidePost,
|
||||
variant: "guide",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="bg-[var(--color-surface-default-primary)]">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const NoBannerFallbackToThumbnail = {
|
||||
args: {
|
||||
post: {
|
||||
|
||||
@@ -60,4 +60,26 @@ describe("ContentBanner", () => {
|
||||
render(<ContentBanner post={mockPost} />);
|
||||
expect(screen.getByText("Test description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders guide variant with left-aligned copy and logo mark", () => {
|
||||
const { container } = render(
|
||||
<ContentBanner post={mockPost} variant="guide" />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Test Article" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Test description")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Author")).not.toBeInTheDocument();
|
||||
|
||||
const bannerRow = container.querySelector('[data-node-id="19189:9358"]');
|
||||
expect(bannerRow).toHaveClass("md:flex-row");
|
||||
|
||||
const logoMark = container.querySelector(
|
||||
'[data-node-id="22078:806960"] img',
|
||||
);
|
||||
expect(logoMark).toHaveAttribute(
|
||||
"src",
|
||||
expect.stringContaining("guide-banner-logo-arrow.svg"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,11 +15,13 @@ test.describe("Critical User Journeys", () => {
|
||||
).toBeVisible();
|
||||
|
||||
// 3. User clicks CTA to learn more
|
||||
const learnButton = page
|
||||
.locator('button:has-text("Learn how CommunityRule works")')
|
||||
const learnCta = page
|
||||
.getByRole("link", { name: /Learn how CommunityRule works/i })
|
||||
.or(page.getByRole("button", { name: /Learn how CommunityRule works/i }))
|
||||
.first();
|
||||
if ((await learnButton.count()) > 0 && (await learnButton.isVisible())) {
|
||||
await learnButton.click();
|
||||
if ((await learnCta.count()) > 0 && (await learnCta.isVisible())) {
|
||||
await learnCta.click();
|
||||
await expect(page).toHaveURL(/\/how-it-works/);
|
||||
}
|
||||
|
||||
// 4. User scrolls to CardSteps section (home)
|
||||
|
||||
@@ -61,27 +61,28 @@ test.describe("Edge Cases and Error Scenarios", () => {
|
||||
// Page should continue to function
|
||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
||||
|
||||
const learnButtons = page.locator(
|
||||
'button:has-text("Learn how CommunityRule works")',
|
||||
);
|
||||
const buttonCount = await learnButtons.count();
|
||||
let visibleButton = null;
|
||||
const learnCta = page
|
||||
.getByRole("link", { name: /Learn how CommunityRule works/i })
|
||||
.or(page.getByRole("button", { name: /Learn how CommunityRule works/i }));
|
||||
const ctaCount = await learnCta.count();
|
||||
let visibleCta = null;
|
||||
|
||||
for (let i = 0; i < buttonCount; i++) {
|
||||
const button = learnButtons.nth(i);
|
||||
if (await button.isVisible()) {
|
||||
visibleButton = button;
|
||||
for (let i = 0; i < ctaCount; i++) {
|
||||
const cta = learnCta.nth(i);
|
||||
if (await cta.isVisible()) {
|
||||
visibleCta = cta;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!visibleButton) {
|
||||
if (!visibleCta) {
|
||||
throw new Error(
|
||||
'No visible "Learn how CommunityRule works" button found',
|
||||
'No visible "Learn how CommunityRule works" CTA found',
|
||||
);
|
||||
}
|
||||
|
||||
await visibleButton.click();
|
||||
await visibleCta.click();
|
||||
await expect(page).toHaveURL(/\/how-it-works/);
|
||||
});
|
||||
|
||||
test("handles missing images gracefully", async ({ page }) => {
|
||||
@@ -97,26 +98,27 @@ test.describe("Edge Cases and Error Scenarios", () => {
|
||||
// Page should still function without images
|
||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
||||
|
||||
const learnButtons = page.locator(
|
||||
'button:has-text("Learn how CommunityRule works")',
|
||||
);
|
||||
const buttonCount = await learnButtons.count();
|
||||
let visibleButton = null;
|
||||
const learnCta = page
|
||||
.getByRole("link", { name: /Learn how CommunityRule works/i })
|
||||
.or(page.getByRole("button", { name: /Learn how CommunityRule works/i }));
|
||||
const ctaCount = await learnCta.count();
|
||||
let visibleCta = null;
|
||||
|
||||
for (let i = 0; i < buttonCount; i++) {
|
||||
const button = learnButtons.nth(i);
|
||||
if (await button.isVisible()) {
|
||||
visibleButton = button;
|
||||
for (let i = 0; i < ctaCount; i++) {
|
||||
const cta = learnCta.nth(i);
|
||||
if (await cta.isVisible()) {
|
||||
visibleCta = cta;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!visibleButton) {
|
||||
if (!visibleCta) {
|
||||
throw new Error(
|
||||
'No visible "Learn how CommunityRule works" button found',
|
||||
'No visible "Learn how CommunityRule works" CTA found',
|
||||
);
|
||||
}
|
||||
|
||||
await visibleButton.click();
|
||||
await visibleCta.click();
|
||||
await expect(page).toHaveURL(/\/how-it-works/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,10 @@ describe("Page", () => {
|
||||
expect(
|
||||
screen.getAllByText("Learn how CommunityRule works").length,
|
||||
).toBeGreaterThan(0);
|
||||
const learnLinks = screen.getAllByRole("link", {
|
||||
name: "Learn how CommunityRule works",
|
||||
});
|
||||
expect(learnLinks[0]).toHaveAttribute("href", "/how-it-works");
|
||||
});
|
||||
|
||||
test("renders CardSteps section with correct data", async () => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, test, expect, vi } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { renderWithProviders as render } from "../utils/test-utils";
|
||||
import HowItWorksPage from "../../app/(marketing)/how-it-works/page";
|
||||
import messages from "../../messages/en/index";
|
||||
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: (importFn) => {
|
||||
const Component = vi.fn(() => (
|
||||
<section data-testid="related-articles">Related articles</section>
|
||||
));
|
||||
return Component;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../app/components/sections/ContentBanner", () => ({
|
||||
default: ({ post, variant }) => (
|
||||
<section data-testid="content-banner" data-variant={variant}>
|
||||
<h1>{post.frontmatter.title}</h1>
|
||||
<p>{post.frontmatter.description}</p>
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../app/components/sections/AskOrganizer", () => ({
|
||||
default: ({ title, subtitle, buttonText }) => (
|
||||
<section data-testid="ask-organizer">
|
||||
<h2>{title}</h2>
|
||||
<p>{subtitle}</p>
|
||||
<button type="button">{buttonText}</button>
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("HowItWorksPage", () => {
|
||||
const page = messages.pages.howItWorks;
|
||||
|
||||
test("renders banner, body sections, related articles, and ask organizer", async () => {
|
||||
render(<HowItWorksPage />);
|
||||
|
||||
expect(screen.getByTestId("content-banner")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: page.banner.title }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("content-banner")).toHaveAttribute(
|
||||
"data-variant",
|
||||
"guide",
|
||||
);
|
||||
expect(screen.getByText(page.banner.description)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("related-articles")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("ask-organizer")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(messages.pages.home.askOrganizer.title),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/How the Platform Works: From Chaos to Clarity/),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/mutual aid network, manage an open-source project/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user