mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-13 18:29:03 +02:00
SEO : URLs de fiche parlantes, pages métier indexables, aides de rédaction
- Fiches adhérents : URL canonique /adherents/12-au-bon-pain-frouard (memberPath). L'identifiant en tête suffit ; /adherents/12 et tout slug périmé sont redirigés de façon permanente vers la forme canonique, donc les anciens liens et les partages sociaux restent valides. Tous les liens (accueil, carrousel, annuaire, JSON-LD, sitemap, publications Facebook / LinkedIn) passent par memberPath. - Pages métier /annuaire/[categorie] : une page indexable par catégorie avec titre, introduction (effectif, communes), grille d'adhérents, fil d'Ariane et ItemList. Sans adhérent : noindex et hors sitemap. Section « Parcourir par métier » sur l'annuaire et « Autres métiers » sur chaque page pour le maillage interne. - Composants partagés MemberCard et CategoryLinks ; slugify déplacé dans src/lib/slug.ts. - Backend : conseils de rédaction sous les champs description et tags des formulaires adhérent (espace adhérent et fiche staff). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
This commit is contained in:
19 files changed
+489
-99
No files matched your search
@@ -137,6 +137,14 @@ site sans traitement supplémentaire.
|
||||
`app/manifest.ts` et `app/opengraph-image.tsx` (vignette 1200×630 générée).
|
||||
- `/backend`, `/login`, `/inscription/*` et les fiches non actives sont en
|
||||
`NOINDEX` ; les pages publiques utilisent `<main>` et un seul `<h1>`.
|
||||
- **URLs de fiche** : `memberPath({ id, name, city })` donne
|
||||
`/adherents/12-au-bon-pain-frouard`. L'identifiant en tête suffit
|
||||
(`parseMemberParam`), toute autre écriture est redirigée en 301 vers la forme
|
||||
canonique par la page : ne construisez jamais `/adherents/${id}` à la main.
|
||||
- **Pages métier** : `/annuaire/[categorie]` (slug de `categories`) rend une page
|
||||
indexable par activité (titre, intro, grille `MemberCard`, `ItemList`) ;
|
||||
sans adhérent elle passe en `NOINDEX` et sort du sitemap. L'annuaire et ces
|
||||
pages listent les métiers via `CategoryLinks` pour le maillage interne.
|
||||
|
||||
## Roles
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, permanentRedirect } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { Sparkle } from "@/components/Sparkle";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
memberPath,
|
||||
metaDescription,
|
||||
pageMetadata,
|
||||
parseMemberParam,
|
||||
} from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
import {
|
||||
@@ -108,7 +109,7 @@ export async function generateMetadata({
|
||||
params: Promise<{ id: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const memberId = Number(id);
|
||||
const memberId = parseMemberParam(id);
|
||||
const member = memberId ? await getPublicMember(memberId) : null;
|
||||
if (!member || member.status !== "active") {
|
||||
return { title: "Adhérent introuvable", robots: NOINDEX };
|
||||
@@ -119,7 +120,7 @@ export async function generateMetadata({
|
||||
return pageMetadata({
|
||||
title: memberTitle(member),
|
||||
description: metaDescription(member.description, fallback),
|
||||
path: memberPath(member.id),
|
||||
path: memberPath(member),
|
||||
ogType: "profile",
|
||||
});
|
||||
}
|
||||
@@ -130,12 +131,17 @@ export default async function FicheAdherentPage({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const memberId = Number(id);
|
||||
const memberId = parseMemberParam(id);
|
||||
if (!memberId) notFound();
|
||||
|
||||
const member = await getPublicMember(memberId);
|
||||
if (!member || member.status !== "active") notFound();
|
||||
|
||||
// Une seule URL par fiche : l'ancienne forme `/adherents/12` et tout slug
|
||||
// périmé (renommage, changement de commune) sont redirigés définitivement.
|
||||
const canonical = memberPath(member);
|
||||
if (`/adherents/${id}` !== canonical) permanentRedirect(canonical);
|
||||
|
||||
const [promos, baseUrl] = await Promise.all([getMemberLivePromotions(memberId), publicBaseUrl()]);
|
||||
const accent = member.accent ?? "#E0A63C";
|
||||
|
||||
@@ -174,7 +180,7 @@ export default async function FicheAdherentPage({
|
||||
breadcrumbJsonLd(baseUrl, [
|
||||
{ name: "Accueil", path: "/" },
|
||||
{ name: "Annuaire", path: "/annuaire" },
|
||||
{ name: member.name, path: memberPath(member.id) },
|
||||
{ name: member.name, path: canonical },
|
||||
]),
|
||||
localBusinessJsonLd(baseUrl, member, hours, normalizedWebsite),
|
||||
]}
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { VitrineImage } from "@/components/VitrineImage";
|
||||
import { MemberCard, type MemberCardData } from "@/components/MemberCard";
|
||||
|
||||
export type DirectoryMember = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
city: string | null;
|
||||
address: string | null;
|
||||
coverUrl: string | null;
|
||||
logoUrl: string | null;
|
||||
categoryLabel: string | null;
|
||||
accent: string | null;
|
||||
export type DirectoryMember = MemberCardData & {
|
||||
hasPromo: boolean;
|
||||
promoBadge: string | null;
|
||||
};
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
const STRIPE_COOL =
|
||||
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 12px,#e2e8e6 12px,#e2e8e6 24px)";
|
||||
|
||||
export function AnnuaireClient({
|
||||
members,
|
||||
categories,
|
||||
@@ -190,46 +175,9 @@ export function AnnuaireClient({
|
||||
{/* grid / empty */}
|
||||
{list.length > 0 ? (
|
||||
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||
{list.map((m, i) => {
|
||||
return (
|
||||
<Link
|
||||
key={m.id}
|
||||
href={`/adherents/${m.id}`}
|
||||
className="lift-card"
|
||||
style={{ textDecoration: "none", color: "inherit", background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, overflow: "hidden", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<VitrineImage
|
||||
coverUrl={m.coverUrl}
|
||||
logoUrl={m.logoUrl}
|
||||
height={150}
|
||||
stripe={i % 2 === 0 ? STRIPE_WARM : STRIPE_COOL}
|
||||
>
|
||||
{m.categoryLabel && (
|
||||
<span style={{ position: "absolute", top: 12, left: 12, background: m.accent ?? "#9a6638", color: "#fff", borderRadius: 999, padding: "5px 12px", fontSize: 11, fontWeight: 700 }}>
|
||||
{m.categoryLabel}
|
||||
</span>
|
||||
)}
|
||||
{m.hasPromo && m.promoBadge && (
|
||||
<span className="font-display" style={{ position: "absolute", top: 0, right: 0, background: "#d8472b", color: "#fff", fontWeight: 800, fontSize: 12.5, padding: "6px 12px", borderRadius: "0 0 0 14px" }}>
|
||||
{m.promoBadge}
|
||||
</span>
|
||||
)}
|
||||
</VitrineImage>
|
||||
<div style={{ padding: "16px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
|
||||
{m.name}
|
||||
</h3>
|
||||
<p style={{ margin: "6px 0 12px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
|
||||
{m.description}
|
||||
</p>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, color: "#6c6150", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C" }} />
|
||||
{[m.address, m.city].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{list.map((m, i) => (
|
||||
<MemberCard key={m.id} m={m} index={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ background: "#fff", border: "1px dashed #d8cdb4", borderRadius: 18, padding: 54, textAlign: "center" }}>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CategoryLinks } from "@/components/CategoryLinks";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { MemberCard } from "@/components/MemberCard";
|
||||
import { MembershipModalButton } from "@/components/MembershipModalButton";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { Sparkle } from "@/components/Sparkle";
|
||||
import {
|
||||
getActiveMembersByCategory,
|
||||
getCategoriesInUse,
|
||||
getCategoryBySlug,
|
||||
getLiveBadgesByMember,
|
||||
} from "@/lib/queries";
|
||||
import {
|
||||
NOINDEX,
|
||||
breadcrumbJsonLd,
|
||||
categoryPath,
|
||||
memberListJsonLd,
|
||||
metaDescription,
|
||||
pageMetadata,
|
||||
} from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = Promise<{ categorie: string }>;
|
||||
|
||||
/** Communes représentées, dans l'ordre d'effectif, pour le texte d'introduction. */
|
||||
function listCities(members: { city: string | null }[], max = 4): string[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const m of members) if (m.city) counts.set(m.city, (counts.get(m.city) ?? 0) + 1);
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "fr"))
|
||||
.slice(0, max)
|
||||
.map(([city]) => city);
|
||||
}
|
||||
|
||||
function joinFr(items: string[]): string {
|
||||
if (items.length <= 1) return items.join("");
|
||||
return `${items.slice(0, -1).join(", ")} et ${items[items.length - 1]}`;
|
||||
}
|
||||
|
||||
/** Titre et introduction d'une page métier, réutilisés par les métadonnées et le rendu. */
|
||||
function categoryCopy(label: string, members: { city: string | null }[]) {
|
||||
const n = members.length;
|
||||
const cities = listCities(members);
|
||||
const where = cities.length > 0 ? ` à ${joinFr(cities)}` : " sur le Bassin de Pompey";
|
||||
const title = `${label} sur le Bassin de Pompey`;
|
||||
const intro =
|
||||
n === 0
|
||||
? `Aucun adhérent Plein R n'est encore référencé dans la catégorie ${label}. Vous exercez ce métier sur le Bassin de Pompey ? Rejoignez l'association pour apparaître ici.`
|
||||
: `${n} ${n > 1 ? "professionnels adhérents" : "professionnel adhérent"} Plein R dans la catégorie ${label}${where} : coordonnées, horaires d'ouverture et bons plans du moment. Des commerçants et entreprises de proximité, membres du réseau économique du Bassin de Pompey.`;
|
||||
return { title, intro };
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
||||
const { categorie } = await params;
|
||||
const category = await getCategoryBySlug(categorie);
|
||||
if (!category) return { title: "Catégorie introuvable", robots: NOINDEX };
|
||||
const members = await getActiveMembersByCategory(category.id);
|
||||
const { title, intro } = categoryCopy(category.label, members);
|
||||
return pageMetadata({
|
||||
title,
|
||||
description: metaDescription(intro),
|
||||
path: categoryPath(category.slug),
|
||||
// Une catégorie sans adhérent n'a rien à offrir aux moteurs : on la laisse
|
||||
// visible pour les visiteurs, mais hors index.
|
||||
robots: members.length === 0 ? NOINDEX : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function CategoriePage({ params }: { params: Params }) {
|
||||
const { categorie } = await params;
|
||||
const category = await getCategoryBySlug(categorie);
|
||||
if (!category) notFound();
|
||||
|
||||
const [rawMembers, badges, categoriesInUse, baseUrl] = await Promise.all([
|
||||
getActiveMembersByCategory(category.id),
|
||||
getLiveBadgesByMember(),
|
||||
getCategoriesInUse(),
|
||||
publicBaseUrl(),
|
||||
]);
|
||||
const badgeByMember = new Map<number, string | null>();
|
||||
for (const b of badges) {
|
||||
if (b.memberId != null && !badgeByMember.has(b.memberId)) badgeByMember.set(b.memberId, b.badge);
|
||||
}
|
||||
const members = rawMembers.map((m) => ({
|
||||
...m,
|
||||
hasPromo: badgeByMember.has(m.id),
|
||||
promoBadge: badgeByMember.get(m.id) ?? null,
|
||||
}));
|
||||
const { title, intro } = categoryCopy(category.label, members);
|
||||
const accent = category.accent || "#E0A63C";
|
||||
|
||||
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" },
|
||||
{ name: category.label, path: categoryPath(category.slug) },
|
||||
]),
|
||||
memberListJsonLd(baseUrl, title, members),
|
||||
]}
|
||||
/>
|
||||
|
||||
<main className="container" style={{ paddingBottom: 56 }}>
|
||||
<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>
|
||||
<span>›</span>
|
||||
<span style={{ color: "#3c3322", fontWeight: 600 }} aria-current="page">{category.label}</span>
|
||||
</nav>
|
||||
|
||||
<section style={{ position: "relative", padding: "6px 0 22px", overflow: "hidden" }}>
|
||||
<Sparkle color={accent} size={18} style={{ top: 12, right: 30 }} duration={3.2} />
|
||||
<Sparkle color="#6FB0C6" size={12} style={{ top: 52, right: 90 }} duration={2.7} delay={0.5} />
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
background: category.tint || "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
color: "#9a6638",
|
||||
fontSize: 12,
|
||||
letterSpacing: "0.12em",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 800,
|
||||
padding: "6px 13px",
|
||||
borderRadius: 999,
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: accent }} />
|
||||
{members.length} {members.length > 1 ? "adhérents" : "adhérent"}
|
||||
</div>
|
||||
<h1 className="font-display" style={{ fontWeight: 800, fontSize: "clamp(26px, 6vw, 40px)", letterSpacing: "-0.02em", margin: "0 0 10px", color: "#26201a" }}>
|
||||
{title}
|
||||
</h1>
|
||||
<p style={{ margin: 0, fontSize: 16, color: "#6c6150", maxWidth: 680, lineHeight: 1.6 }}>{intro}</p>
|
||||
</section>
|
||||
|
||||
{members.length > 0 ? (
|
||||
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||
{members.map((m, i) => (
|
||||
<MemberCard key={m.id} m={m} index={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ background: "#fff", border: "1px dashed #d8cdb4", borderRadius: 18, padding: 44, textAlign: "center" }}>
|
||||
<div className="font-display" style={{ fontWeight: 700, fontSize: 19, color: "#26201a", marginBottom: 14 }}>
|
||||
Soyez le premier référencé dans cette catégorie
|
||||
</div>
|
||||
<MembershipModalButton
|
||||
label="Adhérer à l'association"
|
||||
className="font-display"
|
||||
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15, padding: "12px 22px", borderRadius: 12 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categoriesInUse.length > 0 && (
|
||||
<section style={{ marginTop: 34, background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, padding: "22px 24px" }}>
|
||||
<h2 className="font-display" style={{ fontWeight: 700, fontSize: 19, margin: "0 0 14px", color: "#26201a" }}>
|
||||
Autres métiers de l'annuaire
|
||||
</h2>
|
||||
<CategoryLinks categories={categoriesInUse} currentSlug={category.slug} />
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import { MembershipModalButton } from "@/components/MembershipModalButton";
|
||||
import {
|
||||
getActiveMembersWithCategory,
|
||||
getAllCategories,
|
||||
getCategoriesInUse,
|
||||
getLiveBadgesByMember,
|
||||
} from "@/lib/queries";
|
||||
import { CategoryLinks } from "@/components/CategoryLinks";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
@@ -30,11 +32,12 @@ export default async function AnnuairePage({
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
}) {
|
||||
const { q } = await searchParams;
|
||||
const [rawMembers, categories, badges, baseUrl] = await Promise.all([
|
||||
const [rawMembers, categories, badges, baseUrl, categoriesInUse] = await Promise.all([
|
||||
getActiveMembersWithCategory(),
|
||||
getAllCategories(),
|
||||
getLiveBadgesByMember(),
|
||||
publicBaseUrl(),
|
||||
getCategoriesInUse(),
|
||||
]);
|
||||
|
||||
const badgeByMember = new Map<number, string | null>();
|
||||
@@ -98,6 +101,19 @@ export default async function AnnuairePage({
|
||||
|
||||
<AnnuaireClient members={members} categories={categories} initialQuery={q ?? ""} />
|
||||
|
||||
{/* Pages métier : une URL indexable par catégorie, en plus du filtre. */}
|
||||
{categoriesInUse.length > 0 && (
|
||||
<section style={{ marginTop: 34, background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, padding: "22px 24px" }}>
|
||||
<h2 className="font-display" style={{ fontWeight: 700, fontSize: 19, margin: "0 0 6px", color: "#26201a" }}>
|
||||
Parcourir par métier
|
||||
</h2>
|
||||
<p style={{ margin: "0 0 14px", fontSize: 14, color: "#6c6150" }}>
|
||||
Tous les commerçants, artisans et entreprises du Bassin de Pompey, classés par activité.
|
||||
</p>
|
||||
<CategoryLinks categories={categoriesInUse} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* join CTA */}
|
||||
<section
|
||||
style={{
|
||||
|
||||
@@ -10,6 +10,7 @@ import { signOut } from "@/auth";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { CATEGORY_PALETTE } from "@/db/categories";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import {
|
||||
activityLog,
|
||||
categories,
|
||||
@@ -45,15 +46,6 @@ import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
|
||||
import type { AppRole } from "@/types/next-auth";
|
||||
|
||||
|
||||
function slugify(input: string): string {
|
||||
return input
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 70);
|
||||
}
|
||||
|
||||
async function logActivity(message: string, dot = "#2C6FB3") {
|
||||
// Le message agrège des saisies de tiers : on ne laisse passer que <strong>.
|
||||
@@ -127,7 +119,7 @@ function revalidatePromoPaths(memberId?: number | null) {
|
||||
revalidatePath("/backend");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/annuaire");
|
||||
if (memberId) revalidatePath(`/adherents/${memberId}`);
|
||||
if (memberId) revalidatePath("/adherents/[id]", "page");
|
||||
}
|
||||
|
||||
/** Réseaux demandés pour une promo, dans l'ordre d'affichage. */
|
||||
@@ -168,6 +160,7 @@ async function publishPromoShares(
|
||||
imageUrl: promotions.imageUrl,
|
||||
memberId: promotions.memberId,
|
||||
memberName: members.name,
|
||||
memberCity: members.city,
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
@@ -550,7 +543,7 @@ export async function updateMember(formData: FormData) {
|
||||
.where(eq(members.id, id));
|
||||
|
||||
revalidatePath("/backend/adherents");
|
||||
revalidatePath(`/adherents/${id}`);
|
||||
revalidatePath("/adherents/[id]", "page");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
@@ -1104,7 +1097,7 @@ export async function updateOwnProfile(formData: FormData) {
|
||||
.where(eq(members.id, memberId));
|
||||
|
||||
revalidatePath("/backend/espace");
|
||||
revalidatePath(`/adherents/${memberId}`);
|
||||
revalidatePath("/adherents/[id]", "page");
|
||||
revalidatePath("/annuaire");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
@@ -134,11 +134,19 @@ export default async function EditMemberPage({
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
|
||||
<textarea name="description" rows={4} defaultValue={member.description ?? ""} className="field" style={{ resize: "vertical" }} />
|
||||
<p className="field-hint">
|
||||
Ce texte est lu par Google : indiquez votre métier, votre commune et vos spécialités en trois à cinq
|
||||
phrases (ex. « Boulangerie artisanale à Frouard : pain au levain, viennoiseries, pâtisseries maison »).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
|
||||
<input name="tags" defaultValue={member.tags ?? ""} className="field" placeholder="Levain naturel, Produits locaux, Fait maison" />
|
||||
<p className="field-hint">
|
||||
Mots-clés que vos clients recherchent : produits, services, marques, quartier. Ils s'affichent sur
|
||||
votre fiche et sont transmis aux moteurs de recherche.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
|
||||
@@ -231,11 +231,19 @@ export default async function EspacePage() {
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
|
||||
<textarea name="description" rows={4} defaultValue={profile.description ?? ""} className="field" style={{ resize: "vertical" }} />
|
||||
<p className="field-hint">
|
||||
Ce texte est lu par Google : indiquez votre métier, votre commune et vos spécialités en trois à cinq
|
||||
phrases (ex. « Boulangerie artisanale à Frouard : pain au levain, viennoiseries, pâtisseries maison »).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
|
||||
<input name="tags" defaultValue={profile.tags ?? ""} className="field" />
|
||||
<input name="tags" defaultValue={profile.tags ?? ""} className="field" placeholder="Levain naturel, Produits locaux, Fait maison" />
|
||||
<p className="field-hint">
|
||||
Mots-clés que vos clients recherchent : produits, services, marques, quartier. Ils s'affichent sur
|
||||
votre fiche et sont transmis aux moteurs de recherche.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
|
||||
@@ -948,6 +948,13 @@ img {
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
/* Conseil de rédaction sous un champ : discret, mais lu avant de saisir. */
|
||||
.field-hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted-2);
|
||||
}
|
||||
|
||||
/* ---- Studio e-mail Plein R ---- */
|
||||
.email-workbench {
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ import { PromoImage } from "@/components/PromoImage";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import { getActiveMemberCount, getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
|
||||
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
||||
import { SITE_DESCRIPTION, SITE_TITLE, pageMetadata } from "@/lib/seo";
|
||||
import { SITE_DESCRIPTION, SITE_TITLE, memberPath, pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -235,7 +235,7 @@ export default async function AccueilPage() {
|
||||
{promos.map((p) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href={p.memberId ? `/adherents/${p.memberId}` : "#"}
|
||||
href={p.memberId ? memberPath({ id: p.memberId, name: p.memberName ?? "", city: p.memberCity }) : "#"}
|
||||
className="lift-card"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
|
||||
+22
-9
@@ -2,7 +2,8 @@ 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 { getCategoriesInUse } from "@/lib/queries";
|
||||
import { STATIC_ROUTES, absoluteUrl, categoryPath, memberPath } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -19,22 +20,34 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
priority: route.priority,
|
||||
}));
|
||||
|
||||
let rows: { id: number }[] = [];
|
||||
let rows: { id: number; name: string; city: string | null }[] = [];
|
||||
let categoriesInUse: { slug: string }[] = [];
|
||||
try {
|
||||
rows = await db
|
||||
.select({ id: members.id })
|
||||
.from(members)
|
||||
.where(eq(members.status, "active"))
|
||||
.orderBy(asc(members.id));
|
||||
[rows, categoriesInUse] = await Promise.all([
|
||||
db
|
||||
.select({ id: members.id, name: members.name, city: members.city })
|
||||
.from(members)
|
||||
.where(eq(members.status, "active"))
|
||||
.orderBy(asc(members.id)),
|
||||
getCategoriesInUse(),
|
||||
]);
|
||||
} catch {
|
||||
// Base indisponible : on livre au moins les pages statiques.
|
||||
}
|
||||
|
||||
// Pages métier : seulement celles qui ont au moins un adhérent (les autres
|
||||
// sont en noindex, une page vide n'apporte rien aux moteurs).
|
||||
const metiers: MetadataRoute.Sitemap = categoriesInUse.map((c) => ({
|
||||
url: absoluteUrl(base, categoryPath(c.slug)),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.8,
|
||||
}));
|
||||
|
||||
const fiches: MetadataRoute.Sitemap = rows.map((m) => ({
|
||||
url: absoluteUrl(base, memberPath(m.id)),
|
||||
url: absoluteUrl(base, memberPath(m)),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.7,
|
||||
}));
|
||||
|
||||
return [...statics, ...fiches];
|
||||
return [...statics, ...metiers, ...fiches];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { categoryPath } from "@/lib/seo";
|
||||
|
||||
export type CategoryLink = { slug: string; label: string; accent: string; memberCount: number };
|
||||
|
||||
/** Pastilles-liens vers les pages métier : contenu statique, lisible sans JavaScript. */
|
||||
export function CategoryLinks({ categories, currentSlug }: { categories: CategoryLink[]; currentSlug?: string }) {
|
||||
return (
|
||||
<nav aria-label="Parcourir par métier" style={{ display: "flex", flexWrap: "wrap", gap: 9 }}>
|
||||
{categories.map((c) => {
|
||||
const active = c.slug === currentSlug;
|
||||
return (
|
||||
<Link
|
||||
key={c.slug}
|
||||
href={categoryPath(c.slug)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
fontSize: 13.5,
|
||||
fontWeight: 600,
|
||||
padding: "8px 14px",
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${active ? "#13324F" : "#e6dcc6"}`,
|
||||
background: active ? "#13324F" : "#fff",
|
||||
color: active ? "#fff" : "#3c3322",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: active ? "#E0A63C" : c.accent, flexShrink: 0 }} />
|
||||
{c.label}
|
||||
<span style={{ opacity: 0.6 }}>{c.memberCount}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Link from "next/link";
|
||||
import { VitrineImage } from "@/components/VitrineImage";
|
||||
import { memberPath } from "@/lib/seo";
|
||||
|
||||
export type MemberCardData = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
city: string | null;
|
||||
address: string | null;
|
||||
coverUrl: string | null;
|
||||
logoUrl: string | null;
|
||||
categoryLabel: string | null;
|
||||
accent: string | null;
|
||||
hasPromo?: boolean;
|
||||
promoBadge?: string | null;
|
||||
};
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
const STRIPE_COOL =
|
||||
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 12px,#e2e8e6 12px,#e2e8e6 24px)";
|
||||
|
||||
/**
|
||||
* Carte d'un adhérent dans une grille (annuaire, page métier). Composant sans
|
||||
* état : il se rend aussi bien côté serveur que dans le filtre client.
|
||||
*/
|
||||
export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number }) {
|
||||
return (
|
||||
<Link
|
||||
href={memberPath(m)}
|
||||
className="lift-card"
|
||||
style={{ textDecoration: "none", color: "inherit", background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, overflow: "hidden", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<VitrineImage
|
||||
coverUrl={m.coverUrl}
|
||||
logoUrl={m.logoUrl}
|
||||
height={150}
|
||||
stripe={index % 2 === 0 ? STRIPE_WARM : STRIPE_COOL}
|
||||
>
|
||||
{m.categoryLabel && (
|
||||
<span style={{ position: "absolute", top: 12, left: 12, background: m.accent ?? "#9a6638", color: "#fff", borderRadius: 999, padding: "5px 12px", fontSize: 11, fontWeight: 700 }}>
|
||||
{m.categoryLabel}
|
||||
</span>
|
||||
)}
|
||||
{m.hasPromo && m.promoBadge && (
|
||||
<span className="font-display" style={{ position: "absolute", top: 0, right: 0, background: "#d8472b", color: "#fff", fontWeight: 800, fontSize: 12.5, padding: "6px 12px", borderRadius: "0 0 0 14px" }}>
|
||||
{m.promoBadge}
|
||||
</span>
|
||||
)}
|
||||
</VitrineImage>
|
||||
<div style={{ padding: "16px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
|
||||
{m.name}
|
||||
</h3>
|
||||
<p style={{ margin: "6px 0 12px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
|
||||
{m.description}
|
||||
</p>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, color: "#6c6150", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C" }} />
|
||||
{[m.address, m.city].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { VitrineImage } from "./VitrineImage";
|
||||
import { memberPath } from "@/lib/seo";
|
||||
|
||||
export type CarouselMember = {
|
||||
id: number;
|
||||
@@ -24,7 +25,7 @@ const FADE_MS = 450;
|
||||
function Card({ m, visible, index }: { m: CarouselMember; visible: boolean; index: number }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/adherents/${m.id}`}
|
||||
href={memberPath(m)}
|
||||
className="lift-card"
|
||||
style={{
|
||||
background: "#fff",
|
||||
|
||||
@@ -69,6 +69,7 @@ export async function getLivePromotions(limit = 6) {
|
||||
validUntil: promotions.validUntil,
|
||||
memberId: promotions.memberId,
|
||||
memberName: members.name,
|
||||
memberCity: members.city,
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
@@ -174,3 +175,44 @@ export async function getActiveMemberCount(): Promise<number> {
|
||||
.where(eq(members.status, "active"));
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
/** Une catégorie par son slug d'URL, ou `null`. */
|
||||
export async function getCategoryBySlug(slug: string) {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: categories.id,
|
||||
slug: categories.slug,
|
||||
label: categories.label,
|
||||
accent: categories.accent,
|
||||
tint: categories.tint,
|
||||
})
|
||||
.from(categories)
|
||||
.where(eq(categories.slug, slug));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Adhérents actifs d'une catégorie, pour la page métier de l'annuaire. */
|
||||
export async function getActiveMembersByCategory(categoryId: number) {
|
||||
return db
|
||||
.select({
|
||||
id: members.id,
|
||||
name: members.name,
|
||||
description: members.description,
|
||||
city: members.city,
|
||||
address: members.address,
|
||||
coverUrl: members.coverUrl,
|
||||
logoUrl: members.logoUrl,
|
||||
categoryLabel: categories.label,
|
||||
accent: categories.accent,
|
||||
})
|
||||
.from(members)
|
||||
.leftJoin(categories, eq(members.categoryId, categories.id))
|
||||
.where(and(eq(members.status, "active"), eq(members.categoryId, categoryId)))
|
||||
.orderBy(asc(members.name));
|
||||
}
|
||||
|
||||
/** Catégories ayant au moins un adhérent actif, avec leur effectif. */
|
||||
export async function getCategoriesInUse() {
|
||||
const rows = await getCategoriesWithCounts(1000);
|
||||
return rows.filter((c) => Number(c.memberCount) > 0);
|
||||
}
|
||||
+30
-6
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { DaySchedule, WeekDayKey } from "@/lib/member-profile";
|
||||
import type { SiteSettings, SocialLink } from "@/lib/site-settings";
|
||||
import { slugify } from "@/lib/slug";
|
||||
|
||||
/**
|
||||
* Référencement : constantes, métadonnées et données structurées (JSON-LD).
|
||||
@@ -83,9 +84,32 @@ export function absoluteUrl(baseUrl: string, path = "/"): string {
|
||||
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
/** Chemin canonique d'une fiche adhérent. */
|
||||
export function memberPath(id: number): string {
|
||||
return `/adherents/${id}`;
|
||||
export type MemberPathInput = { id: number; name: string; city?: string | null };
|
||||
|
||||
/**
|
||||
* Chemin canonique d'une fiche adhérent : `/adherents/12-au-bon-pain-frouard`.
|
||||
*
|
||||
* L'identifiant reste en tête : il suffit à retrouver la fiche, et le nom qui
|
||||
* suit n'est là que pour les moteurs et les lecteurs. Toute autre écriture
|
||||
* (`/adherents/12`, ancien nom…) est redirigée en 301 vers cette forme par la
|
||||
* page, donc un renommage ne casse aucun lien.
|
||||
*/
|
||||
export function memberPath(member: MemberPathInput): string {
|
||||
const slug = slugify([member.name, member.city ?? ""].filter(Boolean).join(" "));
|
||||
return slug ? `/adherents/${member.id}-${slug}` : `/adherents/${member.id}`;
|
||||
}
|
||||
|
||||
/** Identifiant numérique en tête du paramètre d'URL (`12-au-bon-pain` → 12), sinon `null`. */
|
||||
export function parseMemberParam(param: string): number | null {
|
||||
const match = /^(\d+)(?:-|$)/.exec(param);
|
||||
if (!match) return null;
|
||||
const id = Number(match[1]);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
/** Chemin d'une page métier de l'annuaire. */
|
||||
export function categoryPath(slug: string): string {
|
||||
return `/annuaire/${slug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,7 +283,7 @@ export function localBusinessJsonLd(
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const url = absoluteUrl(baseUrl, memberPath(member.id));
|
||||
const url = absoluteUrl(baseUrl, memberPath(member));
|
||||
return compact({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
@@ -325,7 +349,7 @@ export function eventJsonLd(baseUrl: string, meeting: EventInput) {
|
||||
});
|
||||
}
|
||||
|
||||
export type ItemListEntry = { id: number; name: string; description?: string | null };
|
||||
export type ItemListEntry = MemberPathInput;
|
||||
|
||||
/** Liste d'adhérents (annuaire) : chaque entrée pointe vers sa fiche. */
|
||||
export function memberListJsonLd(baseUrl: string, name: string, members: ItemListEntry[]) {
|
||||
@@ -337,7 +361,7 @@ export function memberListJsonLd(baseUrl: string, name: string, members: ItemLis
|
||||
itemListElement: members.map((m, index) => ({
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
url: absoluteUrl(baseUrl, memberPath(m.id)),
|
||||
url: absoluteUrl(baseUrl, memberPath(m)),
|
||||
name: m.name,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Slug ASCII minuscule : accents retirés, tout le reste devient un tiret. */
|
||||
export function slugify(input: string, maxLength = 70): string {
|
||||
return input
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, maxLength)
|
||||
.replace(/-+$/g, "");
|
||||
}
|
||||
+5
-1
@@ -15,6 +15,7 @@ import {
|
||||
siteUrl,
|
||||
type SocialNetwork,
|
||||
} from "./social-accounts";
|
||||
import { memberPath } from "./seo";
|
||||
|
||||
export {
|
||||
configuredNetworks,
|
||||
@@ -37,12 +38,15 @@ export type PromoForSharing = {
|
||||
imageUrl: string | null;
|
||||
memberId: number | null;
|
||||
memberName: string | null;
|
||||
memberCity?: string | null;
|
||||
};
|
||||
|
||||
export async function promoLink(promo: PromoForSharing): Promise<string | null> {
|
||||
const base = await siteUrl();
|
||||
if (!base) return null;
|
||||
return promo.memberId ? `${base}/adherents/${promo.memberId}` : `${base}/#promotions`;
|
||||
return promo.memberId
|
||||
? `${base}${memberPath({ id: promo.memberId, name: promo.memberName ?? "", city: promo.memberCity })}`
|
||||
: `${base}/#promotions`;
|
||||
}
|
||||
|
||||
/** Texte du post, commun aux deux réseaux. */
|
||||
|
||||
+20
-4
@@ -8,6 +8,9 @@ import {
|
||||
STATIC_ROUTES,
|
||||
absoluteUrl,
|
||||
breadcrumbJsonLd,
|
||||
categoryPath,
|
||||
memberPath,
|
||||
parseMemberParam,
|
||||
eventJsonLd,
|
||||
localBusinessJsonLd,
|
||||
memberListJsonLd,
|
||||
@@ -30,6 +33,19 @@ describe("SEO — URLs et descriptions", () => {
|
||||
assert.equal(absoluteUrl(BASE, "adherents/3"), `${BASE}/adherents/3`);
|
||||
});
|
||||
|
||||
it("construit des URLs de fiche parlantes et les relit", () => {
|
||||
assert.equal(memberPath({ id: 12, name: "Au Bon Pain", city: "Frouard" }), "/adherents/12-au-bon-pain-frouard");
|
||||
assert.equal(memberPath({ id: 3, name: "Épicerie « Chez Léa »", city: null }), "/adherents/3-epicerie-chez-lea");
|
||||
assert.equal(memberPath({ id: 7, name: "???" }), "/adherents/7");
|
||||
for (const param of ["12-au-bon-pain-frouard", "12-ancien-nom", "12"]) {
|
||||
assert.equal(parseMemberParam(param), 12, param);
|
||||
}
|
||||
for (const param of ["abc", "-12", "12abc", "0", ""]) {
|
||||
assert.equal(parseMemberParam(param), null, param);
|
||||
}
|
||||
assert.equal(categoryPath("boulangerie"), "/annuaire/boulangerie");
|
||||
});
|
||||
|
||||
it("borne la meta description sur un mot entier", () => {
|
||||
const long = "Boulangerie artisanale ".repeat(20);
|
||||
const out = metaDescription(long);
|
||||
@@ -116,7 +132,7 @@ describe("SEO — données structurées", () => {
|
||||
};
|
||||
const biz = localBusinessJsonLd(BASE, member, [], null) as Record<string, unknown>;
|
||||
assert.equal(biz["@type"], "LocalBusiness");
|
||||
assert.equal(biz.url, `${BASE}/adherents/7`);
|
||||
assert.equal(biz.url, `${BASE}/adherents/7-au-bon-pain-pompey`);
|
||||
assert.equal((biz.address as { addressLocality: string }).addressLocality, "Pompey");
|
||||
assert.equal(biz.keywords, "pain, viennoiseries");
|
||||
assert.ok(!("email" in biz));
|
||||
@@ -124,7 +140,7 @@ describe("SEO — données structurées", () => {
|
||||
|
||||
const withSite = localBusinessJsonLd(BASE, member, [], "https://aubonpain.fr") as Record<string, unknown>;
|
||||
assert.equal(withSite.url, "https://aubonpain.fr");
|
||||
assert.equal(withSite.mainEntityOfPage, `${BASE}/adherents/7`);
|
||||
assert.equal(withSite.mainEntityOfPage, `${BASE}/adherents/7-au-bon-pain-pompey`);
|
||||
});
|
||||
|
||||
it("décrit une rencontre à venir avec ses places restantes", () => {
|
||||
@@ -144,9 +160,9 @@ describe("SEO — données structurées", () => {
|
||||
});
|
||||
|
||||
it("liste les adhérents de l'annuaire", () => {
|
||||
const list = memberListJsonLd(BASE, "Annuaire", [{ id: 1, name: "A" }, { id: 2, name: "B" }]);
|
||||
const list = memberListJsonLd(BASE, "Annuaire", [{ id: 1, name: "A" }, { id: 2, name: "Bar du Port", city: "Pompey" }]);
|
||||
assert.equal(list.numberOfItems, 2);
|
||||
assert.equal(list.itemListElement[1].url, `${BASE}/adherents/2`);
|
||||
assert.equal(list.itemListElement[1].url, `${BASE}/adherents/2-bar-du-port-pompey`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user