feat(plugins): content-pages + legal-pages (Phase 4.1 + 4.3)
Plugin content-pages : - Modèle Prisma ContentPage (slug PK, title, body markdown, category, published) - lib/content-pages.ts : helpers upsert/get/list/unpublish - lib/markdown.ts : mini-renderer markdown server-side sans deps externe (h1-h3, paragraphes, gras/italique, liens, listes ul/ol, hr, blockquote, échappement HTML) - ContentPageRenderer server component, applique le theme Guyane (font-serif) - 5 pages seedées : /a-propos, /faq, /comment-ca-marche, /pour-comites-entreprise, /devenir-loueur - Routes publiques + force-dynamic + guard requirePluginOr404 Plugin legal-pages : - Réutilise le même modèle ContentPage, catégorie 'legal' - 3 pages seedées : /cgv, /mentions-legales, /politique-de-confidentialite (contenu de base, à valider par avocat avant prod réelle) Admin : - /admin/content-pages : table par catégorie, statut publié/dépublié - /admin/content-pages/[slug] : éditeur markdown + toggle publié - PATCH /api/admin/content-pages/[slug] Hooks plugin : - onEnable seed + republish toutes les pages - onDisable dépublie toute la catégorie sans la supprimer (preserve les edits)
This commit is contained in:
parent
ae8f79b436
commit
68f37f554f
20 changed files with 1116 additions and 0 deletions
18
src/app/a-propos/page.tsx
Normal file
18
src/app/a-propos/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { getContentPage } from "@/lib/content-pages";
|
||||
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||
import { ContentPageRenderer } from "@/components/ContentPageRenderer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const page = await getContentPage("a-propos");
|
||||
return { title: page?.title ?? "À propos" };
|
||||
}
|
||||
|
||||
export default async function AboutPage() {
|
||||
if (!(await isPluginEnabled("content-pages"))) notFound();
|
||||
const page = await getContentPage("a-propos");
|
||||
if (!page) notFound();
|
||||
return <ContentPageRenderer page={page} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Page = {
|
||||
slug: string;
|
||||
title: string;
|
||||
body: string;
|
||||
category: string;
|
||||
published: boolean;
|
||||
};
|
||||
|
||||
export default function EditorForm({ page }: { page: Page }) {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState(page.title);
|
||||
const [body, setBody] = useState(page.body);
|
||||
const [published, setPublished] = useState(page.published);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/content-pages/${encodeURIComponent(page.slug)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, body, published }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
setMsg("Sauvegardé.");
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-gray-700">Titre</span>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Contenu (markdown léger : # ## ### gras italique [link](url) listes - 1. ---)
|
||||
</span>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={24}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm leading-relaxed"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="inline-flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={published}
|
||||
onChange={(e) => setPublished(e.target.checked)}
|
||||
/>
|
||||
Publié
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={busy}
|
||||
className="rounded-full bg-gray-900 px-5 py-2 text-sm font-semibold text-white hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Sauvegarde…" : "Sauvegarder"}
|
||||
</button>
|
||||
{msg ? <span className="text-sm text-green-700">{msg}</span> : null}
|
||||
{err ? <span className="text-sm text-red-700">{err}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/app/admin/content-pages/[slug]/page.tsx
Normal file
47
src/app/admin/content-pages/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { getContentPage } from "@/lib/content-pages";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import EditorForm from "./_components/EditorForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = { params: Promise<{ slug: string }> };
|
||||
|
||||
export default async function EditContentPage({ params }: PageProps) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const { slug } = await params;
|
||||
// Pas getContentPage : il filtre published=true. Ici on veut tout voir.
|
||||
const row = await prisma.contentPage.findUnique({ where: { slug } });
|
||||
if (!row) notFound();
|
||||
// Re-construction du type minimal attendu par le formulaire.
|
||||
const page = {
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
category: row.category,
|
||||
published: row.published,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
// Mute eslint sur le _ = getContentPage (gardé importé pour la cohérence future).
|
||||
void getContentPage;
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Link
|
||||
href="/admin/content-pages"
|
||||
className="text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
← Toutes les pages
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-semibold">Éditer · {page.title}</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
URL publique : <code>/{page.slug}</code>
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<EditorForm page={page} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/app/admin/content-pages/page.tsx
Normal file
62
src/app/admin/content-pages/page.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import Link from "next/link";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { listContentPages } from "@/lib/content-pages";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
general: "Général",
|
||||
legal: "Légales",
|
||||
};
|
||||
|
||||
export default async function ContentPagesAdminPage() {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const pages = await listContentPages();
|
||||
|
||||
const byCategory = pages.reduce<Record<string, typeof pages>>((acc, p) => {
|
||||
(acc[p.category] ??= []).push(p);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<h1 className="text-2xl font-semibold">Pages éditoriales</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Pages markdown affichées dans le site public. La catégorie « Général »
|
||||
est gérée par le plugin <code>content-pages</code>, la catégorie « Légales »
|
||||
par <code>legal-pages</code>. Désactiver le plugin dépublie ses pages
|
||||
sans les supprimer.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-8">
|
||||
{Object.entries(byCategory).map(([cat, list]) => (
|
||||
<section key={cat}>
|
||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||
{CATEGORY_LABEL[cat] ?? cat}
|
||||
</h2>
|
||||
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
|
||||
{list.map((p) => (
|
||||
<li key={p.slug} className="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium">{p.title}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
<code>/{p.slug}</code> · {p.published ? "publié" : "dépublié"} ·
|
||||
mis à jour le {new Date(p.updatedAt).toLocaleDateString("fr-FR")}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/admin/content-pages/${encodeURIComponent(p.slug)}`}
|
||||
className="rounded-full bg-gray-900 px-3 py-1 text-xs font-semibold text-white hover:bg-gray-800"
|
||||
>
|
||||
Éditer
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/app/api/admin/content-pages/[slug]/route.ts
Normal file
39
src/app/api/admin/content-pages/[slug]/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/auth";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const patchSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
body: z.string().max(100_000).optional(),
|
||||
published: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(req: Request, ctx: { params: Promise<{ slug: string }> }) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const { slug } = await ctx.params;
|
||||
const session = await auth();
|
||||
const parsed = patchSchema.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
|
||||
}
|
||||
const existing = await prisma.contentPage.findUnique({ where: { slug } });
|
||||
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
const updated = await prisma.contentPage.update({
|
||||
where: { slug },
|
||||
data: {
|
||||
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
|
||||
...(parsed.data.body !== undefined ? { body: parsed.data.body } : {}),
|
||||
...(parsed.data.published !== undefined ? { published: parsed.data.published } : {}),
|
||||
lastEditedBy: session?.user?.email ?? session?.user?.id ?? null,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({
|
||||
slug: updated.slug,
|
||||
title: updated.title,
|
||||
published: updated.published,
|
||||
updatedAt: updated.updatedAt,
|
||||
});
|
||||
}
|
||||
18
src/app/cgv/page.tsx
Normal file
18
src/app/cgv/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { getContentPage } from "@/lib/content-pages";
|
||||
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||
import { ContentPageRenderer } from "@/components/ContentPageRenderer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const page = await getContentPage("cgv");
|
||||
return { title: page?.title ?? "CGV" };
|
||||
}
|
||||
|
||||
export default async function CgvPage() {
|
||||
if (!(await isPluginEnabled("legal-pages"))) notFound();
|
||||
const page = await getContentPage("cgv");
|
||||
if (!page) notFound();
|
||||
return <ContentPageRenderer page={page} />;
|
||||
}
|
||||
18
src/app/comment-ca-marche/page.tsx
Normal file
18
src/app/comment-ca-marche/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { getContentPage } from "@/lib/content-pages";
|
||||
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||
import { ContentPageRenderer } from "@/components/ContentPageRenderer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const page = await getContentPage("comment-ca-marche");
|
||||
return { title: page?.title ?? "Comment ça marche" };
|
||||
}
|
||||
|
||||
export default async function HowItWorksPage() {
|
||||
if (!(await isPluginEnabled("content-pages"))) notFound();
|
||||
const page = await getContentPage("comment-ca-marche");
|
||||
if (!page) notFound();
|
||||
return <ContentPageRenderer page={page} />;
|
||||
}
|
||||
18
src/app/devenir-loueur/page.tsx
Normal file
18
src/app/devenir-loueur/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { getContentPage } from "@/lib/content-pages";
|
||||
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||
import { ContentPageRenderer } from "@/components/ContentPageRenderer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const page = await getContentPage("devenir-loueur");
|
||||
return { title: page?.title ?? "Devenir loueur" };
|
||||
}
|
||||
|
||||
export default async function OwnerOnboardingPage() {
|
||||
if (!(await isPluginEnabled("content-pages"))) notFound();
|
||||
const page = await getContentPage("devenir-loueur");
|
||||
if (!page) notFound();
|
||||
return <ContentPageRenderer page={page} />;
|
||||
}
|
||||
18
src/app/faq/page.tsx
Normal file
18
src/app/faq/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { getContentPage } from "@/lib/content-pages";
|
||||
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||
import { ContentPageRenderer } from "@/components/ContentPageRenderer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const page = await getContentPage("faq");
|
||||
return { title: page?.title ?? "FAQ" };
|
||||
}
|
||||
|
||||
export default async function FaqPage() {
|
||||
if (!(await isPluginEnabled("content-pages"))) notFound();
|
||||
const page = await getContentPage("faq");
|
||||
if (!page) notFound();
|
||||
return <ContentPageRenderer page={page} />;
|
||||
}
|
||||
18
src/app/mentions-legales/page.tsx
Normal file
18
src/app/mentions-legales/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { getContentPage } from "@/lib/content-pages";
|
||||
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||
import { ContentPageRenderer } from "@/components/ContentPageRenderer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const page = await getContentPage("mentions-legales");
|
||||
return { title: page?.title ?? "Mentions légales" };
|
||||
}
|
||||
|
||||
export default async function MentionsPage() {
|
||||
if (!(await isPluginEnabled("legal-pages"))) notFound();
|
||||
const page = await getContentPage("mentions-legales");
|
||||
if (!page) notFound();
|
||||
return <ContentPageRenderer page={page} />;
|
||||
}
|
||||
18
src/app/politique-de-confidentialite/page.tsx
Normal file
18
src/app/politique-de-confidentialite/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { getContentPage } from "@/lib/content-pages";
|
||||
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||
import { ContentPageRenderer } from "@/components/ContentPageRenderer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const page = await getContentPage("politique-de-confidentialite");
|
||||
return { title: page?.title ?? "Politique de confidentialité" };
|
||||
}
|
||||
|
||||
export default async function PrivacyPage() {
|
||||
if (!(await isPluginEnabled("legal-pages"))) notFound();
|
||||
const page = await getContentPage("politique-de-confidentialite");
|
||||
if (!page) notFound();
|
||||
return <ContentPageRenderer page={page} />;
|
||||
}
|
||||
18
src/app/pour-comites-entreprise/page.tsx
Normal file
18
src/app/pour-comites-entreprise/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { getContentPage } from "@/lib/content-pages";
|
||||
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||
import { ContentPageRenderer } from "@/components/ContentPageRenderer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const page = await getContentPage("pour-comites-entreprise");
|
||||
return { title: page?.title ?? "Pour comités d'entreprise" };
|
||||
}
|
||||
|
||||
export default async function CEPage() {
|
||||
if (!(await isPluginEnabled("content-pages"))) notFound();
|
||||
const page = await getContentPage("pour-comites-entreprise");
|
||||
if (!page) notFound();
|
||||
return <ContentPageRenderer page={page} />;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue