mirror of
https://github.com/dyzulk/trustlab.git
synced 2026-01-26 13:32:06 +07:00
Refactor public legal URLs and fix broken links
This commit is contained in:
@@ -1,22 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, Suspense } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowRight, Scale, FileText } from 'lucide-react';
|
import { ArrowRight, Scale, FileText } from 'lucide-react';
|
||||||
import axios from '@/lib/axios';
|
import axios from '@/lib/axios';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
interface LegalPageItem {
|
interface LegalPageItem {
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LegalIndexPage() {
|
function LegalContent() {
|
||||||
const t = useTranslations("Legal");
|
const t = useTranslations("Legal");
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const pageSlug = searchParams.get('page'); // Changed from 'slug' to 'page' as requested
|
||||||
|
|
||||||
const [pages, setPages] = useState<LegalPageItem[]>([]);
|
const [pages, setPages] = useState<LegalPageItem[]>([]);
|
||||||
|
const [activePage, setActivePage] = useState<any>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Fetch List
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (pageSlug) return; // Don't fetch list if viewing a page
|
||||||
|
|
||||||
const fetchPages = async () => {
|
const fetchPages = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/api/public/legal-pages`);
|
const { data } = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/api/public/legal-pages`);
|
||||||
@@ -28,8 +38,89 @@ export default function LegalIndexPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchPages();
|
fetchPages();
|
||||||
}, []);
|
}, [pageSlug]);
|
||||||
|
|
||||||
|
// Fetch Single Page
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pageSlug) return;
|
||||||
|
|
||||||
|
const fetchPage = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
const res = await axios.get(`${apiUrl}/api/public/legal-pages/${pageSlug}`);
|
||||||
|
setActivePage(res.data.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchPage();
|
||||||
|
}, [pageSlug]);
|
||||||
|
|
||||||
|
// View: Single Page
|
||||||
|
if (pageSlug) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="w-10 h-10 border-4 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activePage) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[50vh] flex flex-col items-center justify-center">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">404 - Not Found</h2>
|
||||||
|
<Link href="/legal" className="text-brand-600 hover:underline">
|
||||||
|
{t('center_title')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||||
|
<Link href="/legal" className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-brand-600 mb-8 transition-colors">
|
||||||
|
<ArrowRight className="rotate-180" size={16} />
|
||||||
|
{t('center_title')}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-3xl shadow-xl overflow-hidden border border-gray-100 dark:border-gray-800">
|
||||||
|
<div className="p-8 md:p-12">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-gray-200 dark:border-gray-800 pb-8 mb-8">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-brand-600 to-blue-600 dark:from-brand-400 dark:to-blue-400 mb-4">
|
||||||
|
{activePage.title}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{t('version')} {activePage.version}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{t('last_updated')}: {new Date(activePage.updated_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Styled Content */}
|
||||||
|
<article className="prose dark:prose-invert max-w-none
|
||||||
|
prose-headings:font-bold prose-headings:tracking-tight
|
||||||
|
prose-headings:!mt-6 prose-headings:!mb-4
|
||||||
|
prose-a:text-brand-600 dark:prose-a:text-brand-400 hover:prose-a:text-brand-500
|
||||||
|
prose-img:rounded-2xl prose-hr:border-gray-200 dark:prose-hr:border-gray-800
|
||||||
|
prose-p:leading-relaxed prose-p:mb-4
|
||||||
|
prose-ul:list-disc prose-ul:pl-6 prose-ul:mb-4
|
||||||
|
prose-ol:list-decimal prose-ol:pl-6 prose-ol:mb-4
|
||||||
|
prose-li:mb-1
|
||||||
|
whitespace-pre-wrap">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{activePage.content}</ReactMarkdown>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// View: Index List
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 pb-20">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 pb-20">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
@@ -62,7 +153,7 @@ export default function LegalIndexPage() {
|
|||||||
{pages.map((page) => (
|
{pages.map((page) => (
|
||||||
<Link
|
<Link
|
||||||
key={page.slug}
|
key={page.slug}
|
||||||
href={`/legal/view?slug=${page.slug}`}
|
href={`/legal?page=${page.slug}`} // New URL structure
|
||||||
className="group bg-white dark:bg-gray-800 p-8 rounded-3xl border border-gray-100 dark:border-gray-700 shadow-sm hover:shadow-xl transition-all duration-300 flex items-center justify-between"
|
className="group bg-white dark:bg-gray-800 p-8 rounded-3xl border border-gray-100 dark:border-gray-700 shadow-sm hover:shadow-xl transition-all duration-300 flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -96,3 +187,11 @@ export default function LegalIndexPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function LegalIndexPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<LegalContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useEffect, useState, Suspense } from 'react';
|
|
||||||
import { notFound, useSearchParams } from 'next/navigation';
|
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import remarkGfm from 'remark-gfm';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
|
|
||||||
function LegalPageContent() {
|
|
||||||
const t = useTranslations("Legal");
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const slug = searchParams.get('slug');
|
|
||||||
const [page, setPage] = useState<any>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!slug) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchPage = async () => {
|
|
||||||
try {
|
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
|
||||||
const res = await fetch(`${apiUrl}/api/public/legal-pages/${slug}`, {
|
|
||||||
headers: { 'Accept': 'application/json' }
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const json = await res.json();
|
|
||||||
setPage(json.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchPage();
|
|
||||||
}, [slug]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="w-10 h-10 border-4 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-3xl shadow-xl overflow-hidden border border-gray-100 dark:border-gray-800">
|
|
||||||
<div className="p-8 md:p-12">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="border-b border-gray-200 dark:border-gray-800 pb-8 mb-8">
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-brand-600 to-blue-600 dark:from-brand-400 dark:to-blue-400 mb-4">
|
|
||||||
{page.title}
|
|
||||||
</h1>
|
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<span>{t('version')} {page.version}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{t('last_updated')}: {new Date(page.updated_at).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Marketing/Styled Content */}
|
|
||||||
<article className="prose dark:prose-invert max-w-none
|
|
||||||
prose-headings:font-bold prose-headings:tracking-tight
|
|
||||||
prose-headings:!mt-1 prose-headings:!mb-0
|
|
||||||
prose-a:text-brand-600 dark:prose-a:text-brand-400 hover:prose-a:text-brand-500
|
|
||||||
prose-img:rounded-2xl prose-hr:border-gray-200 dark:prose-hr:border-gray-800
|
|
||||||
prose-p:!leading-tight prose-p:!my-0
|
|
||||||
prose-ul:!my-0 prose-ol:!my-0
|
|
||||||
prose-li:!my-0 prose-li:!leading-tight [&_li_p]:!my-0
|
|
||||||
whitespace-pre-wrap">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{page.content}</ReactMarkdown>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PublicLegalPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<div className="flex items-center justify-center min-h-[400px]"><div className="w-10 h-10 border-4 border-brand-500 border-t-transparent rounded-full animate-spin"></div></div>}>
|
|
||||||
<LegalPageContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -241,7 +241,7 @@ export default function SigninClient() {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-4 text-center text-xs text-gray-500 sm:text-start dark:text-gray-500">
|
<p className="mt-4 text-center text-xs text-gray-500 sm:text-start dark:text-gray-500">
|
||||||
{t('agree_to_text')} <Link href="/legal/view?slug=terms" className="underline hover:text-gray-700 dark:hover:text-gray-300">{t('terms_link')}</Link> {t('and_text')} <Link href="/legal/view?slug=privacy" className="underline hover:text-gray-700 dark:hover:text-gray-300">{t('privacy_policy_link')}</Link>.
|
{t('agree_to_text')} <Link href="/legal?page=terms" className="underline hover:text-gray-700 dark:hover:text-gray-300">{t('terms_link')}</Link> {t('and_text')} <Link href="/legal?page=privacy" className="underline hover:text-gray-700 dark:hover:text-gray-300">{t('privacy_policy_link')}</Link>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export default function SignupClient() {
|
|||||||
className="mr-3 h-5 w-5 rounded-md border-gray-300 dark:border-gray-700 text-brand-500 focus:ring-brand-500"
|
className="mr-3 h-5 w-5 rounded-md border-gray-300 dark:border-gray-700 text-brand-500 focus:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
<p className="inline-block font-normal text-gray-500 dark:text-gray-400">
|
<p className="inline-block font-normal text-gray-500 dark:text-gray-400">
|
||||||
{t('agree_to_signup_text')} <Link href="/legal/view?slug=terms" className="underline hover:text-gray-700 dark:hover:text-gray-300">{t('terms_link')}</Link> {t('and_text')} <Link href="/legal/view?slug=privacy" className="underline hover:text-gray-700 dark:hover:text-gray-300">{t('privacy_policy_link')}</Link>.
|
{t('agree_to_signup_text')} <Link href="/legal?page=terms" className="underline hover:text-gray-700 dark:hover:text-gray-300">{t('terms_link')}</Link> {t('and_text')} <Link href="/legal?page=privacy" className="underline hover:text-gray-700 dark:hover:text-gray-300">{t('privacy_policy_link')}</Link>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -99,7 +99,21 @@ export default function AdminLegalEditorClient({ mode, initialData }: EditorProp
|
|||||||
}
|
}
|
||||||
router.push("/dashboard/admin/legal");
|
router.push("/dashboard/admin/legal");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
addToast(error.response?.data?.message || t("toast_save_failed"), "error");
|
console.error(error);
|
||||||
|
const msg = error.response?.data?.message;
|
||||||
|
if (msg && msg.length < 150) {
|
||||||
|
addToast(msg, "error");
|
||||||
|
} else {
|
||||||
|
addToast(t("toast_error_generic"), "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Validation Errors
|
||||||
|
if (error.response?.status === 422 && error.response?.data?.errors) {
|
||||||
|
// You might want to set form errors state here if you had one
|
||||||
|
// For now just logging or maybe showing first error
|
||||||
|
const firstError = Object.values(error.response.data.errors)[0];
|
||||||
|
if (Array.isArray(firstError)) addToast(firstError[0] as string, "error");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -157,11 +171,11 @@ export default function AdminLegalEditorClient({ mode, initialData }: EditorProp
|
|||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
>
|
>
|
||||||
<option value="" disabled>{t("form_title_placeholder")}</option>
|
<option value="" disabled>{t("form_title_placeholder")}</option>
|
||||||
<option value="Privacy Policy">Privacy Policy</option>
|
<option value="Privacy Policy">{t("page_privacy_policy")}</option>
|
||||||
<option value="Terms and Conditions">Terms and Conditions</option>
|
<option value="Terms and Conditions">{t("page_terms_conditions")}</option>
|
||||||
<option value="Cookie Policy">Cookie Policy</option>
|
<option value="Cookie Policy">{t("page_cookie_policy")}</option>
|
||||||
<option value="Disclaimer">Disclaimer</option>
|
<option value="Disclaimer">{t("page_disclaimer")}</option>
|
||||||
<option value="Acceptable Use Policy">Acceptable Use Policy</option>
|
<option value="Acceptable Use Policy">{t("page_acceptable_use")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -190,7 +204,7 @@ export default function AdminLegalEditorClient({ mode, initialData }: EditorProp
|
|||||||
required
|
required
|
||||||
rows={20}
|
rows={20}
|
||||||
className="w-full px-4 py-4 bg-white dark:bg-gray-900 border-none focus:ring-0 font-mono text-sm text-gray-900 dark:text-gray-300 resize-y"
|
className="w-full px-4 py-4 bg-white dark:bg-gray-900 border-none focus:ring-0 font-mono text-sm text-gray-900 dark:text-gray-300 resize-y"
|
||||||
placeholder="# Privacy Policy\n\nWrite your content in Markdown..."
|
placeholder={t("placeholder_markdown_content")}
|
||||||
value={formData.content}
|
value={formData.content}
|
||||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export default function AdminLegalListClient() {
|
|||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={`/legal/view?slug=${page.slug}`}
|
href={`/legal?page=${page.slug}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="p-1.5 text-gray-400 hover:text-brand-500 hover:bg-brand-50 dark:hover:bg-brand-900/20 rounded-lg transition-colors"
|
className="p-1.5 text-gray-400 hover:text-brand-500 hover:bg-brand-50 dark:hover:bg-brand-900/20 rounded-lg transition-colors"
|
||||||
title={t("view_public")}
|
title={t("view_public")}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function Footer() {
|
|||||||
{legalPages.map((page) => (
|
{legalPages.map((page) => (
|
||||||
<Link
|
<Link
|
||||||
key={page.slug}
|
key={page.slug}
|
||||||
href={`/legal/view?slug=${page.slug}`}
|
href={`/legal?page=${page.slug}`}
|
||||||
className="hover:text-brand-500 transition-colors"
|
className="hover:text-brand-500 transition-colors"
|
||||||
>
|
>
|
||||||
{page.title}
|
{page.title}
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ export const useAuth = ({ middleware, redirectIfAuthenticated }: { middleware?:
|
|||||||
if (middleware === 'auth' && error) logout();
|
if (middleware === 'auth' && error) logout();
|
||||||
}, [user, error, middleware, redirectIfAuthenticated, router]);
|
}, [user, error, middleware, redirectIfAuthenticated, router]);
|
||||||
|
|
||||||
|
const hasRole = (role: string | string[]) => {
|
||||||
|
if (!user) return false;
|
||||||
|
if (Array.isArray(role)) return role.includes(user.role);
|
||||||
|
return user.role === role;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
mutate,
|
mutate,
|
||||||
@@ -112,5 +118,10 @@ export const useAuth = ({ middleware, redirectIfAuthenticated }: { middleware?:
|
|||||||
logout,
|
logout,
|
||||||
forgotPassword,
|
forgotPassword,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
|
hasRole,
|
||||||
|
// Convenience getters
|
||||||
|
isAdmin: user?.role === 'admin',
|
||||||
|
isOwner: user?.role === 'owner',
|
||||||
|
isAdminOrOwner: ['admin', 'owner'].includes(user?.role || ''),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -660,6 +660,13 @@
|
|||||||
"form_slug_placeholder": "e.g. terms-and-conditions",
|
"form_slug_placeholder": "e.g. terms-and-conditions",
|
||||||
"form_content_label": "Document Content (Markdown)",
|
"form_content_label": "Document Content (Markdown)",
|
||||||
"form_content_placeholder": "# Privacy Policy\\n\\nWrite your content in Markdown...",
|
"form_content_placeholder": "# Privacy Policy\\n\\nWrite your content in Markdown...",
|
||||||
|
"placeholder_markdown_content": "# Privacy Policy\n\nWrite your content in Markdown...",
|
||||||
|
"page_privacy_policy": "Privacy Policy",
|
||||||
|
"page_terms_conditions": "Terms and Conditions",
|
||||||
|
"page_cookie_policy": "Cookie Policy",
|
||||||
|
"page_disclaimer": "Disclaimer",
|
||||||
|
"page_acceptable_use": "Acceptable Use Policy",
|
||||||
|
"toast_error_generic": "An error occurred while saving the page.",
|
||||||
"form_summary_label": "Change Log",
|
"form_summary_label": "Change Log",
|
||||||
"form_summary_placeholder": "What changed?",
|
"form_summary_placeholder": "What changed?",
|
||||||
"btn_cancel": "Back to List",
|
"btn_cancel": "Back to List",
|
||||||
|
|||||||
Reference in New Issue
Block a user