Refactor public legal URLs and fix broken links

This commit is contained in:
dyzulk
2025-12-30 18:02:36 +07:00
parent 59160de9ab
commit ca3641e338
9 changed files with 146 additions and 109 deletions

View File

@@ -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>
)
}

View File

@@ -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>
);
}

View File

@@ -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>
</> </>

View File

@@ -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>

View File

@@ -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 })}
/> />

View File

@@ -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")}

View File

@@ -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}

View File

@@ -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 || ''),
}; };
}; };

View File

@@ -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",