feat: add privacy, terms, and cookies pages

Footer and login legal links were stubs. Point them at real marketing
pages that follow the Figma content-page template.
This commit is contained in:
adilallo
2026-08-17 11:15:04 -06:00
parent 99419915d7
commit 0167b03231
16 changed files with 634 additions and 9 deletions
@@ -0,0 +1,202 @@
import type { Metadata } from "next";
import Link from "next/link";
import type { ReactNode } from "react";
import {
buildLegalSyntheticPost,
type LegalDocumentMessages,
type LegalDocumentSlug,
} from "../../../lib/legalSyntheticPost";
import ContentBanner from "../../components/sections/ContentBanner";
type LegalDocumentPageProps = {
slug: LegalDocumentSlug;
path: string;
page: LegalDocumentMessages;
};
const INLINE_LINK = /\[([^\]]+)\]\(([^)]+)\)/g;
const ARTICLE_COLUMN_CLASS =
"mx-auto flex w-full flex-col gap-[var(--space-800,32px)] font-inter text-[var(--color-content-default-primary)] text-[16px] leading-[24px] sm:max-w-[390px] sm:text-[18px] sm:leading-[130%] md:max-w-[472px] lg:max-w-[700px] lg:text-[24px] lg:leading-[32px] xl:max-w-[904px] xl:text-[32px] xl:leading-[40px]";
const LINK_CLASS = "underline decoration-solid underline-offset-2";
function isSafeHref(href: string): boolean {
return (
(href.startsWith("/") && !href.startsWith("//")) ||
href.startsWith("https://") ||
href.startsWith("http://") ||
href.startsWith("mailto:")
);
}
function LegalInlineText({ text }: { text: string }) {
const nodes: ReactNode[] = [];
let lastIndex = 0;
let key = 0;
for (const match of text.matchAll(INLINE_LINK)) {
const matchIndex = match.index ?? 0;
if (matchIndex > lastIndex) {
nodes.push(text.slice(lastIndex, matchIndex));
}
const label = match[1];
const href = match[2];
if (isSafeHref(href)) {
const isInternal = href.startsWith("/") && !href.startsWith("//");
nodes.push(
isInternal ? (
<Link key={key} href={href} className={LINK_CLASS}>
{label}
</Link>
) : (
<a key={key} href={href} className={LINK_CLASS}>
{label}
</a>
),
);
key += 1;
} else {
nodes.push(label);
}
lastIndex = matchIndex + match[0].length;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes;
}
export function legalPageMetadata(
meta: { title: string; description: string; keywords: string[] },
page: { banner: { title: string; description: string } },
): Metadata {
return {
title: meta.title,
description: meta.description,
keywords: meta.keywords,
openGraph: {
title: page.banner.title,
description: page.banner.description,
type: "website",
siteName: "CommunityRule",
},
};
}
/**
* Public legal documents (`/privacy`, `/terms`, `/cookies`).
*
* Figma: "Content page Template" (19003:23305) — ContentBanner guide
* (22078:806964) + article column (19003:23574). Body stacks follow
* Type / Text Block (22001:29793): heading + paragraphs. Omits Related
* articles, Ask Organizer, and decorative shapes.
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=19003-23305
*/
export default function LegalDocumentPage({
slug,
path,
page,
}: LegalDocumentPageProps) {
const post = buildLegalSyntheticPost(slug, page);
const structuredData = {
"@context": "https://schema.org",
"@type": "WebPage",
name: page.banner.title,
description: page.banner.description,
url: `https://communityrule.com${path}`,
dateModified: page.banner.date,
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${path}`,
},
],
};
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={post} variant="guide" />
<article
className="relative z-10 p-[var(--spacing-scale-024)] sm:py-[var(--spacing-scale-032)]"
data-node-id="19003:23574"
>
<div className={`${ARTICLE_COLUMN_CLASS} -mt-[var(--spacing-scale-048)]`}>
<div
className="flex flex-wrap items-end gap-[var(--measures-spacing-008,8px)] font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-default-secondary)] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%]"
data-name="Metadata Container"
>
<span>{page.banner.author}</span>
<span>{page.updated}</span>
</div>
{page.intro.length > 0 ? (
<div className="flex flex-col gap-[1em]">
{page.intro.map((paragraph) => (
<p key={paragraph}>
<LegalInlineText text={paragraph} />
</p>
))}
</div>
) : null}
{page.sections.map((section) => (
<section
key={section.heading}
className="flex w-full flex-col gap-[var(--space-200,8px)]"
data-name="TextBlock"
>
<h2 className="w-full font-bold">{section.heading}</h2>
<div className="flex flex-col gap-[1em]">
{section.paragraphs.map((paragraph) => (
<p key={paragraph}>
<LegalInlineText text={paragraph} />
</p>
))}
</div>
</section>
))}
</div>
</article>
</div>
</>
);
}