mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 14:48:04 +02:00
Référencement : métadonnées complètes, données structurées, sitemap et robots
- Layout : metadataBase, gabarit de titre « %s · Plein R », description, mots-clés, Open Graph / Twitter, robots, icônes, manifeste, viewport et couleur de thème ; JSON-LD Organization + WebSite (recherche annuaire). - Chaque page publique pose titre, description (≤ 160 caractères) et canonique via pageMetadata() ; l'annuaire ignore ainsi ?q=. Fiche adhérent : titre « Nom · Catégorie à Ville », description dérivée, LocalBusiness avec adresse, contacts et horaires (OpeningHoursSpecification), BreadcrumbList. Annuaire : ItemList des adhérents. Association : BusinessEvent par rencontre à venir. Rencontres passées : fil d'Ariane. - Nouvelles routes : robots.txt, sitemap.xml (pages statiques + adhérents actifs), manifest.webmanifest, image de partage 1200×630 générée avec le logo, page 404 dédiée (noindex). - /backend, /login, /inscription/* et les fiches inactives en noindex. - Landmarks <main> sur toutes les pages publiques, fil d'Ariane en <nav>. - serializeJsonLd() échappe <, > et & : les saisies des adhérents ne peuvent pas fermer le bloc <script>. Tests unitaires dans tests/seo.test.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
This commit is contained in:
21 files changed
+1113
-36
No files matched your search
@@ -6,7 +6,17 @@ import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { VitrineImage } from "@/components/VitrineImage";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { getMemberLivePromotions, getPublicMember } from "@/lib/queries";
|
||||
import {
|
||||
NOINDEX,
|
||||
breadcrumbJsonLd,
|
||||
localBusinessJsonLd,
|
||||
memberPath,
|
||||
metaDescription,
|
||||
pageMetadata,
|
||||
} from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
import {
|
||||
formatSlots,
|
||||
getOpenStatus,
|
||||
@@ -86,15 +96,32 @@ function ContactAction({
|
||||
);
|
||||
}
|
||||
|
||||
/** Titre de la fiche : « Nom · Catégorie à Ville », le plus parlant en résultat de recherche. */
|
||||
function memberTitle(member: { name: string; categoryLabel: string | null; city: string | null }) {
|
||||
const where = member.city ? ` à ${member.city}` : " · Bassin de Pompey";
|
||||
return member.categoryLabel ? `${member.name} · ${member.categoryLabel}${where}` : `${member.name}${where}`;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const member = await getPublicMember(Number(id));
|
||||
if (!member) return { title: "Adhérent — Plein R" };
|
||||
return { title: `${member.name} — Plein R`, description: member.description ?? undefined };
|
||||
const memberId = Number(id);
|
||||
const member = memberId ? await getPublicMember(memberId) : null;
|
||||
if (!member || member.status !== "active") {
|
||||
return { title: "Adhérent introuvable", robots: NOINDEX };
|
||||
}
|
||||
const fallback =
|
||||
`${member.name}, ${member.categoryLabel ? `${member.categoryLabel.toLowerCase()} ` : ""}` +
|
||||
`adhérent Plein R${member.city ? ` à ${member.city}` : " sur le Bassin de Pompey"} : coordonnées, horaires et bons plans.`;
|
||||
return pageMetadata({
|
||||
title: memberTitle(member),
|
||||
description: metaDescription(member.description, fallback),
|
||||
path: memberPath(member.id),
|
||||
ogType: "profile",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function FicheAdherentPage({
|
||||
@@ -109,7 +136,7 @@ export default async function FicheAdherentPage({
|
||||
const member = await getPublicMember(memberId);
|
||||
if (!member || member.status !== "active") notFound();
|
||||
|
||||
const promos = await getMemberLivePromotions(memberId);
|
||||
const [promos, baseUrl] = await Promise.all([getMemberLivePromotions(memberId), publicBaseUrl()]);
|
||||
const accent = member.accent ?? "#E0A63C";
|
||||
|
||||
const fullAddress = [member.address, [member.postalCode, member.city].filter(Boolean).join(" ")]
|
||||
@@ -142,15 +169,25 @@ export default async function FicheAdherentPage({
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader active="annuaire" />
|
||||
<JsonLd
|
||||
data={[
|
||||
breadcrumbJsonLd(baseUrl, [
|
||||
{ name: "Accueil", path: "/" },
|
||||
{ name: "Annuaire", path: "/annuaire" },
|
||||
{ name: member.name, path: memberPath(member.id) },
|
||||
]),
|
||||
localBusinessJsonLd(baseUrl, member, hours, normalizedWebsite),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="container" style={{ paddingBottom: 56 }}>
|
||||
<main className="container" style={{ paddingBottom: 56 }}>
|
||||
{/* breadcrumb */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13.5, color: "#9a8d72", padding: "6px 0 18px", flexWrap: "wrap" }}>
|
||||
<nav aria-label="Fil d'Ariane" style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13.5, color: "#9a8d72", padding: "6px 0 18px", flexWrap: "wrap" }}>
|
||||
<Link href="/annuaire" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 600 }}>Annuaire</Link>
|
||||
{member.categoryLabel && (<><span>›</span><span style={{ color: "#9a8d72" }}>{member.categoryLabel}</span></>)}
|
||||
<span>›</span>
|
||||
<span style={{ color: "#3c3322", fontWeight: 600 }}>{member.name}</span>
|
||||
</div>
|
||||
<span style={{ color: "#3c3322", fontWeight: 600 }} aria-current="page">{member.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* cover + identity */}
|
||||
<section style={{ position: "relative", borderRadius: 22, overflow: "hidden", border: "1px solid #e6dcc6" }}>
|
||||
@@ -409,7 +446,7 @@ export default async function FicheAdherentPage({
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
|
||||
@@ -8,15 +8,21 @@ import {
|
||||
getAllCategories,
|
||||
getLiveBadgesByMember,
|
||||
} from "@/lib/queries";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
import { AnnuaireClient, type DirectoryMember } from "./AnnuaireClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Annuaire — Plein R",
|
||||
// Canonique sans paramètre de recherche : /annuaire?q=… ne crée pas de doublon.
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Annuaire des commerçants du Bassin de Pompey",
|
||||
description:
|
||||
"L'annuaire des commerçants et entreprises du Bassin de Pompey. Trouvez un professionnel et ses bons plans.",
|
||||
};
|
||||
"Trouvez un commerçant, artisan ou entreprise du Bassin de Pompey (Pompey, Frouard, Liverdun, " +
|
||||
"Champigneulles…) : coordonnées, horaires et bons plans.",
|
||||
path: "/annuaire",
|
||||
});
|
||||
|
||||
export default async function AnnuairePage({
|
||||
searchParams,
|
||||
@@ -24,10 +30,11 @@ export default async function AnnuairePage({
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
}) {
|
||||
const { q } = await searchParams;
|
||||
const [rawMembers, categories, badges] = await Promise.all([
|
||||
const [rawMembers, categories, badges, baseUrl] = await Promise.all([
|
||||
getActiveMembersWithCategory(),
|
||||
getAllCategories(),
|
||||
getLiveBadgesByMember(),
|
||||
publicBaseUrl(),
|
||||
]);
|
||||
|
||||
const badgeByMember = new Map<number, string | null>();
|
||||
@@ -46,8 +53,17 @@ export default async function AnnuairePage({
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader active="annuaire" logo />
|
||||
<JsonLd
|
||||
data={[
|
||||
breadcrumbJsonLd(baseUrl, [
|
||||
{ name: "Accueil", path: "/" },
|
||||
{ name: "Annuaire", path: "/annuaire" },
|
||||
]),
|
||||
memberListJsonLd(baseUrl, "Annuaire des adhérents Plein R", rawMembers),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="container" style={{ paddingBottom: 56 }}>
|
||||
<main className="container" style={{ paddingBottom: 56 }}>
|
||||
{/* title block */}
|
||||
<section style={{ position: "relative", padding: "14px 0 22px", overflow: "hidden" }}>
|
||||
<Sparkle color="#E0A63C" size={18} style={{ top: 22, right: 30 }} duration={3.2} />
|
||||
@@ -113,7 +129,7 @@ export default async function AnnuairePage({
|
||||
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "14px 26px", borderRadius: 12, whiteSpace: "nowrap" }}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
@@ -13,9 +15,26 @@ import {
|
||||
parsePillars,
|
||||
splitLines,
|
||||
} from "@/lib/site-settings";
|
||||
import { breadcrumbJsonLd, eventJsonLd, pageMetadata } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ASSOCIATION_TITLE = "Plein R, l'association des entreprises du Bassin de Pompey";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
...pageMetadata({
|
||||
title: ASSOCIATION_TITLE,
|
||||
ogTitle: ASSOCIATION_TITLE,
|
||||
description:
|
||||
"Plein R fédère commerçants, artisans et entreprises du Bassin de Pompey : mission, équipe, " +
|
||||
"prochaines rencontres. Rejoignez le réseau économique local.",
|
||||
path: "/association",
|
||||
}),
|
||||
// Le nom de l'association est déjà dans le titre : pas de suffixe « · Plein R ».
|
||||
title: { absolute: ASSOCIATION_TITLE },
|
||||
};
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", {
|
||||
weekday: "long",
|
||||
@@ -30,7 +49,7 @@ function formatTime(date: Date) {
|
||||
}
|
||||
|
||||
export default async function AssociationPage() {
|
||||
const [settings, session] = await Promise.all([getSiteSettings(), auth()]);
|
||||
const [settings, session, baseUrl] = await Promise.all([getSiteSettings(), auth(), publicBaseUrl()]);
|
||||
const now = new Date();
|
||||
const memberId = session?.user.memberId ?? null;
|
||||
|
||||
@@ -108,6 +127,15 @@ export default async function AssociationPage() {
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader active="association" logo />
|
||||
<JsonLd
|
||||
data={[
|
||||
breadcrumbJsonLd(baseUrl, [
|
||||
{ name: "Accueil", path: "/" },
|
||||
{ name: "L'association", path: "/association" },
|
||||
]),
|
||||
...upcoming.map((meeting) => eventJsonLd(baseUrl, meeting)),
|
||||
]}
|
||||
/>
|
||||
|
||||
<main>
|
||||
<section className="container" style={{ paddingTop: 34, paddingBottom: 52 }}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
@@ -7,9 +8,16 @@ import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||
import { isStaff } from "@/lib/rbac";
|
||||
import { saveImageConsent } from "./actions";
|
||||
import { BackendShell } from "./BackendShell";
|
||||
import { NOINDEX } from "@/lib/seo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Espace privé : jamais indexé, même si une page fuyait hors du middleware.
|
||||
export const metadata: Metadata = {
|
||||
title: "Espace adhérent",
|
||||
robots: NOINDEX,
|
||||
};
|
||||
|
||||
export default async function BackendLayout({
|
||||
children,
|
||||
}: {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/components/LegalPage";
|
||||
import { getSiteSettings } from "@/lib/site-settings";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Confidentialité — Plein R",
|
||||
description: "Politique de confidentialité et traitement des données personnelles par Plein R.",
|
||||
};
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Politique de confidentialité",
|
||||
description: "Politique de confidentialité et traitement des données personnelles par l'association Plein R.",
|
||||
path: "/confidentialite",
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { and, asc, eq, gte, sql } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
@@ -6,9 +7,16 @@ import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, meetings, members } from "@/db/schema";
|
||||
import { NOINDEX } from "@/lib/seo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Page transactionnelle : rien à indexer, la rencontre est décrite sur /association.
|
||||
export const metadata: Metadata = {
|
||||
title: "Inscription à une rencontre",
|
||||
robots: NOINDEX,
|
||||
};
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", {
|
||||
weekday: "long",
|
||||
|
||||
+80
-8
@@ -1,4 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import {
|
||||
INDEX_FOLLOW,
|
||||
SITE_DESCRIPTION,
|
||||
SITE_KEYWORDS,
|
||||
SITE_LOCALE,
|
||||
SITE_NAME,
|
||||
SITE_TITLE,
|
||||
THEME_COLOR,
|
||||
organizationJsonLd,
|
||||
webSiteJsonLd,
|
||||
} from "@/lib/seo";
|
||||
import { metadataBase, publicBaseUrl } from "@/lib/seo-server";
|
||||
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
||||
import "./globals.css";
|
||||
|
||||
// Rendu dynamique pour toutes les pages : la CSP à nonce du middleware ne peut
|
||||
@@ -6,17 +20,73 @@ import "./globals.css";
|
||||
// interrogent de toute façon la base.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Plein R — Commerçants & entreprises du Bassin de Pompey",
|
||||
description:
|
||||
"Association des commerçants et entreprises du Bassin de Pompey. Réseau · Rencontre · Réussite.",
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
themeColor: THEME_COLOR,
|
||||
colorScheme: "light",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
/**
|
||||
* Métadonnées par défaut de tout le site. Chaque page complète ou remplace
|
||||
* titre, description et canonique ; `metadataBase` rend absolues les URLs
|
||||
* relatives (canonique, Open Graph, image de partage).
|
||||
*/
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
metadataBase: await metadataBase(),
|
||||
title: {
|
||||
default: SITE_TITLE,
|
||||
template: `%s · ${SITE_NAME}`,
|
||||
},
|
||||
description: SITE_DESCRIPTION,
|
||||
applicationName: SITE_NAME,
|
||||
keywords: SITE_KEYWORDS,
|
||||
authors: [{ name: `Association ${SITE_NAME}` }],
|
||||
creator: `Association ${SITE_NAME}`,
|
||||
publisher: `Association ${SITE_NAME}`,
|
||||
category: "business",
|
||||
referrer: "strict-origin-when-cross-origin",
|
||||
// Pas de canonique ici : chaque page publique pose la sienne via
|
||||
// `pageMetadata()` ; les pages privées (noindex) n'en ont pas.
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: SITE_LOCALE,
|
||||
siteName: SITE_NAME,
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
url: "/",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
},
|
||||
robots: INDEX_FOLLOW,
|
||||
icons: {
|
||||
icon: [{ url: "/assets/logo.png", type: "image/png" }],
|
||||
apple: [{ url: "/assets/logo.png", type: "image/png" }],
|
||||
shortcut: ["/assets/logo.png"],
|
||||
},
|
||||
manifest: "/manifest.webmanifest",
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Données structurées communes : l'association (Organization) et le site
|
||||
// (WebSite + recherche dans l'annuaire). Ne bloque jamais le rendu.
|
||||
const [baseUrl, settings] = await Promise.all([
|
||||
publicBaseUrl(),
|
||||
getSiteSettings().catch(() => null),
|
||||
]);
|
||||
const structuredData = settings
|
||||
? [organizationJsonLd(baseUrl, settings, socialLinks(settings)), webSiteJsonLd(baseUrl)]
|
||||
: [webSiteJsonLd(baseUrl)];
|
||||
|
||||
return (
|
||||
<html lang="fr">
|
||||
<head>
|
||||
@@ -30,13 +100,15 @@ export default function RootLayout({
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,600;12..96,700;12..96,800&family=Public+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="icon" href="/assets/logo.png" type="image/png" />
|
||||
{/* Fallback: if JS is disabled, never keep revealed content hidden. */}
|
||||
<noscript>
|
||||
<style>{`.reveal,.reveal-stagger>*{opacity:1!important}`}</style>
|
||||
</noscript>
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
{children}
|
||||
<JsonLd data={structuredData} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { NOINDEX } from "@/lib/seo";
|
||||
|
||||
// La page de connexion est un composant client : ses métadonnées vivent ici.
|
||||
export const metadata: Metadata = {
|
||||
title: "Connexion à l'espace adhérent",
|
||||
robots: NOINDEX,
|
||||
};
|
||||
|
||||
export default function LoginLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_DESCRIPTION, SITE_NAME, SITE_TITLE, THEME_COLOR } from "@/lib/seo";
|
||||
|
||||
/** Manifeste web : nom, icône et couleurs, pour l'ajout à l'écran d'accueil. */
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: SITE_TITLE,
|
||||
short_name: SITE_NAME,
|
||||
description: SITE_DESCRIPTION,
|
||||
lang: "fr",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#F6F2E8",
|
||||
theme_color: THEME_COLOR,
|
||||
icons: [{ src: "/assets/logo.png", sizes: "any", type: "image/png" }],
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/components/LegalPage";
|
||||
import { getSiteSettings } from "@/lib/site-settings";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Mentions légales — Plein R",
|
||||
description: "Mentions légales du site de l'association Plein R.",
|
||||
};
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Mentions légales",
|
||||
description: "Mentions légales du site de l'association Plein R : éditeur, hébergeur, propriété intellectuelle.",
|
||||
path: "/mentions-legales",
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { NOINDEX } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Page introuvable",
|
||||
robots: NOINDEX,
|
||||
};
|
||||
|
||||
/** 404 publique : renvoie vers l'annuaire plutôt que vers une page blanche. */
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader logo />
|
||||
<main className="container" style={{ paddingTop: 48, paddingBottom: 72, maxWidth: 720 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800, marginBottom: 12 }}>
|
||||
Erreur 404
|
||||
</div>
|
||||
<h1 className="font-display" style={{ margin: "0 0 14px", color: "#26201a", fontSize: "clamp(30px, 6vw, 48px)", lineHeight: 1.05, letterSpacing: "-0.02em" }}>
|
||||
Cette page n'existe pas ou plus
|
||||
</h1>
|
||||
<p style={{ fontSize: 17, lineHeight: 1.6, color: "#6c6150", margin: "0 0 26px" }}>
|
||||
La fiche ou la page demandée a peut-être été retirée. Retrouvez tous les commerçants et
|
||||
entreprises du Bassin de Pompey dans l'annuaire.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
<Link
|
||||
href="/annuaire"
|
||||
className="font-display"
|
||||
style={{ textDecoration: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "13px 24px", borderRadius: 12 }}
|
||||
>
|
||||
Voir l'annuaire
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="font-display"
|
||||
style={{ textDecoration: "none", background: "#fff", border: "1px solid #e6dcc6", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "13px 24px", borderRadius: 12 }}
|
||||
>
|
||||
Retour à l'accueil
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { ImageResponse } from "next/og";
|
||||
import { SITE_NAME } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* Image de partage (Open Graph / Twitter) générée à la volée, 1200×630.
|
||||
* Sert de vignette par défaut à toutes les pages ; les fiches adhérents la
|
||||
* reprennent, leurs logos étant stockés en data-URI (illisibles par les
|
||||
* robots de prévisualisation).
|
||||
*/
|
||||
export const alt = "Plein R — Commerçants & entreprises du Bassin de Pompey";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
/** Logo servi depuis `public/` (copié dans l'image standalone), en data-URI pour Satori. */
|
||||
async function logoDataUri(): Promise<string | null> {
|
||||
try {
|
||||
const file = await readFile(path.join(process.cwd(), "public", "assets", "logo.png"));
|
||||
return `data:image/png;base64,${file.toString("base64")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function OpenGraphImage() {
|
||||
const logo = await logoDataUri();
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
padding: 72,
|
||||
background: "linear-gradient(135deg, #F6F2E8 0%, #efe9da 100%)",
|
||||
color: "#26201a",
|
||||
fontFamily: "sans-serif",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 24 }}>
|
||||
{logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={logo} alt="" width={120} height={120} style={{ objectFit: "contain" }} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 84,
|
||||
height: 84,
|
||||
borderRadius: 24,
|
||||
background: "#E0A63C",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#26201a",
|
||||
fontSize: 48,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
R
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 40, fontWeight: 800, letterSpacing: -1 }}>{SITE_NAME}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div style={{ fontSize: 26, letterSpacing: 6, textTransform: "uppercase", color: "#9a6638", fontWeight: 700 }}>
|
||||
Réseau · Rencontre · Réussite
|
||||
</div>
|
||||
<div style={{ fontSize: 72, fontWeight: 800, lineHeight: 1.02, letterSpacing: -2, display: "flex", flexWrap: "wrap" }}>
|
||||
Le cœur commerçant du
|
||||
<span style={{ color: "#2C6FB3" }}>Bassin de Pompey</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 30, color: "#6c6150", marginTop: 8 }}>
|
||||
Annuaire des commerçants, artisans et entreprises · Bons plans · Rencontres
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 14 }}>
|
||||
{["#E0A63C", "#2C6FB3", "#d8472b", "#1f8a5b"].map((c) => (
|
||||
<div key={c} style={{ width: 64, height: 10, borderRadius: 999, background: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size }
|
||||
);
|
||||
}
|
||||
+15
-2
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Sparkle } from "@/components/Sparkle";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
@@ -10,9 +11,21 @@ import { PromoImage } from "@/components/PromoImage";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import { getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
|
||||
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
||||
import { SITE_DESCRIPTION, SITE_TITLE, pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
...pageMetadata({
|
||||
title: SITE_TITLE,
|
||||
ogTitle: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
path: "/",
|
||||
}),
|
||||
// Titre absolu : la page d'accueil ne prend pas le suffixe « · Plein R ».
|
||||
title: { absolute: SITE_TITLE },
|
||||
};
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
|
||||
@@ -40,7 +53,7 @@ export default async function AccueilPage() {
|
||||
>
|
||||
<SiteHeader active="accueil" />
|
||||
|
||||
<div className="container">
|
||||
<main className="container">
|
||||
{/* hero */}
|
||||
<section
|
||||
className="grid hero-grid"
|
||||
@@ -456,7 +469,7 @@ export default async function AccueilPage() {
|
||||
/>
|
||||
</Reveal>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import { asc, desc, inArray } from "drizzle-orm";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||
import { splitLines } from "@/lib/site-settings";
|
||||
import { breadcrumbJsonLd, pageMetadata } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Rencontres passées de l'association",
|
||||
description:
|
||||
"Retour en images sur les rencontres Plein R : soirées réseau, visites d'entreprises et moments " +
|
||||
"partagés entre entrepreneurs du Bassin de Pompey.",
|
||||
path: "/rencontres-passees",
|
||||
});
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "long", year: "numeric" }).format(date);
|
||||
}
|
||||
|
||||
export default async function PastMeetingsPage() {
|
||||
const [archives, photos] = await Promise.all([
|
||||
const [archives, photos, baseUrl] = await Promise.all([
|
||||
db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate)),
|
||||
db.select().from(pastMeetingPhotos).orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id)),
|
||||
publicBaseUrl(),
|
||||
]);
|
||||
const linkedIds = archives.map((archive) => archive.meetingId).filter((id): id is number => id !== null);
|
||||
const linkedRegistrations = linkedIds.length > 0
|
||||
@@ -37,6 +50,13 @@ export default async function PastMeetingsPage() {
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader active="association" logo />
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd(baseUrl, [
|
||||
{ name: "Accueil", path: "/" },
|
||||
{ name: "L'association", path: "/association" },
|
||||
{ name: "Rencontres passées", path: "/rencontres-passees" },
|
||||
])}
|
||||
/>
|
||||
<main>
|
||||
<section style={{ background: "#13324F", color: "#fff" }}>
|
||||
<div className="container" style={{ paddingTop: 58, paddingBottom: 54 }}>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { PRIVATE_PATHS, absoluteUrl } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** /robots.txt : tout le site public est explorable, l'espace privé exclu. */
|
||||
export default async function robots(): Promise<MetadataRoute.Robots> {
|
||||
const base = await publicBaseUrl();
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: PRIVATE_PATHS,
|
||||
},
|
||||
],
|
||||
sitemap: base ? absoluteUrl(base, "/sitemap.xml") : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { members } from "@/db/schema";
|
||||
import { STATIC_ROUTES, absoluteUrl, memberPath } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** /sitemap.xml : pages publiques + une entrée par adhérent actif. */
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const base = await publicBaseUrl();
|
||||
|
||||
// Pas de `lastModified` : sans date de mise à jour fiable en base, mieux vaut
|
||||
// l'omettre qu'annoncer une valeur que les moteurs finiraient par ignorer.
|
||||
const statics: MetadataRoute.Sitemap = STATIC_ROUTES.map((route) => ({
|
||||
url: absoluteUrl(base, route.path),
|
||||
changeFrequency: route.changeFrequency,
|
||||
priority: route.priority,
|
||||
}));
|
||||
|
||||
let rows: { id: number }[] = [];
|
||||
try {
|
||||
rows = await db
|
||||
.select({ id: members.id })
|
||||
.from(members)
|
||||
.where(eq(members.status, "active"))
|
||||
.orderBy(asc(members.id));
|
||||
} catch {
|
||||
// Base indisponible : on livre au moins les pages statiques.
|
||||
}
|
||||
|
||||
const fiches: MetadataRoute.Sitemap = rows.map((m) => ({
|
||||
url: absoluteUrl(base, memberPath(m.id)),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.7,
|
||||
}));
|
||||
|
||||
return [...statics, ...fiches];
|
||||
}
|
||||
Reference in new issue
Block a user