diff --git a/CLAUDE.md b/CLAUDE.md
index 9e95189..00795b5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -68,6 +68,14 @@ ne sert plus que de repli d'affichage. La même phrase part dans le message
Facebook / LinkedIn (`buildPromoMessage`) : un seul formateur pour le site,
le backoffice et les réseaux.
+La liste publique vit sur **`/promotions`** (toutes les offres en cours) ;
+l'accueil n'en montre que les six dernières et renvoie vers elle. Les deux
+listes partagent `src/components/PromoCard.tsx` : un seul rendu, pas deux
+cartes à maintenir. `getLivePromotions(limit)` accepte `null` pour « tout »,
+la limite restant appliquée en SQL. Les liens « Promotions » de l'en-tête, du
+pied de page et des publications réseaux pointent sur cette page, plus sur
+l'ancre `/#promotions`.
+
Une promotion n'est **affichée que pendant sa période** : les lectures de
`src/lib/queries.ts` passent par `VISIBLE_PROMO` (statut `live` **et** fenêtre
de dates, journée calculée en `Europe/Paris` — un conteneur en UTC retirerait
diff --git a/src/app/backend/espace/promotions/page.tsx b/src/app/backend/espace/promotions/page.tsx
index b407020..78bf501 100644
--- a/src/app/backend/espace/promotions/page.tsx
+++ b/src/app/backend/espace/promotions/page.tsx
@@ -201,7 +201,7 @@ export default async function EspacePromotionsPage() {
{myPromos.length > 0 && (
- Les offres en ligne apparaissent sur la page d'accueil
+ Les offres en ligne apparaissent sur la page des promotions
{publicPath ? (
<>
{" "}et sur votre fiche
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 9d07b76..168c408 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -7,13 +7,11 @@ import { SiteFooter } from "@/components/SiteFooter";
import { MembershipModalButton } from "@/components/MembershipModalButton";
import { MembersCarousel } from "@/components/MembersCarousel";
import { StarField } from "@/components/StarField";
-import { PromoImage } from "@/components/PromoImage";
-import { MemberAvatar } from "@/components/MemberAvatar";
+import { PromoCard } from "@/components/PromoCard";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { getActiveMemberCount, getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
-import { formatValidity } from "@/lib/promo-validity";
-import { SITE_DESCRIPTION, SITE_TITLE, memberPath, pageMetadata } from "@/lib/seo";
+import { SITE_DESCRIPTION, SITE_TITLE, pageMetadata } from "@/lib/seo";
export const dynamic = "force-dynamic";
@@ -28,14 +26,6 @@ export const metadata: Metadata = {
title: { absolute: SITE_TITLE },
};
-const STRIPE_WARM =
- "repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
-
-function badgeColor(badge: string | null) {
- if (!badge) return "#1f8a5b";
- return badge.includes("%") ? "#d8472b" : "#1f8a5b";
-}
-
export default async function AccueilPage() {
const [promos, rotatingMembers, settings, memberCount] = await Promise.all([
getLivePromotions(6),
@@ -228,107 +218,14 @@ export default async function AccueilPage() {
Offres, réductions et nouveautés publiées par les commerçants du réseau.
- {/* Le logo du commerce juste à gauche de son nom. */}
-
-
-
-
{p.memberName}
- {formatValidity(p) && (
-
{formatValidity(p)}
- )}
-
-
-
-
-
+
))}
diff --git a/src/app/promotions/page.tsx b/src/app/promotions/page.tsx
new file mode 100644
index 0000000..37bd08a
--- /dev/null
+++ b/src/app/promotions/page.tsx
@@ -0,0 +1,135 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { Sparkle } from "@/components/Sparkle";
+import { SiteHeader } from "@/components/SiteHeader";
+import { SiteFooter } from "@/components/SiteFooter";
+import { PromoCard } from "@/components/PromoCard";
+import { JsonLd } from "@/components/JsonLd";
+import { getLivePromotions } from "@/lib/queries";
+import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
+import { publicBaseUrl } from "@/lib/seo-server";
+
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = pageMetadata({
+ title: "Bons plans et promotions du Bassin de Pompey",
+ description:
+ "Toutes les offres en cours chez les commerçants, artisans et entreprises adhérents Plein R : " +
+ "réductions, nouveautés et bons plans à Pompey, Frouard, Liverdun, Champigneulles…",
+ path: "/promotions",
+});
+
+export default async function PromotionsPage() {
+ // `null` : toutes les offres en cours, là où l'accueil n'en montre que six.
+ const [promos, baseUrl] = await Promise.all([getLivePromotions(null), publicBaseUrl()]);
+
+ // Une offre pointe vers la fiche de son commerçant : la liste structurée
+ // renvoie donc vers les adhérents, pas vers des URLs de promotion.
+ const listed = promos
+ .filter((p) => p.memberId != null)
+ .map((p) => ({ id: p.memberId as number, name: p.memberName ?? "", city: p.memberCity }));
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Bons plans
+
+
+ Les promotions de nos adhérents
+
+
+ Toutes les offres en cours chez les commerçants, artisans et entreprises du réseau.
+ Chaque promotion renvoie vers la fiche de son commerce.
+
+
+ {promos.length === 0 ? (
+
+ Aucune promotion en cours pour le moment.
+
+
+ Parcourir l'annuaire des adhérents →
+
+
+ ) : (
+ <>
+
+ {promos.length} offre{promos.length > 1 ? "s" : ""} en cours
+
+
+ {promos.map((p) => (
+
+ ))}
+
+ >
+ )}
+
+
+
+ Vous êtes commerçant du Bassin de Pompey ?{" "}
+
+ Rejoignez Plein R
+ {" "}
+ pour publier vos offres ici.
+
+
+
+
+
+ );
+}
diff --git a/src/components/PromoCard.tsx b/src/components/PromoCard.tsx
new file mode 100644
index 0000000..da580af
--- /dev/null
+++ b/src/components/PromoCard.tsx
@@ -0,0 +1,129 @@
+/**
+ * Carte d'une promotion sur le site public.
+ *
+ * Un seul rendu pour l'accueil (les 6 dernières) et la page `/promotions`
+ * (toutes les offres en cours) : les deux listes doivent rester identiques.
+ */
+
+import Link from "next/link";
+import { PromoImage } from "@/components/PromoImage";
+import { MemberAvatar } from "@/components/MemberAvatar";
+import { formatValidity } from "@/lib/promo-validity";
+import { memberPath } from "@/lib/seo";
+
+const STRIPE_WARM =
+ "repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
+
+/** Rouge pour une remise chiffrée, vert pour une offre en nature. */
+function badgeColor(badge: string | null) {
+ if (!badge) return "#1f8a5b";
+ return badge.includes("%") ? "#d8472b" : "#1f8a5b";
+}
+
+export type PublicPromo = {
+ id: number;
+ title: string;
+ text: string | null;
+ category: string | null;
+ badge: string | null;
+ imageUrl: string | null;
+ validUntil: string | null;
+ startsOn: string | null;
+ endsOn: string | null;
+ memberId: number | null;
+ memberName: string | null;
+ memberCity: string | null;
+ memberLogoUrl: string | null;
+};
+
+export function PromoCard({ promo: p }: { promo: PublicPromo }) {
+ const validity = formatValidity(p);
+ return (
+
+