Use list-outside and left padding so ReactMarkdown and TipTap list items render markers beside text instead of on separate lines.
249 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|