Files
Spanglish/frontend/src/components/layout/LegalPageLayout.tsx
Michilis ecd2a7d009 fix(legal): align bullet list markers with list text
Use list-outside and left padding so ReactMarkdown and TipTap list items render markers beside text instead of on separate lines.
2026-06-04 22:58:31 +00:00

249 lines
8.2 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import Link from 'next/link';
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import { legalPagesApi } from '@/lib/api';
interface LegalPageLayoutProps {
slug: string;
initialLocale: 'en' | 'es';
title: string;
content: string;
lastUpdated?: string;
}
function extractLastUpdated(contentMarkdown: string, updatedAt?: string): string | undefined {
const match = contentMarkdown?.match(/Last updated:\s*(.+)/i);
return match ? match[1].trim() : updatedAt;
}
export default function LegalPageLayout({
slug,
initialLocale,
title: initialTitle,
content: initialContent,
lastUpdated: initialLastUpdated,
}: LegalPageLayoutProps) {
const { locale, t } = useLanguage();
const [title, setTitle] = useState(initialTitle);
const [content, setContent] = useState(initialContent);
const [lastUpdated, setLastUpdated] = useState(initialLastUpdated);
const [loadedLocale, setLoadedLocale] = useState(initialLocale);
useEffect(() => {
if (locale === loadedLocale) {
return;
}
// Returning to the server-rendered language: restore SSR content without a fetch
if (locale === initialLocale) {
setTitle(initialTitle);
setContent(initialContent);
setLastUpdated(initialLastUpdated);
setLoadedLocale(initialLocale);
return;
}
let cancelled = false;
legalPagesApi
.getBySlug(slug, locale)
.then(({ page }) => {
if (cancelled || !page) {
return;
}
setTitle(page.title);
setContent(page.contentMarkdown);
setLastUpdated(extractLastUpdated(page.contentMarkdown, page.updatedAt));
setLoadedLocale(locale);
})
.catch(() => {
// Keep the server-rendered content if the re-fetch fails
});
return () => {
cancelled = true;
};
}, [locale, loadedLocale, initialLocale, slug, initialTitle, initialContent, initialLastUpdated]);
return (
<div className="section-padding">
<div className="container-page max-w-4xl">
{/* Back link */}
<Link
href="/"
className="inline-flex items-center text-gray-600 hover:text-primary-dark transition-colors mb-8"
>
<ArrowLeftIcon className="w-4 h-4 mr-2" />
{t('legalPage.backToHome')}
</Link>
{/* Title */}
<div className="mb-8 pb-6 border-b border-gray-200">
<h1 className="text-3xl md:text-4xl font-bold text-primary-dark mb-2">
{title}
</h1>
{lastUpdated && lastUpdated !== '[Insert Date]' && (
<p className="text-sm text-gray-500">
{t('legalPage.lastUpdated', { date: lastUpdated })}
</p>
)}
</div>
{/* Markdown content */}
<article className="prose prose-gray max-w-none legal-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Style headings
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-primary-dark mt-8 mb-4 first:mt-0">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-semibold text-primary-dark mt-8 mb-4 pb-2 border-b border-gray-200">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold text-primary-dark mt-6 mb-3">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-semibold text-primary-dark mt-4 mb-2">
{children}
</h4>
),
// Style paragraphs
p: ({ children }) => (
<p className="text-gray-700 leading-relaxed mb-4">
{children}
</p>
),
// Style lists
ul: ({ children }) => (
<ul className="list-disc list-outside space-y-2 mb-4 text-gray-700 pl-6">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-outside space-y-2 mb-4 text-gray-700 pl-6">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-gray-700">
{children}
</li>
),
// Style links
a: ({ href, children }) => (
<a
href={href}
className="text-primary-dark underline hover:text-primary-yellow transition-colors"
target={href?.startsWith('http') ? '_blank' : undefined}
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{children}
</a>
),
// Style horizontal rules
hr: () => (
<hr className="my-8 border-gray-200" />
),
// Style blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary-yellow pl-4 my-4 italic text-gray-600">
{children}
</blockquote>
),
// Style tables
table: ({ children }) => (
<div className="overflow-x-auto my-6">
<table className="min-w-full divide-y divide-gray-200 border border-gray-200 rounded-lg">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-gray-50">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody className="bg-white divide-y divide-gray-200">
{children}
</tbody>
),
tr: ({ children }) => (
<tr>
{children}
</tr>
),
th: ({ children }) => (
<th className="px-4 py-3 text-left text-sm font-semibold text-primary-dark">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-3 text-sm text-gray-700">
{children}
</td>
),
// Style code blocks
code: ({ className, children }) => {
const isInline = !className;
if (isInline) {
return (
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm text-gray-800">
{children}
</code>
);
}
return (
<code className={className}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="bg-gray-100 rounded-lg p-4 overflow-x-auto my-4">
{children}
</pre>
),
// Style strong and emphasis
strong: ({ children }) => (
<strong className="font-semibold text-primary-dark">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic">
{children}
</em>
),
}}
>
{content}
</ReactMarkdown>
</article>
{/* Back to top link */}
<div className="mt-12 pt-6 border-t border-gray-200">
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="text-gray-500 hover:text-primary-dark transition-colors text-sm"
>
{t('legalPage.backToTop')}
</button>
</div>
</div>
</div>
);
}