Map exact size/weight/line-height combos onto existing text-* utilities across cards, sections, navigation, modals, controls, and create-flow. Leave unmappable combos hardcoded for design review. Co-authored-by: Cursor <cursoragent@cursor.com>
203 lines
5.8 KiB
TypeScript
203 lines
5.8 KiB
TypeScript
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)] text-[var(--color-content-default-primary)] text-medium-paragraph sm:max-w-[390px] sm:text-large-paragraph md:max-w-[472px] lg:max-w-[700px] lg:text-x-large-paragraph xl:max-w-[904px] xl:text-xx-large-paragraph";
|
|
|
|
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)] text-xx-small-paragraph text-[var(--color-content-default-secondary)] md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph"
|
|
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>
|
|
</>
|
|
);
|
|
}
|