Implement how it works page

This commit is contained in:
adilallo
2026-05-17 22:40:06 -06:00
parent 450da4d8ab
commit 40ce5064d6
35 changed files with 707 additions and 123 deletions
@@ -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 13 (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>
-84
View File
@@ -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>